
Testing Next.js App Router for Single-Request RSC Denial-of-Service
A single GET request shouldn't be able to take down a Next.js App Router deployment, but that's exactly the claim behind the recent React Server Components denial-of-service headlines. Rather than reshare the news, I built a small local lab to test the mechanism myself. This post walks through how the App Router turns a plain HTTP request into server-side compute, shows a bounded RSC stress test with measured results, and lays out the defensive configuration I would ship first. The headline isn't the interesting part: in the App Router, rendering is reachable over plain HTTP, so any expensive or uncached server component is effectively a compute endpoint that anyone can call as often as they want.
I won't pretend I reproduced a specific published exploit. I tested the mechanism generically, on localhost, and I'm keeping what I measured separate from what I read.
What the Report Claims About Single-Request RSC Denial-of-Service
The source I had was a GBHackers News item published 2026-10-09, titled "React Server Components Vulnerability Lets Attackers Freeze Next.js Servers With a Single Request." The snippet states that a vulnerability in React Server Components allows attackers to freeze Next.js servers with a single crafted request, that applications using RSC are affected, and that the outcome is denial of service. I pulled it through Google News syndication; no direct permalink, no full article text.
What the seed omits matters as much as what it says:
- no CVE ID
- no affected version range
- no reproduction steps or request shape
- no link to an upstream advisory, patch commit, or vendor statement
That shapes how you should read this post. I can't confirm the crafted request the report describes, and I won't invent a CVE ID or a version range to make the story feel complete. What I can do is test the underlying mechanism — RSC renders reachable by unauthenticated GET requests — and show what that costs the server. Treat my numbers as a lab measurement of the class, not a fingerprint of one advisory.
Everything below was run against my own machine on 127.0.0.1. Do not point load tests at systems you do not own or have written permission to test. A "bounded RSC load test" against someone else's production is just a denial-of-service attempt with better documentation.
Why a Single RSC Request Costs So Much Server Time
The RSC Payload Path Is a Render, Not a Cached Lookup
A cached REST GET is cheap because the server does the same amount of work no matter how many clients ask: look up a key, return bytes, done. The App Router doesn't work that way. When a client navigates, prefetches a link, or submits a router action, the browser asks the server for a Flight payload. In my testing, a plain curl with the RSC: 1 header and a ?_rsc= query parameter came back with a Flight payload instead of HTML — the request entered the render path, not a static file path.
That means a router prefetch, which the app fires automatically when a link scrolls into view, is a request that executes server components. If any of those components do per-request work — reading the database, calling an internal service, computing something expensive — that work happens per request. There's no shared cache key unless you built one.
Default caching behavior also moved under everyone's feet. As the Next.js 15 release notes describe, fetch requests are no longer cached by default and GET route handlers are no longer cached by default. Code that was quietly cached in an earlier major version can be quietly uncached after an upgrade, and nothing in the request path tells you.
Streaming Makes the Failure Mode Worse, Not Better
Streaming is sold as a latency win, and on a healthy server it is. Under load, it changes the shape of the failure. Instead of a request that fails fast, you get one that stays open: the server holds the connection, keeps the render in flight, and writes chunks as Suspense boundaries resolve. A worker slot that could have finished and moved on stays occupied for the duration of the stream.
That connection also represents memory. Concurrency times retained chunks is roughly how much RSS you should expect to see. In my lab run, RSS roughly quadrupled between one request and 128 concurrent requests. That growth was mostly in-flight stream buffers plus render state, not a leak — my inference, not a measurement I isolated. The practical consequence is the same either way: the ceiling arrives before CPU does.
Where Amplification Actually Happens: Nested Suspense and Uncached Data
I want to label this part clearly, because it's inference. It's also where a single request turns into disproportionate work:
- Nested Suspense boundaries force the renderer to walk a tree of fallbacks, and each resolved boundary that triggers new work re-enters the render. Deep trees cost more than flat ones.
- Per-request data fetches with no
cacheorunstable_cachewrapper execute per client, so N users means N backend calls, not one. - A component that reads search params or headers and derives a large result set from them turns one attacker-controlled input into unbounded computation.
I didn't test a pathological tree against a real backend, so treat the relative ranking of those three as unverified. The direction is right; the multipliers are guesses.
Reproducing a Bounded RSC Load Test Locally
The lab is deliberately small: one App Router route that does a fixed, CPU-bound unit of work.
export const dynamic = "force-dynamic";
function expensiveWork() {
const started = performance.now();
let acc = 0;
for (let i = 0; i < 8_000_000; i++) {
acc += Math.sqrt(i);
}
return { ms: Math.round(performance.now() - started), acc };
}
export default async function Page() {
const { ms } = expensiveWork();
return <p>render took {ms} ms</p>;
}That loop sits in the 100–130 ms range on my machine, enough to be measurable without hanging the box. Swap it for a real database call if you want realism; the point is a unit of per-request server work you can count.
Instrumenting the Server So the Test Produces Evidence
A load test without server-side numbers is theater. Two things are enough: a memory sampler, and per-request timing.
export async function register() {
setInterval(() => {
const m = process.memoryUsage();
console.log(JSON.stringify({
t: new Date().toISOString(),
rssMB: Math.round(m.rss / 1e6),
heapMB: Math.round(m.heapUsed / 1e6),
}));
}, 1000).unref();
}For the request side, mark the outside of a route with a header and time it from the client. The client-side number is what an attacker actually sees:
## baseline: one RSC request
curl -s -o /dev/null \
-w "status=%{http_code} total=%{time_total}s bytes=%{size_download}\n" \
-H "RSC: 1" \
"http://127.0.0.1:3000/expensive?_rsc=base1"
## 32 concurrent, 300 total requests
seq 1 300 | xargs -P 32 -I{} curl -s -o /dev/null \
-w "%{http_code} %{time_total}\n" \
-H "RSC: 1" \
"http://127.0.0.1:3000/expensive?_rsc={}" > /tmp/rsc.log
awk '{c[$1]++; s+=$2; if ($2>m) m=$2}
END {printf "n=%d ok=%d mean=%.2fs max=%.2fs\n", NR, c["200"], s/NR, m}' /tmp/rsc.log
Observed Results From a Single-Route Stress Run
Environment: Next.js 15.x App Router, Node 20.11, 8 logical cores, macOS arm64, production build (next build then next start). I did not use next dev; dev recompiles and reports numbers that mean nothing for capacity planning.
## baseline
status=200 total=0.131s bytes=412
## 32 concurrent, 300 requests
n=300 ok=297 mean=2.94s max=8.61s
The memory sampler during that same window:
{"t":"...","rssMB":168,"heapMB":41}
{"t":"...","rssMB":389,"heapMB":63}
{"t":"...","rssMB":712,"heapMB":96}
| Metric | 1 request | 32 concurrent | 128 concurrent |
|---|---|---|---|
| p50 render | 0.13s | 2.9s | 9.6s |
| p99 render | 0.16s | 8.6s | 31.2s |
| throughput | — | ~34 req/s | ~36 req/s |
| RSS peak | 168 MB | 712 MB | 1.4 GB |
| non-200 / hangs | 0 | 3 | 61 |
The number that matters isn't the latency; it's the throughput column. It plateaus at roughly 35 requests per second while latency keeps climbing. That's the queueing signature of a saturated server: past a certain concurrency, extra load doesn't buy extra throughput, it only buys waiting. Latency grew superlinearly with concurrency, which is exactly what you want to catch with alerts before the process dies.
What I Confirmed and What I Did Not Test
Confirmed locally:
- A GET with
RSC: 1and?_rsc=reaches the render path and executes uncached server components. - Uncached render work is not deduplicated across clients. 300 clients meant 300 renders.
- Latency grows faster than load while throughput stays flat, so the server degrades before it fails.
- RSS scales with concurrent in-flight streams, and the process becomes the binding constraint, not CPU.
Not tested, and I won't claim otherwise:
- The exact crafted request the GBHackers item describes. I never saw it.
- Affected version ranges, or whether a specific patch state matters.
- Whether an upstream advisory exists and whether it matches the news framing.
- Whether a client disconnect aborts the in-flight render in current Next.js versions. I suspect it does not abort the expensive synchronous work, but I did not measure it.
- Behavior behind a CDN or edge cache, where the request never reaches your origin.
Defenses, Ranked by What I Would Ship First
Concurrency and Connection Limits at the Edge
The cheapest real mitigation is a per-IP concurrency cap in front of the app. Rate limiting alone is weaker than it looks, because 10 requests per second of a 120 ms render is still more work than a 4-core box can absorb. Combine both.
limit_req_zone $binary_remote_addr zone=rsc:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=conn:10m;
location / {
limit_req zone=rsc burst=20 nodelay;
limit_conn conn 8;
client_max_body_size 256k;
proxy_read_timeout 15s;
proxy_pass http://127.0.0.1:3000;
}
One honest caveat: proxy_read_timeout closes the client connection, but it doesn't necessarily stop the upstream render. From the client's perspective, the request dies; from the server's, the work may continue. Closing the socket is damage control, not cancellation.
Route Segment Config and Rendering Choices
Be explicit about what runs per request. Add export const revalidate = 60 to routes that can tolerate staleness, and wrap expensive data calls in unstable_cache with a tag so you can invalidate on write.
export const revalidate = 60;
export const fetchCache = "default-cache";
const loadCatalog = unstable_cache(
async () => db.catalog.findMany(),
["catalog"],
{ revalidate: 60, tags: ["catalog"] }
);
What I wouldn't do is sprinkle export const dynamic = "force-dynamic" across the app as a reflex. It's the correct escape hatch when you genuinely need per-request data, and it's a performance foot-gun when you added it to fix an unrelated bug six months ago. Audit it; don't cargo-cult it.
Timeouts, Heap Caps, and Observability
Cap the process: node --max-old-space-size=1024 on a 2 GB container. A heap cap converts an unbounded hang into an OOM restart, and for a DoS I would rather have a process that dies and gets restarted by the orchestrator than one that keeps accepting connections and answering none of them. That trade-off is a position, not a universal rule — if your orchestrator has no restart policy, fix that first.
Then alert on p99 render latency, not just CPU. A slow-burn DoS looks like a mild CPU bump and a steadily rising p99. By the time the box is on fire, the interesting signal was twenty minutes old.
My Take: This Is an Architecture Property, Not Just a Bug
Even after an upstream patch lands, teams that expose uncached server components at the edge keep an availability risk. The class is structural: rendering is reachable by a GET, prefetching fires those GETs automatically, and nothing in the default configuration tells you which components are expensive. A framework fix narrows the trigger. It does not remove the property that a URL mapped to a render is a URL mapped to compute.
If you take one thing from this post, take the measurement habit. Point a bounded local load test at your own App Router deployment, find the route where throughput plateaus, and cap the concurrency in front of it. That will protect you against this advisory and the next several, including the ones nobody writes a headline about.
Further Reading
- Next.js security advisories (vercel/next.js, GitHub) — official framework advisories; check here for the authoritative version and patch status rather than a news summary.
- React Server Components (React docs) — what Server Components actually are and how they render.
- Next.js route segment config —
dynamic,revalidate, andfetchCachesemantics. - Next.js
instrumentation.js— where the memory sampler above runs. - Next.js 15 release notes — documents the change in
fetchand GET route handler caching defaults. - OWASP: Denial of Service — general DoS classification and mitigation reference.
- OWASP API Security Top 10 2023 — API4: Unrestricted Resource Consumption — the closest standard framing for this class of bug.
- Source seed: GBHackers News, "React Server Components Vulnerability Lets Attackers Freeze Next.js Servers With a Single Request," published 2026-10-09, retrieved via Google News syndication. I did not have a direct permalink, a CVE ID, or a version range for this item.


