diff --git a/README.md b/README.md
index ff84d02..633f039 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@ ThreadLocal (JEP 506)**. Every number and transcript in the post comes from a fi
Next LTS: Java 29, September 2027 (Oracle Java SE Support Roadmap). Layout: `g1-container/`, `object-headers/`, `jep-tour/` (27 features and `broken/` 26-era sources),
`recap26/` (the Java 26 lane), `lanes/` (the 21 to 25 hub demos, the AI prompts in `lanes/prompts/` and the build-file demo in `lanes/upgrade/`), `other-changes/`, `api-diff/`,
-`gatherers/` (JEP 485, standalone post), `scoped-values/` (JEP 506 vs `ThreadLocal`, standalone post).
+`gatherers/` (JEP 485, standalone post), `scoped-values/` (JEP 506 vs `ThreadLocal`, standalone post). `jdk27-perf/` (the same Spring Boot service on JDK 25 and 27: footprint, throughput, p99; its README and `output/` replace a `docs/` folder).
## Quickstart
diff --git a/jdk27-perf/README.md b/jdk27-perf/README.md
new file mode 100644
index 0000000..6fe3036
--- /dev/null
+++ b/jdk27-perf/README.md
@@ -0,0 +1,70 @@
+# jdk27-perf — what actually got faster in JDK 27, measured on one Spring Boot service
+
+Companion module for the ankurm.com post **What Actually Got Faster in JDK 27: Measured on a Spring Boot App**
+().
+No `docs/` folder: this README and the raw files in [`output/`](output) are the documentation.
+
+## Question
+
+JDK 27 changed two defaults that matter to a server: compact object headers are on (JEP 534) and G1 is the default collector everywhere (JEP 523).
+Does the same Spring Boot service get faster, smaller, or neither when you move from JDK 25 to JDK 27 with no flags?
+
+## Setup
+
+| Thing | Value |
+|---|---|
+| JDK 25 | Temurin 25.0.4.1+1 (LTS) |
+| JDK 27 | Temurin 27+35 (GA 2026-09-15) |
+| Spring Boot | 4.1.1, `spring-boot-starter-webmvc` |
+| Machine | 2 vCPU, 8 GB RAM Linux sandbox (the load client shares the box, so absolute numbers are modest and noisy) |
+| Service | 3,000,000 resident `Item(int id, int qty)` records; `/quote?n=200` builds 200 response records; Jackson serialises them |
+| Load | closed loop, 4 client threads, 15 s warmup discarded, 30 s measured, client always JDK 25 |
+| Repetitions | 3 per configuration, interleaved so machine drift hits all four equally |
+
+Four configurations: `jdk25 defaults`, `jdk25 +CompactObjectHeaders` (opt in), `jdk27 defaults`, `jdk27 -CompactObjectHeaders` (opt out).
+[`output/01-defaults.txt`](output/01-defaults.txt) shows both JDKs pick G1 on this machine, so the collector is the same everywhere and only headers and the rest of the JDK differ.
+
+## Quick start
+
+```console
+mvn -B -DskipTests package # build with JDK 25 or later
+export J25=/path/to/jdk-25 J27=/path/to/jdk-27
+scripts/run-all.sh # about 12 minutes; regenerates output/
+REPS=5 scripts/run-all.sh # more repetitions
+```
+
+## Results
+
+Medians of 3 runs ([`output/03-summary.txt`](output/03-summary.txt), raw blocks in [`output/02-runs.txt`](output/02-runs.txt)):
+
+| Config | Startup ms | Throughput req/s (range) | p99 ms |
+|---|---|---|---|
+| jdk25 defaults | 3437 | 6788 (6239-6868) | 3.23 |
+| jdk25 +CompactObjectHeaders | 3512 | 7157 (6407-7228) | 3.09 |
+| jdk27 defaults | 3220 | 7016 (6755-7275) | 2.99 |
+| jdk27 -CompactObjectHeaders | 3388 | 7010 (6867-7227) | 3.03 |
+
+Resident data set after a forced full GC ([`output/04-live-set.txt`](output/04-live-set.txt), identical in all three repetitions):
+
+| Config | Live heap MiB |
+|---|---|
+| jdk25 defaults | 93 |
+| jdk25 +CompactObjectHeaders | 69 |
+| jdk27 defaults | 70 |
+| jdk27 -CompactObjectHeaders | 93-94 |
+
+## What it says
+
+- **Footprint is the clear win.** Compact headers cut the live heap by 23 to 24 MiB (whole-MiB rounding). That matches 3,000,000 objects times 8 bytes saved, which is 22.9 MiB. A two-int record goes from 24 to 16 bytes.
+- **Speed is not distinguishable.** The throughput ranges overlap in every row and p99 differs by fractions of a millisecond. On this box I cannot claim JDK 27 is faster.
+- **Startup is inconclusive.** JDK 27 defaults had the lowest median, but the individual runs overlap with JDK 25.
+- Resident set size and committed heap vary run to run with G1's sizing decisions; see the per-run `/stats` lines in `02-runs.txt` before drawing a conclusion from them.
+
+## Honest notes
+
+- `output/02-runs.txt` was produced by the first build of the jar. The `/live` endpoint used for `04-live-set.txt` was added afterwards; it does not touch the `/quote` path, but strictly the two files come from two builds of this module.
+- Timings drift. Treat them as indicative.
+
+## Licence
+
+MIT, as the parent repository.
diff --git a/jdk27-perf/output/01-defaults.txt b/jdk27-perf/output/01-defaults.txt
new file mode 100644
index 0000000..47470a4
--- /dev/null
+++ b/jdk27-perf/output/01-defaults.txt
@@ -0,0 +1,16 @@
+== JDK 25
+openjdk version "25.0.4.1" 2026-08-18 LTS
+ size_t MaxHeapSize = 1568669696 {product} {ergonomic}
+ bool UseCompactObjectHeaders = false {product lp64_product} {default}
+ bool UseG1GC = true {product} {ergonomic}
+ bool UseSerialGC = false {product} {default}
+ bool UseZGC = false {product} {default}
+
+== JDK 27
+openjdk version "27" 2026-09-15
+ size_t MaxHeapSize = 1568669696 {product} {ergonomic}
+ bool UseCompactObjectHeaders = true {product lp64_product} {default}
+ bool UseG1GC = true {product} {ergonomic}
+ bool UseSerialGC = false {product} {default}
+ bool UseZGC = false {product} {default}
+
diff --git a/jdk27-perf/output/02-runs.txt b/jdk27-perf/output/02-runs.txt
new file mode 100644
index 0000000..2aefd15
--- /dev/null
+++ b/jdk27-perf/output/02-runs.txt
@@ -0,0 +1,48 @@
+== jdk25 defaults
+startup_ms=3437
+requests=203628 throughput=6788 req/s p50=0.45 ms p99=3.23 ms p99.9=7.27 ms max=27.80 ms
+{"javaVersion":"25.0.4.1","gc":["G1 Young Generation","G1 Concurrent GC","G1 Old Generation"],"compactHeaders":"false","heapUsedMiB":162,"heapCommittedMiB":252,"rssMiB":403}
+== jdk25 +CompactObjectHeaders
+startup_ms=3658
+requests=214723 throughput=7157 req/s p50=0.43 ms p99=2.88 ms p99.9=6.60 ms max=31.57 ms
+{"javaVersion":"25.0.4.1","gc":["G1 Young Generation","G1 Concurrent GC","G1 Old Generation"],"compactHeaders":"true","heapUsedMiB":114,"heapCommittedMiB":219,"rssMiB":361}
+== jdk27 defaults
+startup_ms=3166
+requests=218262 throughput=7275 req/s p50=0.44 ms p99=2.76 ms p99.9=6.49 ms max=20.31 ms
+{"javaVersion":"27","gc":["G1 Young Generation","G1 Concurrent GC","G1 Old Generation"],"compactHeaders":"true","heapUsedMiB":76,"heapCommittedMiB":284,"rssMiB":434}
+== jdk27 -CompactObjectHeaders
+startup_ms=3363
+requests=216804 throughput=7227 req/s p50=0.43 ms p99=3.00 ms p99.9=6.46 ms max=15.04 ms
+{"javaVersion":"27","gc":["G1 Young Generation","G1 Concurrent GC","G1 Old Generation"],"compactHeaders":"false","heapUsedMiB":155,"heapCommittedMiB":310,"rssMiB":458}
+== jdk25 defaults
+startup_ms=3563
+requests=187160 throughput=6239 req/s p50=0.46 ms p99=4.36 ms p99.9=8.83 ms max=35.12 ms
+{"javaVersion":"25.0.4.1","gc":["G1 Young Generation","G1 Concurrent GC","G1 Old Generation"],"compactHeaders":"false","heapUsedMiB":108,"heapCommittedMiB":177,"rssMiB":325}
+== jdk25 +CompactObjectHeaders
+startup_ms=3512
+requests=216853 throughput=7228 req/s p50=0.43 ms p99=3.09 ms p99.9=6.54 ms max=20.85 ms
+{"javaVersion":"25.0.4.1","gc":["G1 Young Generation","G1 Concurrent GC","G1 Old Generation"],"compactHeaders":"true","heapUsedMiB":158,"heapCommittedMiB":228,"rssMiB":363}
+== jdk27 defaults
+startup_ms=3220
+requests=202647 throughput=6755 req/s p50=0.46 ms p99=3.20 ms p99.9=7.43 ms max=24.21 ms
+{"javaVersion":"27","gc":["G1 Young Generation","G1 Concurrent GC","G1 Old Generation"],"compactHeaders":"true","heapUsedMiB":238,"heapCommittedMiB":289,"rssMiB":432}
+== jdk27 -CompactObjectHeaders
+startup_ms=3539
+requests=206016 throughput=6867 req/s p50=0.45 ms p99=3.21 ms p99.9=6.98 ms max=54.74 ms
+{"javaVersion":"27","gc":["G1 Young Generation","G1 Concurrent GC","G1 Old Generation"],"compactHeaders":"false","heapUsedMiB":189,"heapCommittedMiB":313,"rssMiB":454}
+== jdk25 defaults
+startup_ms=3419
+requests=206034 throughput=6868 req/s p50=0.45 ms p99=3.14 ms p99.9=6.58 ms max=21.56 ms
+{"javaVersion":"25.0.4.1","gc":["G1 Young Generation","G1 Concurrent GC","G1 Old Generation"],"compactHeaders":"false","heapUsedMiB":204,"heapCommittedMiB":273,"rssMiB":415}
+== jdk25 +CompactObjectHeaders
+startup_ms=3433
+requests=192205 throughput=6407 req/s p50=0.47 ms p99=3.75 ms p99.9=7.58 ms max=22.22 ms
+{"javaVersion":"25.0.4.1","gc":["G1 Young Generation","G1 Concurrent GC","G1 Old Generation"],"compactHeaders":"true","heapUsedMiB":87,"heapCommittedMiB":154,"rssMiB":313}
+== jdk27 defaults
+startup_ms=3602
+requests=210490 throughput=7016 req/s p50=0.45 ms p99=2.99 ms p99.9=6.26 ms max=16.33 ms
+{"javaVersion":"27","gc":["G1 Young Generation","G1 Concurrent GC","G1 Old Generation"],"compactHeaders":"true","heapUsedMiB":128,"heapCommittedMiB":229,"rssMiB":386}
+== jdk27 -CompactObjectHeaders
+startup_ms=3388
+requests=210293 throughput=7010 req/s p50=0.45 ms p99=3.03 ms p99.9=6.68 ms max=32.18 ms
+{"javaVersion":"27","gc":["G1 Young Generation","G1 Concurrent GC","G1 Old Generation"],"compactHeaders":"false","heapUsedMiB":227,"heapCommittedMiB":311,"rssMiB":475}
diff --git a/jdk27-perf/output/03-summary.txt b/jdk27-perf/output/03-summary.txt
new file mode 100644
index 0000000..a8a47da
--- /dev/null
+++ b/jdk27-perf/output/03-summary.txt
@@ -0,0 +1,4 @@
+jdk25 +CompactObjectHeaders | startup 3512 ms | throughput 7157 req/s | p99 3.09 ms | heap used 114 MiB | RSS 361 MiB
+jdk25 defaults | startup 3437 ms | throughput 6788 req/s | p99 3.23 ms | heap used 162 MiB | RSS 403 MiB
+jdk27 -CompactObjectHeaders | startup 3388 ms | throughput 7010 req/s | p99 3.03 ms | heap used 189 MiB | RSS 458 MiB
+jdk27 defaults | startup 3220 ms | throughput 7016 req/s | p99 2.99 ms | heap used 128 MiB | RSS 432 MiB
diff --git a/jdk27-perf/output/04-live-set.txt b/jdk27-perf/output/04-live-set.txt
new file mode 100644
index 0000000..b6e07aa
--- /dev/null
+++ b/jdk27-perf/output/04-live-set.txt
@@ -0,0 +1,12 @@
+jdk25 defaults: live heap after full GC 93 MiB
+jdk25 +CompactObjectHeaders: live heap after full GC 69 MiB
+jdk27 defaults: live heap after full GC 70 MiB
+jdk27 -CompactObjectHeaders: live heap after full GC 93 MiB
+jdk25 defaults: live heap after full GC 93 MiB
+jdk25 +CompactObjectHeaders: live heap after full GC 69 MiB
+jdk27 defaults: live heap after full GC 70 MiB
+jdk27 -CompactObjectHeaders: live heap after full GC 94 MiB
+jdk25 defaults: live heap after full GC 93 MiB
+jdk25 +CompactObjectHeaders: live heap after full GC 69 MiB
+jdk27 defaults: live heap after full GC 70 MiB
+jdk27 -CompactObjectHeaders: live heap after full GC 94 MiB
diff --git a/jdk27-perf/pom.xml b/jdk27-perf/pom.xml
new file mode 100644
index 0000000..d9bf098
--- /dev/null
+++ b/jdk27-perf/pom.xml
@@ -0,0 +1,33 @@
+
+
+ 4.0.0
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 4.1.1
+
+
+ com.ankurm
+ jdk27-perf
+ 1.0.0
+ jdk27-perf
+ Same Spring Boot service measured on JDK 25 and JDK 27
+
+ 25
+
+
+
+ org.springframework.boot
+ spring-boot-starter-webmvc
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
diff --git a/jdk27-perf/scripts/Load.java b/jdk27-perf/scripts/Load.java
new file mode 100644
index 0000000..45253c7
--- /dev/null
+++ b/jdk27-perf/scripts/Load.java
@@ -0,0 +1,42 @@
+import java.net.URI;
+import java.net.http.*;
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.*;
+
+/** Closed-loop load: THREADS threads, WARMUP seconds discarded, then MEASURE seconds recorded. */
+public class Load {
+ public static void main(String[] a) throws Exception {
+ int port = Integer.parseInt(a[0]), warmup = Integer.parseInt(a[1]), measure = Integer.parseInt(a[2]), threads = Integer.parseInt(a[3]);
+ HttpClient c = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build();
+ HttpRequest rq = HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/quote?n=200")).build();
+ long start = System.nanoTime();
+ long measureFrom = start + warmup * 1_000_000_000L, end = measureFrom + measure * 1_000_000_000L;
+ List perThread = new CopyOnWriteArrayList<>();
+ ExecutorService ex = Executors.newFixedThreadPool(threads);
+ List> fs = new ArrayList<>();
+ for (int t = 0; t < threads; t++) fs.add(ex.submit(() -> {
+ long[] buf = new long[2_000_000]; int n = 0;
+ try {
+ while (true) {
+ long s = System.nanoTime();
+ if (s >= end) break;
+ c.send(rq, HttpResponse.BodyHandlers.discarding());
+ long e = System.nanoTime();
+ if (s >= measureFrom && n < buf.length) buf[n++] = e - s;
+ }
+ } catch (Exception e) { throw new RuntimeException(e); }
+ perThread.add(Arrays.copyOf(buf, n));
+ return null;
+ }));
+ for (Future> f : fs) f.get();
+ ex.shutdown();
+ long total = 0; for (long[] b : perThread) total += b.length;
+ long[] all = new long[(int) total]; int p = 0;
+ for (long[] b : perThread) { System.arraycopy(b, 0, all, p, b.length); p += b.length; }
+ Arrays.sort(all);
+ System.out.printf("requests=%d throughput=%.0f req/s p50=%.2f ms p99=%.2f ms p99.9=%.2f ms max=%.2f ms%n",
+ total, total / (double) measure, all[(int) (total * 0.50)] / 1e6, all[(int) (total * 0.99)] / 1e6,
+ all[(int) (total * 0.999)] / 1e6, all[(int) total - 1] / 1e6);
+ }
+}
diff --git a/jdk27-perf/scripts/live-set.sh b/jdk27-perf/scripts/live-set.sh
new file mode 100755
index 0000000..97eb5d9
--- /dev/null
+++ b/jdk27-perf/scripts/live-set.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+# Usage: live-set.sh