In workerd, new Error() costs between 30 and 230 microseconds when it runs inside a large
top-level module function, and the cost grows with the size of that function. The same code on
the same V8 version under Node costs about 1.5 microseconds and does not vary with module
size. The cost is unrelated to Error.stackTraceLimit: a capture that retains zero frames
costs the same as one that retains two.
Reproduction
Repository: https://github.com/agcty/effect-fn-startup-repro
The generator emits ES modules that place 1,600 new Error() constructions in a module padded
with trivial p = (p ^ N) >>> 0 statements, so the enclosing top-level function is large
while the padding itself costs nothing to run. Each module ends with
globalThis.__evaluated = defs.length
export default { fetch() { return new Response(String(globalThis.__evaluated)) } }
so a request proves the module graph evaluated rather than throwing partway through. This
matters because wrangler check startup reports a fast startup for a bundle that throws
during evaluation.
Numbers below are the scripted CPU total from the profile wrangler check startup writes,
median of five runs, each against a freshly started server. A second gate that uses no
profiler, the wall time from spawning wrangler dev to the first correct response, agrees on
every effect larger than about 100 ms. It cannot resolve smaller ones, because wrangler and
workerd take roughly 800 ms to start.
Measurements
Apple M2 Max, macOS 26.6.2, wrangler 4.127.1, workerd 1.20260828.1, Node 24.15.0
(V8 13.6.233.17), esbuild 0.25.10, bun 1.4.0.
Every row constructs 1,600 Error objects. No library is involved.
| module the constructions sit in |
workerd startup CPU |
per construction |
| small, about 1,600 statements |
47.2 ms |
30 us |
| padded to 15,000 statements |
131.4 ms |
82 us |
| padded to 30,000 statements |
212.1 ms |
133 us |
| padded to 60,000 statements |
372.4 ms |
233 us |
Controls, all in the 60,000 statement module:
| control |
workerd startup CPU |
1,600 new Error() at Error.stackTraceLimit 2 |
372.4 ms |
1,600 new Error() at Error.stackTraceLimit 0 |
373.5 ms |
| 1,600 constructions at limit 2 inside a small helper called from the large module |
343.5 ms |
| 1,600 constructions at limit 0 inside a small helper called from the large module |
343.4 ms |
1,600 Error.stackTraceLimit write pairs with no Error constructed |
0.0 ms |
one new Error() |
0.0 ms |
| the padding alone, no constructions |
0.0 ms |
The profiler-independent gate agrees on the 60,000 statement rows, where it has a matching
baseline. Cold start to the first correct response is 848 ms for the padding with no
constructions, 1274 ms at limit 2, 1248 ms at limit 0, and 1183 ms for the small-helper case.
Four things follow from those controls.
- The cost is the
Error construction. The Error.stackTraceLimit writes that surround it in
the test are free, and the padding on its own is free.
- The stack trace limit does not matter. Retaining zero frames costs the same as retaining two.
- It is linear in the number of constructions. In the 60,000 statement module, 1, 16, 160 and
1,600 constructions cost 0.0, 4.6, 34.6 and 372.4 ms.
- What matters is the size of the calling function, not the constructing one. Moving the
construction into a small helper called from the large module keeps the cost at 343.5 ms.
Splitting the same code across 12 modules of 5,000 statements each, which shrinks every
top-level function without changing the total, brings 1,600 constructions down to 62.3 ms.
Comparison with Node
The same bundles under Node 24.15.0, which reports V8 13.6.233.17, cost about 2.4 ms for the
same 1,600 constructions, roughly 1.5 microseconds each, in both the small and the padded
module. The scaling with module size does not appear there at all.
I have not identified what workerd does differently. The behaviour is reproducible but I am
not claiming a cause.
Version history
The behaviour is not new and has not improved. On workerd 1.20251001.0 the 60,000 statement
case cost 380.3 ms of startup CPU. On 1.20260828.1 it costs 372.4 ms.
One thing did change between those versions. On 1.20251001.0, Date.now() advanced during
module evaluation, which let a worker time its own startup. On 1.20260828.1 both Date.now()
and performance.now() are pinned during module evaluation and report 0. That is presumably
deliberate, and it is noted here only because it removes a way of measuring startup from
inside a worker.
Why this matters
Cloudflare enforces a 400 ms startup CPU limit. Effect's Effect.fn constructs one Error at
every definition site, at module scope, to record where the function was defined. An
application with about 1,600 such definitions in large modules therefore spends its whole
startup budget on Error construction, and its deploys fail on the first attempt. The
corresponding Effect issue is Effect-TS/effect#8038.
In workerd,
new Error()costs between 30 and 230 microseconds when it runs inside a largetop-level module function, and the cost grows with the size of that function. The same code on
the same V8 version under Node costs about 1.5 microseconds and does not vary with module
size. The cost is unrelated to
Error.stackTraceLimit: a capture that retains zero framescosts the same as one that retains two.
Reproduction
Repository: https://github.com/agcty/effect-fn-startup-repro
The generator emits ES modules that place 1,600
new Error()constructions in a module paddedwith trivial
p = (p ^ N) >>> 0statements, so the enclosing top-level function is largewhile the padding itself costs nothing to run. Each module ends with
so a request proves the module graph evaluated rather than throwing partway through. This
matters because
wrangler check startupreports a fast startup for a bundle that throwsduring evaluation.
Numbers below are the scripted CPU total from the profile
wrangler check startupwrites,median of five runs, each against a freshly started server. A second gate that uses no
profiler, the wall time from spawning
wrangler devto the first correct response, agrees onevery effect larger than about 100 ms. It cannot resolve smaller ones, because wrangler and
workerd take roughly 800 ms to start.
Measurements
Apple M2 Max, macOS 26.6.2, wrangler 4.127.1, workerd 1.20260828.1, Node 24.15.0
(V8 13.6.233.17), esbuild 0.25.10, bun 1.4.0.
Every row constructs 1,600
Errorobjects. No library is involved.Controls, all in the 60,000 statement module:
new Error()atError.stackTraceLimit2new Error()atError.stackTraceLimit0Error.stackTraceLimitwrite pairs with noErrorconstructednew Error()The profiler-independent gate agrees on the 60,000 statement rows, where it has a matching
baseline. Cold start to the first correct response is 848 ms for the padding with no
constructions, 1274 ms at limit 2, 1248 ms at limit 0, and 1183 ms for the small-helper case.
Four things follow from those controls.
Errorconstruction. TheError.stackTraceLimitwrites that surround it inthe test are free, and the padding on its own is free.
1,600 constructions cost 0.0, 4.6, 34.6 and 372.4 ms.
construction into a small helper called from the large module keeps the cost at 343.5 ms.
Splitting the same code across 12 modules of 5,000 statements each, which shrinks every
top-level function without changing the total, brings 1,600 constructions down to 62.3 ms.
Comparison with Node
The same bundles under Node 24.15.0, which reports V8 13.6.233.17, cost about 2.4 ms for the
same 1,600 constructions, roughly 1.5 microseconds each, in both the small and the padded
module. The scaling with module size does not appear there at all.
I have not identified what workerd does differently. The behaviour is reproducible but I am
not claiming a cause.
Version history
The behaviour is not new and has not improved. On workerd 1.20251001.0 the 60,000 statement
case cost 380.3 ms of startup CPU. On 1.20260828.1 it costs 372.4 ms.
One thing did change between those versions. On 1.20251001.0,
Date.now()advanced duringmodule evaluation, which let a worker time its own startup. On 1.20260828.1 both
Date.now()and
performance.now()are pinned during module evaluation and report 0. That is presumablydeliberate, and it is noted here only because it removes a way of measuring startup from
inside a worker.
Why this matters
Cloudflare enforces a 400 ms startup CPU limit. Effect's
Effect.fnconstructs oneErroratevery definition site, at module scope, to record where the function was defined. An
application with about 1,600 such definitions in large modules therefore spends its whole
startup budget on
Errorconstruction, and its deploys fail on the first attempt. Thecorresponding Effect issue is Effect-TS/effect#8038.