Why React SSR Blocks Node’s Event Loop (SSR via Templating Doesn’t)
Somewhere in the middle of migrating a Next.js site to Express and EJS, I pulled a couple of PM2 snapshots to see what the server’s event loop latency looked like. They didn’t agree with each other. One read 7.78ms at p95. Another, taken at a different moment, was closer to 385ms. That’s not a rounding difference, it’s two readings that describe different servers.
At the time that felt like a data quality problem. It’s actually the more honest finding. A process whose event loop latency swings that widely between snapshots isn’t in a steady state, and the reason it wasn’t in a steady state has a real, well-documented cause: React’s server-rendering APIs do meaningful synchronous CPU work on the same single thread that’s supposed to be free to handle every other request. A template engine that just interpolates strings doesn’t have that problem, structurally, not as a matter of tuning.
This is a React characteristic, not a Next.js one. The APIs involved, renderToString, renderToPipeableStream, the RSC payload format, live in react-dom/server itself. Next.js is one common consumer of those APIs, not the source of the behavior, and the evidence below reproduces the same cost outside Next.js entirely.
TL;DR
- Node.js runs JavaScript on one main thread. Any synchronous, CPU-bound work on that thread blocks every other request until it finishes.
- React’s
renderToStringis a synchronous operation. Expedia’s engineering team profiled it directly and found it sitting at the top of the call stack, meaning actively blocking, 11.4% of the time in production. - Streaming APIs (
renderToPipeableStream,renderToReadableStream) reduce this by breaking rendering into smaller chunks. Expedia’s own production numbers show the shape of the tradeoff: switching to streaming cut their event loop lag roughly in half and improved throughput by 10%, real gains, but not a full fix, since the underlying rendering work is still synchronous. - A live, reproducible React GitHub issue (React 19.1.1, also confirmed in Next.js) shows server throughput dropping from 329 requests per second to 29 when switching to RSC-based rendering for a large list, with CPU profiling attributing the cost directly to JSON serialization and HTML rendering.
- A template engine like EJS does none of this. No virtual DOM to build, no tree to walk, no client state to serialize for hydration, just string interpolation against a template.
Node’s event loop, briefly
Node.js executes JavaScript on a single main thread. Asynchronous operations, network I/O, file reads, timers, don’t block that thread, they hand off and let the event loop keep processing other work while they complete. Synchronous, CPU-bound code is different: once it starts running, nothing else on that thread runs until it finishes. Node.js’s own documentation warns about exactly this: a function that takes 200ms to execute synchronously doesn’t just take 200ms, it makes every other request queued behind it wait that same 200ms, whether or not those requests have anything to do with the first one.
This is well-documented, uncontroversial Node.js behavior, not something specific to any framework. It matters here because it sets the bar for what “server-side rendering” actually costs: if rendering a page is synchronous CPU work, every concurrent request pays for it.
Why renderToString blocks
React’s renderToString method converts a component tree into an HTML string, and it does that synchronously. Expedia’s engineering team profiled this directly on their production Node applications using the node-clinic suite and found renderToString sitting at the top of the call stack, meaning it was actively executing rather than delegating to something async, 11.4% of the time. In Node’s single-threaded model, time at the top of the stack is time nothing else can run.
The consequence shows up under concurrent load, not in a quick local test. A widely cited 2015 report against a common React SSR starter project measured the difference directly: the same 1,000 requests sent one at a time stayed fast and consistent (99th percentile around 25ms), but sent at a concurrency of 20, the 90th percentile jumped to 333ms and the 99th to 355ms. Each request’s render had to wait for every render ahead of it to finish before its own synchronous work could even start. That’s not a bug, it’s what synchronous rendering does to a request queue by construction.
Streaming spreads the cost out, it doesn’t remove it
React 18 and 19 replaced renderToString with streaming APIs, renderToPipeableStream for Node runtimes, renderToReadableStreamfor edge runtimes, specifically to address this. Streaming lets the server send a page’s shell immediately and fill in slower parts as they resolve, using <Suspense> boundaries to break the tree into independently-streamable pieces.
This genuinely helps, and Expedia’s own numbers show roughly how much. Switching their production application from renderToString to React 16’s older streaming method, renderToNodeStream, the predecessor to today’s renderToPipeableStream, cut their measured event loop lag roughly in half and produced a 10% improvement in server throughput. That’s a real, meaningful gain. It’s also not a full fix: the actual CPU work per chunk, walking that piece of the component tree and converting it to HTML, is still synchronous. Streaming changes the shape of the blocking from one long pause into several shorter ones interleaved with I/O, which is why the gain was roughly half, not all, of the original lag. Edge runtime documentation reflects the same underlying limit on the newer APIs, budgeting edge functions for on the order of tens of milliseconds of CPU time per request, a constraint that exists precisely because the rendering work is still synchronous and still has to fit inside it.
What hydration serialization adds on top
Server-rendered React needs to send more than HTML. For the client to take over and become interactive without a full re-render, it needs the same data the server used, serialized and embedded in the response, along with the React Server Components payload describing the rendered tree so the client can reconcile against it. That serialization is additional synchronous work layered on top of the rendering itself.
A live GitHub issue against React 19.1.1 makes this concrete. The reporter benchmarked a page rendering a 10,000-item list, first with renderToPipeableStream, then with RSC-based server rendering, and measured server throughput dropping from 329 requests per second to 29, an order-of-magnitude difference, for the same content. They noted the same drop reproduces in Next.js, not just their minimal test setup. A CPU profile taken during the request attributes most of the compute time directly to JSON serialization and HTML rendering. This is an open, unresolved issue as of this writing, not a historical problem that’s already been fixed.
Why plain templating doesn’t have this problem
EJS, and template engines like it, do one thing: interpolate values into a string template. There’s no component tree to construct, no virtual DOM to diff, no reconciliation step, and critically, no hydration payload to serialize, because there’s no client-side framework that needs to take over and match what the server produced. The server sends HTML, the browser parses it, and any interactivity comes from a separate, much smaller script (in this case, Alpine.js) that never needs to reconstruct or verify the server’s render.
That’s a structural difference, not a performance tuning difference. React’s approach does more because it’s solving a harder problem, keeping server and client in sync as the same conceptual tree. A template engine was never trying to solve that problem in the first place, so it doesn’t carry the CPU cost that solving it requires.
Where this actually matters
None of this means every React SSR app has a visible problem. At low request volume, synchronous rendering work is easy to miss, each request is fast enough in isolation that queuing effects don’t show up. The Expedia and GitHub cases above both surfaced under load: production traffic in one case, a deliberately stressed 10,000-item benchmark in the other. A content site with modest traffic and simple pages may never notice. A site with either high request volume or large component trees per page will, because the queuing cost compounds with both.
Frequently asked questions
Does using renderToPipeableStream instead of renderToString fix this entirely?
No, it reduces the impact rather than removing it. Streaming breaks a render into smaller synchronous chunks that can interleave with I/O, which improves time to first byte and avoids one request holding the thread for an entire complex render. The CPU cost of converting each chunk’s component tree into HTML, and of serializing any hydration data, is still synchronous work on the same thread.
Is this specific to Next.js, or true of React SSR generally?
It’s a React and Node.js characteristic, not a Next.js-specific bug. The GitHub issue cited above reproduces outside Next.js entirely, using Parcel’s SSR implementation, and the reporter separately confirmed the same throughput drop inside Next.js. Any framework built on React’s server-rendering APIs inherits this cost structure.
Do non-React frameworks avoid this entirely?
It depends on the rendering model, not just the framework name. Anything that builds a component tree server-side and needs to hand off matching state to a client-side runtime carries some version of this cost. Frameworks that ship little or no client-side JavaScript by default, or that render via plain string templating like EJS, don’t have a tree to reconcile or a hydration payload to produce, so the cost doesn’t apply to them the same way.
Should I stop using React SSR because of this?
Not automatically. This cost buys something real: a framework-managed way to keep complex, interactive client state in sync with what the server rendered. For an application that actually needs that, the tradeoff is often worth it. For a content-driven site with small, scattered interactivity, the cost is being paid for a capability the site was never using.
Related reading
This connects to two other things already covered in this series: the memory growth Next.js’s caching model produced under high-cardinality routes, and the case for Alpine.js over React on content-driven sites, which is a big part of why this server no longer needs to answer the hydration question at all.