Skip to content

Constructing an Error in workerd costs time in proportion to the size of the enclosing function #7245

Description

@agcty

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

bun install
bun run all

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions