Why identical JavaScript ran slower: a benchmark post-mortem

In early October, independent developer Theo Browne published a benchmark suite comparing server-side JavaScript execution on Cloudflare Workers versus Vercel. His results showed Workers trailing by as much as 3.5x on CPU-intensive tasks — a surprising outcome given that both platforms run on the same V8 JavaScript engine. Modern server CPUs don't vary by that much, so something else was at play.

Our investigation uncovered a mix of issues: suboptimal scheduling heuristics, outdated V8 garbage-collection tuning, and inefficiencies in the OpenNext adapter that lets Next.js run on Workers. Some of the problems were ours; one of them actually made trig functions slower on Vercel. Over the following week we shipped fixes for most of these issues, and the gap has now essentially closed on every benchmark except the Next.js-based one, where it has narrowed considerably.

It's worth noting that the original benchmark wasn't representative of how most customers are billed on Workers. Billing is based on CPU time actually spent executing code, not wall-clock latency. Still, the exercise surfaced real inefficiencies worth fixing — and the fixes benefit everyone running Workers, not just the benchmark.

Benchmark methodology adjustments

To keep results comparable to Theo's original run, we made only a few changes to how we executed the tests:

  • Theo ran his client from a laptop in San Francisco against Vercel's sfo1 region. We ran our client from AWS us-east-1, targeting Vercel's iad1 region in the same building, to minimize network latency. This gives Vercel slightly better numbers than Theo's original run.
  • We used 1-vCPU Vercel instances instead of 2. All the benchmarks are single-threaded, and Vercel's CTO confirmed this would make no difference — which we verified.
  • We submitted fixes for bugs we found in the benchmark itself via a pull request to the original repository.

Platform-level fixes in the Workers Runtime

The benchmark results made one thing clear: no single JavaScript library could account for the general performance gap. The problem had to live deeper, in the Workers Runtime itself. We found two culprits — not bugs, but configuration and heuristic choices that interacted badly with the workload.

Scheduling: a latency problem, not a CPU problem

Over the past year we shipped smarter routing that directs traffic to warm isolates to reduce cold starts, especially for heavy frameworks like Next.js. That policy optimizes for latency and throughput across billions of mixed requests. But it's less ideal for sustained CPU-bound work: when one request hammers the CPU, other requests queued on the same isolate must wait for it to finish.

Our heuristics detect when requests are blocking each other and spin up more isolates to compensate. The benchmark workload — bursts of expensive requests from a single client — defeated those heuristics, producing inflated and highly variable latency.

This is critical: the benchmark was measuring queueing delay, not CPU execution speed. Time spent waiting for the isolate isn't billed as CPU time against the waiting request, so it wouldn't have affected your bill. Still, we updated the algorithm to detect sustained CPU-heavy work earlier and spin up new isolates faster. The new behavior automatically differentiates between I/O-bound workloads, which benefit from sharing warm isolates, and CPU-bound ones, which should be fanned out. The change is already live globally.

V8 garbage-collector tuning from 2017

With scheduling addressed, we found a second, smaller issue affecting raw code execution. The benchmark results consistently pointed at garbage-collection and memory-management pressure. Since the same frameworks run on Node.js, the difference had to be in how the Workers Runtime configures V8's GC.

V8 exposes many knobs, including the size of the "young generation" — the region where short-lived objects are initially allocated. In June 2017, when the Workers project was two months old and its sole engineer, Kenton, set this value based on V8's then-current guidance for environments with 512MB or less of memory. Workers defaults to 128MB per isolate, so it seemed reasonable at the time.

V8's GC has changed dramatically since then. Our 2017 setting was needlessly limiting the young space, forcing V8's GC to work harder and more often than necessary. We've now backed off that manual tuning, letting V8 pick young-space size based on its own heuristics. This change is live and provides roughly a 25% performance boost on the benchmarks, with a modest increase in memory usage. For most Workers, the actual improvement is smaller.

Optimizing OpenNext for Next.js on Workers

After the platform fixes, all benchmarks were even except one: Next.js. Next.js has historically lacked built-in support for hosting outside a narrow range of platforms. The open-source OpenNext project fills that gap, and we found several missing optimizations in its code explaining the remaining gap.

Unnecessary allocations and copies

Profiling revealed garbage collection dominating the timeline: 10–25% of request processing time was spent reclaiming memory. We found several instances where OpenNext, Next.js, or React itself create needless copies of internal data buffers at the worst points in the request path:

  • A pipeThrough() operation in the rendering pipeline creates up to 50 unused 2048-byte Buffer instances per call.
  • The Cloudflare OpenNext adapter copies every chunk of streamed output on every request. With a 5MB response per request, that's a lot of wasted copies.
  • Some code called getBody().length — where getBody() concatenates a large number of buffers just to read the total byte count, throwing away the result. Clearly unintended.

We've submitted a series of pull requests to OpenNext addressing these and other hot-path issues:

  • Improving streaming response performance
  • Reducing allocations of streams
  • Optimizing readable/writable stream piping
  • Caching expensive compute on OpenNext.js
  • Improving composable-cache performance
  • Improving performance of OpenNext.js converters
  • Avoiding slow-mode on frequently accessed objects
  • Avoiding copying/allocation of extra header objects
  • Avoiding unnecessary buffer copies on responses
  • Caching regexes to reduce GC pressure

This is ongoing work. Many of these improvements apply beyond Workers — they help any platform running OpenNext. The shared goal is to make Next.js as fast as possible regardless of where it's deployed. We're grateful to Theo for the benchmark; it surfaced real issues that we've now fixed, benefiting all Workers customers — and even some who aren't our customers.

Streaming Through Adapters

Next.js leans heavily on Node.js stream primitives, while Workers models HTTP bodies on the web-standard Streams API. Bridging those two worlds forces the framework through conversion layers that, in several hot paths, turned out to be doing far more copying than the data actually required.

const stream = Readable.toWeb(Readable.from(res.getBody()))

In this case, res.getBody() was flattening accumulated chunks with Buffer.concat(), feeding the result into a Node.js stream.Readable, and then wrapping that through an adapter to expose a ReadableStream. Since both stream implementations maintain their own internal buffering, that pipeline multiplies allocations without adding value. A ReadableStream can be constructed directly from those chunks, eliminating the extra copies and the needless adapter hops:

const stream = ReadableStream.from(chunks);

Elsewhere, Next.js and React create ReadableStream instances that are value-oriented rather than byte-oriented. That distinction matters when the enqueued values are Buffer or Uint8Array objects:

const readable = new ReadableStream({
  pull(controller) {
    controller.enqueue(chunks.shift());
    if (chunks.length === 0) {
      controller.close();
    }
});  // Default highWaterMark is 1!

The problem is subtle: when chunks arrive as discrete JavaScript values, the consumer has to issue a separate read for each one. A stream that pushes 1,000 single-byte buffers forces 1,000 reads. Treating it as a byte stream with a sensible high water mark lets the runtime coalesce those values internally:

const readable = new ReadableStream({
  type: 'bytes',
  pull(controller) {
    controller.enqueue(chunks.shift());
    if (chunks.length === 0) {
      controller.close();
    }
}, { highWaterMark: 4096 });

Now reads can pull contiguous bytes instead of individual values. The implementation will keep calling pull() until the highWaterMark is filled, so the consumer no longer has to fetch each enqueued chunk one at a time. Ideally the rendering pipeline would use byte streams natively and respect backpressure, but the adapters themselves can be tightened to avoid the worst of this behavior.

## String Parsing Costs

Beyond buffer handling, profiles flagged JSON.parse() with a reviver function as disproportionately expensive. React and Next.js both use it, and in the benchmark a single request triggered the reviver over 100,000 times — once for every key-value pair, including each element of every serialized array.

That cost got worse recently when the reviver callback gained a third argument exposing the JSON source context. The regression isn't platform-specific, but the Workers team chose not to accept it. A V8 patch has been upstreamed that cuts JSON.parse() with revivers by roughly 33 percent. It ships in V8 14.3 (Chrome 143), so the improvement lands in Node.js, Deno, and every other V8-based runtime — not just Workers.

## Trigonometry and Compiler Flags

Theo's benchmarks were a rebuttal to an earlier comparison that pitted Workers against Vercel on a tight loop of sine and cosine calls. In that test, Workers came out 3x faster — a result that raised red flags internally. Workers doesn't implement its own math library; those functions come from V8, so a 3x gap suggests the comparison wasn't apples-to-apples.

The root cause turned out to be a compile-time difference. Node.js targets a wider range of operating systems and architectures than Workers, so its builds often settle on lowest-common-denominator paths. V8 exposes a flag, V8_USE_LIBM_TRIG_FUNCTIONS, that enables a faster trig implementation. On Workers that flag is enabled effectively by default; in Node.js builds it is not. A pull request has been opened against Node.js to flip it where the platform supports it.

Once that change lands in Node.js and propagates to AWS Lambda and Vercel, the gap should close. It won't make Workers any faster — the optimized path is already active there — but it removes a misleading differentiator from the benchmark and speeds up Node.js users across the ecosystem.

Why CPU benchmarks can mislead

Even well-designed benchmarks carry inherent bias. Planetscale's own analysis of benchmarking pitfalls is a good reference here: a test that measures one narrow slice of behavior can easily be misinterpreted as a proxy for real-world performance. Theo's video makes this same caveat—CPU-bound microbenchmarks don't represent typical web workloads, which are dominated by database queries, downstream network calls, and page size. End-user experience depends on all of those factors; CPU is only one component. Still, when a benchmark shows us as slower, we treat it as a signal worth investigating.

That investigation surfaced real issues we've since fixed, but it also exposed several problems with the benchmarks themselves. Those flaws inflated the apparent performance gap.

The "measure from your laptop" assumption

The benchmark client runs from a developer's machine and measures total time to reach Cloudflare's and Vercel's servers over the public Internet. The implicit assumption is that client-observed latency closely approximates server-side CPU time. That assumption is reasonable on its face: Cloudflare deliberately prevents applications from reading their own CPU time to mitigate timing side-channel attacks, and pulling CPU measurements from logs post-request is cumbersome. Measuring from the client is far simpler.

The complication is that Cloudflare and Vercel host compute in different places. Cloudflare can run a Worker in any of 330+ cities and typically picks the one closest to the requester. Vercel tends to centralize compute, so round-trip latency depends on how far the client is from that region. That geographic asymmetry alone can swing results.

To minimize this in our own testing, we ran the benchmark client from an AWS VM in the same data center as our Vercel instances (us-east-1, Vercel's iad1). Since Cloudflare is well-connected to AWS everywhere, this should have neutralized network latency as a variable. We chose that region because it's the de facto default; picking anything else would invite charges of cherry-picking.

Hardware generation and noisy neighbors

Cloudflare's fleet spans multiple server generations—currently our 10th, 11th, and 12th—because we refresh hardware aggressively but never replace all of it at once. Other providers do the same. Newer CPUs are faster even for single-threaded work, so an application can get lucky or unlucky based purely on which machine it lands on.

Even on identical CPUs, multitenancy introduces variance. An AWS Lambda instance may share a host with hundreds of applications; a Cloudflare server may host thousands. While no two workloads share a physical core, they contend for other resources like memory bandwidth. The result is measurable performance variation between runs.

The key property of this noise is that it's correlated: re-running the benchmark tends to hit the same machines, on both Cloudflare and Vercel. You can't average it away with more iterations. Correcting for it on Cloudflare would require sending requests from many geographic locations to land on different servers, which is a lot of effort. We're not aware of a comparable mechanism for moving an application between machines on Vercel.

A Next.js configuration mismatch

The Cloudflare variant of the Next.js benchmark didn't enable force-dynamic, while the Vercel variant did. That difference triggered unexpected behavior. Pages that aren't marked dynamic should be statically rendered at build time, but under OpenNext, they were still rendered on demand—with a caveat: concurrent requests for the same page were deduplicated, so the page was only rendered once. Before we fixed our scheduling algorithm (which was sending too many requests to the same isolate), this deduplication partly masked the problem. Theo had deliberately disabled force-dynamic for Cloudflare because with it enabled, the results looked outright broken.

Ironically, after we fixed the scheduling issue, the non-dynamic configuration hurt Cloudflare's numbers for a different reason. When OpenNext renders a cacheable page, it buffers the entire response before sending anything. The benchmark client measures time-to-first-byte (TTFB), not total response time. In dynamic mode—as used on Vercel—the first bytes stream out before the page finishes rendering, so TTFB understates full render cost. In non-dynamic mode with OpenNext, nothing is sent until the whole page is buffered, which inflates TTFB.

We became suspicious when Vercel's observability tools showed more CPU time consumed than the benchmark reported. Switching to time-to-last-byte (TTLB) would have been one option, but the responses are large—between 2MB and 15MB—so results would then depend heavily on bandwidth, favoring whoever has the better network path. Since this is a CPU test, not a bandwidth test, that would be unfair.

The correct fix was to enable force-dynamic on the Cloudflare side, matching the Vercel configuration. Both variants now stream the same way, and while neither measures full page render cost, they at least measure the same thing. A side benefit: the original behavior highlighted two performance bottlenecks in OpenNext's composable cache implementation. Those fixes won't change these benchmark numbers, but we're addressing them anyway.

A React NODE_ENV bug

The React SSR benchmark had a more basic flaw. React checks the NODE_ENV environment variable to decide between production and development mode. Vercel sets this automatically in production, and frameworks like OpenNext do the same for Workers. But this benchmark calls React's lower-level APIs directly, bypassing any framework—so NODE_ENV was never set.

When that variable is absent, React defaults to dev mode, which includes extra debugging checks and runs significantly slower. Workers' numbers were therefore much worse than they should have been. It's arguable that Workers should set NODE_ENV automatically for all deployed code, especially with Node.js compatibility enabled—we're looking into that—but for now, the benchmark has been updated to set it explicitly.

What ships now, and what's next

The Workers Runtime improvements are already live for every Worker—no action needed on your part. Many compute-heavy routes should see faster, more consistent tail latency with less jitter during bursts. Some garbage collection changes will also reduce billed CPU seconds for certain workloads.

We've sent Theo a pull request with the OpenNext updates and the benchmark fixes. But closing the gap between OpenNext on Workers and Next.js on Vercel is still ongoing work; given the corrected benchmark results, we're confident it's achievable. Further scheduler improvements are planned so requests rarely block each other, and we'll keep contributing to V8 and Node.js—the Workers team employs core contributors to both projects. The approach is straightforward: improve the open source infrastructure everyone relies on, then ensure our platform extracts maximum benefit from those improvements.

We'll also be writing more benchmarks of our own to catch issues like these earlier. If you have a benchmark that shows Workers underperforming, send it over with a repro. We'll profile it, fix what can be fixed upstream, and share what we learn.