Repository navigation
queueMicrotask keeps its callback alive until the next full GC #7746
Description
Summary
In workerd 1.20261002.1, a closure passed to queueMicrotask stays alive after it has run. It stays alive until the next full GC, and so does everything it captures. workerd 1.20261014.1, the latest release, behaves the same.
In the repro below, a loop builds a structure of about 180 KB every 50 ms and reads it from one queueMicrotask callback. Scavenges promote 157 KB per iteration. With a native promise .then, setTimeout, or setImmediate instead, the same loop promotes 0 to 1.2 KB per iteration.
Code that often passes queueMicrotask a closure over large data turns that short-lived data into old-generation growth. The growth makes full GCs more frequent.
Where the closure is kept
queueMicrotask wraps each task with js.wrapSimpleFunction. That call runs v8::Function::New and attaches a C++ lambda to the new function through attachOpaqueWrapper. The lambda holds the task:
JSG_VISITABLE_LAMBDA((this, fn = kj::mv(task)), (fn), ...)The lambda still holds the task after the microtask has run. As far as I can tell, a scavenge does not free the wrapper. So the task's closure, and everything the closure captures, stays alive until the next full GC.
The links point at main at 587bab1. The cited code is the same in v1.20261002.1.
Measurement
A stateless worker runs 300 iterations per mode, 50 ms apart. Each iteration builds a structure of about 180 KB, which the next iteration drops, and about 530 KB of garbage. One callback reads the structure:
plain: the loop reads it synchronously.promise:Promise.resolve().then(callback).microtask:queueMicrotask(callback).timeout:setTimeout(callback, 0).immediate:setImmediate(callback).sendfrom:Promise.resolve().then(callback), where the callback also sends one WebSocket message.
run-deferred.mjs starts workerd with --expose-gc --trace-gc-nvp and runs each mode between two forced full GCs. It adds up the bytes that the scavenges in between promoted. Results for workerd 1.20261002.1 on macOS arm64:
300 iterations 50 ms apart per mode, each keeping about 180 KB that one deferred callback reads
mode scavenges promoted KB/iteration old gen before full GC MB after MB
plain 49 0.0 0.5 0.5
promise 27 1.2 0.8 0.8
microtask 12 156.6 46.8 0.8
timeout 9 0.0 0.8 0.8
immediate 9 0.0 0.8 0.8
sendfrom 9 0.1 1.1 0.9
queueMicrotaskpromotes 157 KB per iteration, most of each iteration's structure. Every other mode promotes at most 1.2 KB per iteration.- With
queueMicrotask, the old generation reached 46.8 MB. The full GC at the end of the mode brought it back to 0.8 MB. In every other mode it stayed at or below 1.1 MB. - The
sendfrommode, where a promise continuation sends one WebSocket message, does not keep the closure alive either. - workerd 1.20261014.1, which embeds V8 15.6.75, gives the same table.
As a control, node-control.mjs runs the same loop in plain Node 26.8.1 with a 16 MB semi-space. That size is within the young-generation capacity that workerd reported in the run above, where new_space_capacity ranged from 1 to 32 MB. In Node, queueMicrotask promotes as much as plain and setTimeout:
node v26.8.1 (V8 14.6.202.34-node.28), 300 iterations 50 ms apart per mode
mode scavenges promoted KB/iteration
plain 37 0.8
timeout 37 0.9
microtask 37 0.8
Node's queueMicrotask also puts a JS function on V8's microtask queue, and there the closure is not kept alive. The difference points at workerd's wrapper. The control is not strict, because Node 26.8.1 embeds V8 14.6.202.34 and workerd 1.20261002.1 embeds a newer V8, 15.5.35.7.
Repro
Put all files in one directory and run:
npm i workerd@1.20261002.1
node run-deferred.mjs # Node 22 or newer, about 2 minutes
node node-control.mjs # the Node control, about 1 minutedeferred.capnp
using Workerd = import "/workerd/workerd.capnp";
const config :Workerd.Config = (
services = [(name = "main", worker = .worker)],
sockets = [(name = "http", address = "127.0.0.1:8788", http = (), service = "main")],
v8Flags = ["--expose-gc", "--trace-gc-nvp"],
);
const worker :Workerd.Worker = (
modules = [(name = "deferred.js", esModule = embed "deferred.js")],
compatibilityDate = "2026-10-01",
);deferred.js
// Runs iterations 50 ms apart. Each one builds a structure of about 180 KB that the next one
// drops, plus about 530 KB of garbage, and hands the structure to one callback that reads it later:
// plain read synchronously, nothing deferred
// promise Promise.resolve().then(callback)
// microtask queueMicrotask(callback)
// timeout setTimeout(callback, 0)
// immediate setImmediate(callback)
// sendfrom Promise.resolve().then(callback), and the callback sends one WebSocket message
const make = (kb) => {
const out = [];
for (let i = 0; i < kb * 16; i++) out.push({ a: i, b: [i, i + 1] });
return out;
};
const DEFER = {
plain: (callback) => callback(),
promise: (callback) => Promise.resolve().then(callback),
microtask: (callback) => queueMicrotask(callback),
timeout: (callback) => setTimeout(callback, 0),
immediate: (callback) => setImmediate(callback),
};
let sink = 0;
export default {
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/gc") {
gc();
return new Response("ok");
}
const iterations = Number(url.searchParams.get("iterations") ?? 300);
const mode = url.searchParams.get("mode");
const [client, server] = Object.values(new WebSocketPair());
server.accept();
client.accept();
client.addEventListener("message", (event) => {
sink += event.data.length;
});
for (let i = 0; i < iterations; i++) {
const kept = { list: make(200) };
make(600);
if (mode === "sendfrom") {
Promise.resolve().then(() => server.send(`${kept.list.length}`));
} else {
DEFER[mode](() => {
sink += kept.list.length;
});
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
server.close();
return new Response(`done ${sink}\n`);
},
};run-deferred.mjs
// Starts workerd with deferred.capnp, runs ITERATIONS iterations per mode between two forced
// full GCs, and prints what the scavenges promoted per iteration (--trace-gc-nvp "promoted")
// and the old generation before and after the full GC that ends the mode.
// Usage: WORKERD=path/to/workerd node run-deferred.mjs [iterations]
import { spawn } from "node:child_process";
import { createWriteStream, readFileSync } from "node:fs";
import http from "node:http";
import { setTimeout as sleep } from "node:timers/promises";
const WORKERD = process.env.WORKERD ?? "node_modules/.bin/workerd";
const ITERATIONS = Number(process.argv[2] ?? 300);
const BASE = "127.0.0.1:8788";
const MODES = ["plain", "promise", "microtask", "timeout", "immediate", "sendfrom"];
const log = createWriteStream("deferred.log");
const workerd = spawn(WORKERD, ["serve", "deferred.capnp"], { stdio: ["ignore", "pipe", "pipe"] });
workerd.stdout.pipe(log);
workerd.stderr.pipe(log);
process.on("exit", () => workerd.kill());
// one fresh connection per request
const get = (path, timeout) =>
new Promise((resolve, reject) => {
const req = http.get(`http://${BASE}${path}`, { agent: false, timeout }, (res) => {
let body = "";
res.on("data", (chunk) => (body += chunk));
res.on("end", () => resolve(body));
});
req.on("timeout", () => req.destroy(new Error(`timeout: ${path}`)));
req.on("error", reject);
});
for (let i = 0; ; i++) {
try {
await get("/gc", 10_000); // forced full GC 0
break;
} catch (error) {
if (i === 50) throw error;
await sleep(100);
}
}
// Mode m runs between forced full GCs 2m+1 and 2m+2.
for (const mode of MODES) {
await get(`/?mode=${mode}&iterations=20`, 60_000); // warm up
await get("/gc", 10_000);
await get(`/?mode=${mode}&iterations=${ITERATIONS}`, ITERATIONS * 1000);
await get("/gc", 10_000);
}
// workerd buffers V8's trace output: force full GCs until the log holds one complete
// record past the last forced GC the analysis needs
const forcedInLog = () => (readFileSync("deferred.log", "utf8").match(/"gc":"mc".*"reason":"testing".*\}\n/g) ?? []).length;
for (let i = 0; forcedInLog() < 2 + 2 * MODES.length; i++) {
if (i === 300) throw new Error("the GC trace never reached the log");
await get("/gc", 10_000);
await sleep(100);
}
workerd.kill();
await new Promise((resolve) => log.end(resolve));
const records = [];
for (const line of readFileSync("deferred.log", "utf8").split("\n")) {
const nvp = line.match(/ms: GC: (\{.*\})$/);
if (nvp) records.push(JSON.parse(nvp[1]));
}
const forced = records.flatMap((r, index) => (r.gc === "mc" && r.reason === "testing" ? [index] : []));
if (forced.length < 1 + 2 * MODES.length) throw new Error(`only ${forced.length} forced full GCs in the trace`);
console.log(`${ITERATIONS} iterations 50 ms apart per mode, each keeping about 180 KB that one deferred callback reads`);
console.log("mode scavenges promoted KB/iteration old gen before full GC MB after MB");
MODES.forEach((mode, m) => {
const [from, to] = [forced[2 * m + 1], forced[2 * m + 2]];
const scavenges = records.slice(from + 1, to).filter((r) => r.gc === "s");
const promoted = scavenges.reduce((sum, r) => sum + r.promoted, 0);
const end = records[to];
console.log(
`${mode.padEnd(10)} ${String(scavenges.length).padStart(10)} ${(promoted / 1024 / ITERATIONS).toFixed(1).padStart(21)} ${
(end.start_old_gen_consumed_size / 1048576).toFixed(1).padStart(25)
} ${(end.end_old_gen_consumed_size / 1048576).toFixed(1).padStart(8)}`,
);
});node-control.mjs
// The loop from deferred.js in plain Node, as a control for queueMicrotask: every 50 ms build the
// same structure, which the next iteration drops, plus the same garbage, and read the structure
// from one deferred callback. Runs each mode in a child Node with --trace-gc-nvp and
// a 16 MB semi-space, and prints what the scavenges promoted per iteration.
// Usage: node node-control.mjs
import { execFileSync } from "node:child_process";
import { setTimeout as sleep } from "node:timers/promises";
const ITERATIONS = 300;
const MODES = ["plain", "timeout", "microtask"];
const make = (kb) => {
const out = [];
for (let i = 0; i < kb * 16; i++) out.push({ a: i, b: [i, i + 1] });
return out;
};
const DEFER = {
plain: (callback) => callback(),
timeout: (callback) => setTimeout(callback, 0),
microtask: (callback) => queueMicrotask(callback),
};
const mode = process.argv[2];
if (mode) {
let sink = 0;
for (let i = 0; i < ITERATIONS; i++) {
const kept = { list: make(200) };
make(600);
DEFER[mode](() => {
sink += kept.list.length;
});
await sleep(50);
}
} else {
console.log(`node ${process.version} (V8 ${process.versions.v8}), ${ITERATIONS} iterations 50 ms apart per mode`);
console.log("mode scavenges promoted KB/iteration");
for (const m of MODES) {
const trace = execFileSync(
process.execPath,
["--trace-gc-nvp", "--min-semi-space-size=16", "--max-semi-space-size=16", import.meta.filename, m],
{ encoding: "utf8", maxBuffer: 64 * 1024 * 1024 },
);
const scavenges = [...trace.matchAll(/(\{"pause".*\})/g)].map((r) => JSON.parse(r[1])).filter((r) => r.gc === "s");
const promoted = scavenges.reduce((sum, r) => sum + r.promoted, 0);
console.log(
`${m.padEnd(10)} ${String(scavenges.length).padStart(10)} ${(promoted / 1024 / ITERATIONS).toFixed(1).padStart(21)}`,
);
}
}Possible fixes
I don't know the constraints here, so take these as ideas only:
- Release the task after it runs, for example by resetting
fnonce the call returns. A wrapper that lives on would then no longer keep the closure alive. - Enqueue one cached function that drains a task queue in C++. Then
queueMicrotaskcreates no wrapper per call.
Related
- attempt queueMicrotask perf improvements #5584 tried to make exception handling in
queueMicrotaskcheaper, and was closed without merging. It did not touch this. - Report wrapper cppgc allocations to V8 outside the no-GC scope #7649 changed how workerd reports wrapper allocations to V8. The results above are the same on 1.20261014.1, which includes it.
I investigated this with an AI coding agent, Claude Code. The agent read the workerd and V8 sources, wrote the repro, and ran it. I reviewed the analysis before filing.
Activity