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)
- 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
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