Skip to content

queueMicrotask keeps its callback alive until the next full GC #7746

Open

Description

@schutzelaars

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
  • queueMicrotask promotes 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 sendfrom mode, 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 minute
deferred.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 fn once 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 queueMicrotask creates no wrapper per call.

Related


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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions