cloudflare / cloudflare/workerd

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

Open
#7,245 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
C++
Stars
8.7k
Forks
739
Avg merge
2d 20h
Merged PRs (30d)
174

Description

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

```js
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 https://github.com/Effect-TS/effect/issues/8038.

Contributor guide

Open the contributing guide

Research direction

Run the linked effect-fn-startup-repro with bun install and bun run all, then reproduce via wrangler check startup while comparing the stated module-size and helper controls. The payload names no workerd source file or test; done means identifying the workerd path responsible for the scaling and adding a regression check for the reported startup cost.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript
Domain
backend, performance
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Needs clarification
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.