cloudflare / cloudflare/workers-sdk

[vitest-pool-workers] SELF.fetch gets progressively slower within a worker: ~6ms → ~370ms per request after 1,500 requests (superlinear degradation)

Open
#15,446 0 comments 0 reactions 0 assignees View on GitHub
package:vitest
Dominant language
TypeScript
Stars
4.5k
Forks
1.5k
Avg merge
3d 8h
Merged PRs (30d)
186

Description

### Summary

Under `@cloudflare/vitest-pool-workers`, every `SELF.fetch` dispatched inside a test worker gets progressively slower as the *cumulative* number of requests in that workerd instance grows. With a trivial worker that just returns `new Response("ok")`, per-request latency grows from **6.5 ms (first 300 requests) to 368 ms (requests 1,201–1,500)** — roughly linear growth in per-request cost, i.e. quadratic total time. The same worker served by `wrangler dev` stays flat at 5–6 ms for 1,500+ sequential requests, so this is specific to the test harness, not to workerd or the worker code.

For integration-test suites that drive an API through `SELF.fetch` (dozens of requests per test, dozens of tests per file), this dominates the suite runtime: in our project, the slowest test file spent ~14 minutes of a 15-minute CI Vitest step in this degradation. Splitting big test files works around it (each file gets a fresh workerd), which is how we confirmed the accumulation is per-instance.

### Reproduction

`package.json`

```json
{
"name": "vitest-pool-workers-latency-repro",
"private": true,
"type": "module",
"devDependencies": {
"@cloudflare/vitest-pool-workers": "0.22.0",
"vitest": "4.1.11",
"wrangler": "4.125.0"
}
}
```

`wrangler.jsonc`

```jsonc
{
"name": "pool-latency-repro",
"main": "worker.ts",
"compatibility_date": "2026-08-01"
}
```

`worker.ts`

```ts
export default {
async fetch(): Promise {
return new Response("ok");
},
} satisfies ExportedHandler;
```

`vitest.config.ts`

```ts
import { cloudflareTest } from "@cloudflare/vitest-pool-workers";
import { defineConfig } from "vitest/config";

export default defineConfig({
plugins: [cloudflareTest({ wrangler: { configPath: "./wrangler.jsonc" } })],
});
```

`test/latency.test.ts`

```ts
import { SELF } from "cloudflare:test";
import { expect, it } from "vitest";

it("SELF.fetch latency vs cumulative request count", async () => {
for (let batch = 0; batch < 5; batch++) {
const start = performance.now();
for (let i = 0; i < 300; i++) {
const response = await SELF.fetch("https://example.com/");
expect(response.status).toBe(200);
await response.arrayBuffer();
}
const elapsed = performance.now() - start;
console.log(
`batch=${batch} reqs=300 total=${elapsed.toFixed(0)}ms avg=${(elapsed / 300).toFixed(1)}ms`,
);
}
}, 600_000);
```

Run: `npx vitest run --disable-console-intercept`

### Observed

```
batch=0 reqs=300 total=1958ms avg=6.5ms
batch=1 reqs=300 total=11581ms avg=38.6ms
batch=2 reqs=300 total=30049ms avg=100.2ms
batch=3 reqs=300 total=61298ms avg=204.3ms
batch=4 reqs=300 total=110563ms avg=368.5ms
```

Control — the identical worker under `npx wrangler dev`, hammered with 1,500 sequential `curl` requests in batches of 300:

```
batch 1 (300 reqs): avg 6ms
batch 2 (300 reqs): avg 5ms
batch 3 (300 reqs): avg 5ms
batch 4 (300 reqs): avg 5ms
batch 5 (300 reqs): avg 5ms
```

### Additional observations

- While the repro test degrades, the **workerd process burns ~95% CPU** and the Node (vitest) process goes nearly idle — the accumulating cost is inside workerd (likely in the injected runner code or something it retains per request), not in Node-side pooling.
- The degradation follows the **cumulative request count**, not the number of tests or hooks: it reproduces inside a single `it()` as above, and in a real suite it persists across tests within the same file (later tests are uniformly slower, whatever they do).
- A fresh test file resets it (new workerd instance) — test files in the same run always start fast again.
- `isolatedStorage: false` makes no difference.
- Reproduced in a larger real-world app (Effect-based HttpApi worker with DO + D1): identical pattern — 7 ms per request at file start, 400+ ms after ~1,500 requests; flat under `wrangler dev`.

### Impact

Any integration-style suite where each test makes tens of `SELF.fetch` calls (auth handshakes, API fixtures) pays a quadratic penalty per file. In our suite two large files (74 and 50 tests) took 828 s and 601 s in CI; after splitting them into 9 smaller files (identical tests), the same tests run in ~70 s total. That's a viable workaround but shouldn't be a required repo-structure constraint.

### Environment

- `@cloudflare/vitest-pool-workers`: 0.22.0 (latest at time of filing)
- `vitest`: 4.1.11
- `wrangler`: 4.125.0 / workerd 1.20260815.1
- OS: Linux x86_64 (also reproduced on 2-core GitHub Actions `ubuntu-latest`)
- Node: v22 (repro also occurs when the suite is orchestrated via Bun 1.4.0 — the hot process is workerd either way)

Contributor guide

Open the contributing guide

Research direction

Start with the reproduction in test/latency.test.ts and run npx vitest run --disable-console-intercept using package.json, wrangler.jsonc, worker.ts, and vitest.config.ts. Trace the SELF.fetch path in @cloudflare/vitest-pool-workers and its workerd instance, using the flat wrangler dev results as a control. Done means cumulative requests no longer show superlinear latency and the regression is covered by a test.

Written by the indexing model from the issue text.

Assessment

Tech stack
node.js, typescript
Domain
testing
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
42/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.