cloudflare / cloudflare/workers-sdk
Miniflare: `serializeConfig` peak RSS scales with `bundleBytes x bindingCount` (single-segment capnp arena regrows by full copy)
- Dominant language
- TypeScript
- Stars
- 4.5k
- Forks
- 1.5k
- Avg merge
- 3d 8h
- Merged PRs (30d)
- 186
Description
**Package:** `miniflare@4.20260212.0`
**Platform:** macOS (arm64), Node v24.17.0; also reproduced on Linux x64 (16 GB, no swap)
## Summary
Booting a Miniflare instance with several large Workers spikes the supervisor
process to multiple GB of RSS before settling back to a few hundred MB. The spike
is not the module text itself — it is `serializeConfig` repeatedly copying the
whole capnp arena while it appends the config.
On a real 8-Worker setup with ~66 MB of prebuilt module text, the Node supervisor
peaks at ~3.7 GB during `mf.ready` and then decays to ~0.3-0.5 GB. Reserving the
capnp arena up front drops the same peak to ~0.7 GB with no other change.
## Root cause
`serializeConfig` builds the workerd config with a default `Message`:
```js
function serializeConfig(config) {
const message = new Message();
const struct = message.initRoot(Config);
encodeCapnpStruct(config, struct);
return Buffer.from(message.toArrayBuffer());
}
```
`new Message()` with no source initialises a `SingleSegmentArena`. Its `allocate`
grows the arena by allocating a brand-new `ArrayBuffer` of `old + minSize` and
copying the entire old arena into it, with `MIN_SINGLE_SEGMENT_GROWTH = 4096`:
```js
function allocate(minSize, segments, s) {
const srcBuffer = segments.length > 0 ? segments[0].buffer : s.buffer;
minSize = minSize < MIN_SINGLE_SEGMENT_GROWTH ? MIN_SINGLE_SEGMENT_GROWTH : padToWord(minSize);
s.buffer = new ArrayBuffer(srcBuffer.byteLength + minSize);
new Float64Array(s.buffer).set(new Float64Array(srcBuffer));
return new ArenaAllocationResult(0, s.buffer);
}
```
Growth is additive, not geometric. Once the arena holds tens of MB of module text,
every subsequent small allocation (each var, each binding, each nested struct)
allocates a fresh multi-MB `ArrayBuffer` and memcpy's the whole arena into it.
Peak RSS therefore scales with `arenaBytes x allocationCount`, and the allocation
count grows with the number of bindings. GC cannot keep up with the churn inside
one synchronous serialization pass, so the discarded buffers accumulate.
## Isolated repro
8 Workers, ~7 MB of module text each, `V` `bindings` vars per Worker. Only the
supervisor's own RSS is sampled (workerd is not counted).
```js
// repro.mjs — node repro.mjs
import { Miniflare, Log, LogLevel } from "miniflare";
const VARS = Number(process.argv[2] ?? 0);
const WORKERS = 8;
const MODULE_BYTES = 7 * 1024 * 1024;
const filler = "/*" + "x".repeat(MODULE_BYTES) + "*/\n";
const script = `${filler}export default { fetch() { return new Response("ok"); } };`;
let peak = 0;
const sampler = setInterval(() => {
peak = Math.max(peak, process.memoryUsage().rss);
}, 20);
const workers = [];
for (let w = 0; w < WORKERS; w++) {
const bindings = {};
for (let v = 0; v < VARS; v++) bindings[`VAR_${v}`] = `value-${v}`;
workers.push({
name: `worker-${w}`,
modules: [{ type: "ESModule", path: `/virtual/worker-${w}.js`, contents: script }],
modulesRoot: "/virtual",
bindings,
});
}
const baseline = process.memoryUsage().rss;
const mf = new Miniflare({ workers, log: new Log(LogLevel.ERROR) });
await mf.ready;
clearInterval(sampler);
peak = Math.max(peak, process.memoryUsage().rss);
const mb = (n) => (n / 1024 / 1024).toFixed(0);
const payload = WORKERS * MODULE_BYTES;
console.log(
`vars/worker=${VARS} moduleBytes=${mb(payload)}MB baselineRSS=${mb(baseline)}MB ` +
`peakRSS=${mb(peak)}MB amplification=${(peak / payload).toFixed(1)}x`,
);
await mf.dispose();
process.exit(0);
```
Results (`amplification` = peak supervisor RSS / total module bytes):
| vars per worker | peak RSS (current) | peak RSS (with fix below) |
| --- | --- | --- |
| 0 | 954 MB (17.0x) | 346 MB (6.2x) |
| 50 | 1127 MB (20.1x) | 352 MB (6.3x) |
| 200 | 1506 MB (26.9x) | 354 MB (6.3x) |
Note the shape: today's peak climbs with binding count for a fixed payload; with
the fix it is flat.
## A/B on a real 8-Worker setup
8 prebuilt Worker bundles, ~66 MB of module text total, real bindings (KV, R2,
queues, Workflows, Hyperdrive, service bindings). RSS sampled every 500 ms across
the whole process tree from spawn to ready+60s; serialized config is 86,390,088
bytes in one segment.
| | boot peak (node + workerd) | supervisor at peak | idle at ready+60s |
| --- | --- | --- | --- |
| current | 4076 MB | 3699 MB | 1085 MB |
| pre-sized arena | 1482 / 1490 / 1488 MB (3 runs) | 701 / 708 / 705 MB | 1004-1279 MB |
Boot time also improved (7.6s -> 4.5-5.1s to first 200 response). Serialized
output is byte-identical in structure (single segment) and workerd accepts it
unchanged.
The same pre-sized build was then run on Linux x64 (16 GB RAM, `SwapTotal: 0`),
where the unpatched peak was not survivable: boot peak 1219 MB, idle at ready+60s
999 MB, ready in 4.5 s. Without the fix, a memory-constrained CI/agent container
with no swap is pushed into the OOM killer purely by config serialization, even
though the steady-state footprint of the same Workers is well under 1 GB.
## Suggested fix
Two options; the second is what I verified.
1. **Multi-segment arena.** `new Message(new MultiSegmentArena())` removes the
copying entirely (`MultiSegmentArena.allocate` just appends a buffer) and
capnp-es already emits far pointers across segments. **This does not work as-is:**
workerd's reader rejects the result —
`capnp/serialize.c++:181: failed: expected segmentCount < 512 [2775 < 512]; Message has too many segments.`
A real config produced 2775 segments because `MultiSegmentArena.allocate` uses
`DEFAULT_BUFFER_SIZE = 4096` as its minimum segment size. It would become viable
with a much larger minimum segment size (e.g. a few MB), keeping segment count
well under 512.
2. **Pre-size the single segment** (verified, minimal):
```js
function serializeConfig(config) {
const message = new Message(new SingleSegmentArena(new ArrayBuffer(RESERVE_BYTES)));
// Passing an arena makes the constructor run preallocateSegments(), which marks the
// whole reserved buffer as already-used; reset to the empty-arena starting state.
const rootSegment = message._capnp.segments[0];
rootSegment.byteLength = 0;
rootSegment.allocate(8);
const struct = message.initRoot(Config);
encodeCapnpStruct(config, struct);
return Buffer.from(message.toArrayBuffer());
}
```
An untouched `ArrayBuffer` reservation costs no resident memory, `toArrayBuffer`
emits only the bytes actually allocated, and if the reservation is exceeded the
existing growth path still applies — just once instead of thousands of times.
`RESERVE_BYTES` could be derived cheaply from the config (sum of module content
lengths, plus slack) rather than being a constant.
A more general fix would be to make `SingleSegmentArena.allocate` grow
geometrically (e.g. `max(old * 2, old + minSize)`) instead of by
`old + max(minSize, 4096)`; that alone would turn the quadratic into amortized
linear for every capnp-es consumer, without any pre-sizing.
Contributor guide
Research direction
Start by locating the Miniflare serializeConfig entry point and reviewing the capnp-es SingleSegmentArena allocation path described in the issue. Run repro.mjs with varying varsPerWorker values to establish the RSS scaling. Done means config serialization remains accepted by workerd, preserves the serialized structure, and avoids binding-count-dependent memory spikes.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- node.js, typescript
- Domain
- backend, performance
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100