A snow-covered mountain treeline with 'Node's Event Loop' overlaid as the title, evoking something frozen or blocked

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. Two readings that far apart describe two different servers.

At the time that felt like a data quality problem. It turned out to be the more honest finding. A process whose event loop latency swings that widely between snapshots is not in a steady state, and the reason has a well-documented cause: React’s server-rendering APIs do meaningful synchronous CPU work on the same single thread that is supposed to be free to handle every other request. A template engine that interpolates strings avoids this structurally, rather than through tuning.

This is a React characteristic rather than a Next.js one. The APIs involved, renderToStringrenderToPipeableStream, and the RSC payload format, live in react-dom/server itself. Next.js is one common consumer of those APIs, and the evidence below reproduces the same cost outside Next.js entirely.

Rundown

  • 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 renderToString is a synchronous operation. Profiling by an Expedia Group team put the render step at the top of the call stack, meaning actively blocking, 11.4% of the time in production.
  • Streaming APIs reduce this by breaking rendering into smaller chunks. The same team measured the shape of the tradeoff: switching to streaming cut event loop lag roughly in half and improved throughput by 10%, a real gain rather than a full fix, since the underlying rendering work stays synchronous.
  • A published benchmark against React 19.1.1 measured server throughput falling from 329 requests per second to 29 when switching to RSC-based rendering for a 10,000-item list, with CPU profiling attributing the cost to JSON serialization and HTML rendering.
  • React’s maintainers never confirmed or disputed that report, and a bot closed it as stale. The optimization its author proposed is still an open TODO in React’s server renderer.
  • A template engine like EJS does none of this. No component tree to build, no tree to walk, and no client state to serialize for hydration, only string interpolation against a template.

Node’s event loop, briefly

Node.js executes JavaScript on a single main thread, with a small pool of libuv worker threads behind it. Asynchronous operations, network I/O, file reads, and timers hand off and let the event loop keep processing other work while they complete. Synchronous, CPU-bound code behaves differently: once it starts running, nothing else on that thread runs until it finishes. Node’s documentation warns about exactly this. A function that takes 200ms to execute synchronously 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 uncontroversial Node.js behavior rather than anything specific to a framework. It matters here because it sets the bar for what server-side rendering costs: if rendering a page is synchronous CPU work, every concurrent request pays for it.

Why renderToString blocks

React’s renderToString converts a component tree into an HTML string, synchronously. A team at Expedia Group profiled this on production Node applications using the node-clinic suite, and found the step that converts the virtual DOM tree into an HTML string sitting at the top of the call stack 11.4% of the time. In Node’s single-threaded model, time at the top of the stack is time nothing else can run.

Two separate operations are involved, and the profiling separates them usefully. Building the virtual DOM tree, which means executing every React.createElement call, is synchronous blocking work. Converting that tree into an HTML string is a second synchronous pass, and the team found it the more expensive of the two. Both run before any bytes reach the client.

The consequence compounds under concurrent load rather than in a quick local test. Each request’s render waits for every render ahead of it to finish before its own synchronous work can start, which is what a single thread does to a request queue by construction.

Streaming spreads the cost out without removing it

React 18 and 19 introduced streaming APIs, renderToPipeableStream for Node runtimes and renderToReadableStream for 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.

The Expedia Group numbers show roughly how much that helps. Moving their production application from renderToStringto React 16’s renderToNodeStream, the predecessor to today’s renderToPipeableStream, cut measured event loop lag roughly in half and produced a 10% improvement in server throughput. The flame graph changed shape accordingly: the render step stopped appearing at the top of the stack.

The gain was roughly half rather than all of the original lag, and the reason is structural. The CPU work per chunk, walking that piece of the component tree and converting it to HTML, remains synchronous. Streaming changes the shape of the blocking from one long pause into several shorter ones interleaved with I/O.

What hydration serialization adds on top

Server-rendered React sends 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.

In October 2025 a developer benchmarked the difference directly against React 19.1.1 on Node 22, rendering a component that produces a 10,000-item list, first with renderToPipeableStream and then with RSC-based server rendering. Throughput fell from 329 requests per second to 29. The flight request alone fell from 329 to 104. A CPU profile taken during the request attributed most of the compute time to JSON serialization and HTML rendering, and the author published both the profiles and a reproduction repository built on Parcel’s SSR implementation, noting that the same drop appears in Next.js.

The status of that report is worth stating precisely. React’s maintainers never confirmed or disputed it. It carried the label for an unconfirmed potential bug until a bot closed it as stale for inactivity, so it stands as a published measurement rather than an acknowledged defect. What has not changed is the underlying code. One of the two optimizations its author identified was a comment in React’s own server renderer marking the point where hydration data could be emitted directly into the Fizz stream, cutting a serialization pass. That comment is still a TODO in ReactFizzServer.js on main today.

Why plain templating avoids this

EJS, and template engines like it, do one thing: interpolate values into a string template. There is no component tree to construct, no virtual DOM to diff, no reconciliation step, and no hydration payload to serialize, because no client-side framework 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, Alpine.js in this case, that never needs to reconstruct or verify the server’s render.

That is a structural difference rather than a tuning difference. React’s approach does more because it solves a harder problem, keeping server and client in sync as the same conceptual tree. A template engine was never trying to solve that problem, so it never pays the CPU cost that solving it requires.

Where this shows up

Plenty of React SSR applications have no visible problem. At low request volume, synchronous rendering work is easy to miss, because each request is fast enough in isolation that queuing effects stay invisible. Both cases above surfaced under load: production traffic in one, 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.

Related reading

Two siblings cover the rest of the migration case: the memory growth under high-cardinality routes, and the case for Alpine.js over React on content-driven sites, which is a large part of why this server no longer answers the hydration question at all. The full migration writeup covers where this fits among the other reasons for moving off Next.js.

On the broader question of what a framework still buys, the case for vanilla JavaScript covers what the platform now handles natively, and what Node.js gained covers what the runtime absorbed since React launched.

RSS Feed Newsletter
Contact us

Latest Blog Posts