Repository navigation
Each WebSocket send from an idle socket leaves 4 FunctionTemplateInfo objects in old space #7745
Description
Summary
In workerd 1.20261002.1, each time a WebSocket's send pump goes from idle to busy, the isolate allocates four FunctionTemplateInfo and four SharedFunctionInfo objects in old space, 464 bytes together. With the four JS functions that come with them, 112 bytes, the old generation grows by 576 bytes, about 0.56 KB, per pump start. None of it dies before the next full GC.
The cost is per idle socket that a JS turn writes to, not per message or byte. Messages sent to a socket that is already pumping add nothing. Below, a "send turn" is one idle socket written to in one JS turn, so a turn that writes to 16 idle sockets counts as 16 send turns.
workerd 1.20261014.1, the latest release, gives the same numbers.
An isolate that sends WebSocket messages every 50 ms, such as a Durable Object with many clients, pays this cost every 50 ms for each socket. The cost can then become a main source of old-generation growth, and so set how often full GCs pause the isolate.
Where the objects come from
When the socket is not already pumping, LegacyWebSocketAdapter::ensurePumping runs this line:
context.awaitIoLegacy(js, kj::mv(promise)).then(js, ...)That line makes two jsg::Promise .then calls. The first is the .then shown. The second is inside awaitIoImpl, which ends with jsPromise.then(js, throwOrReturnResult<Result>).
Both calls go through thenImpl, which calls v8::Function::New twice. V8 implements Function::New as a new FunctionTemplate with do_not_cache set, followed by GetFunction (api.cc). So every call creates one FunctionTemplateInfo and one SharedFunctionInfo, and V8 allocates both in old space (factory.cc, factory-base.cc). Two .then calls with two Function::New calls each make 4 of each object per send turn. The measurement below shows exactly 4.
Every awaitIo* result gets the same .then from awaitIoImpl. PromiseWrapper::wrap also calls Function::New for each promise it converts, and tryUnwrap does the same for each promise of a non-void type (promise.h#L621, promise.h#L652).
The workerd links point at main at 587bab1. The cited code is the same in v1.20261002.1. The V8 links point at 15.5.35.7, the V8 in workerd 1.20261002.1.
Measurement
A Durable Object holds 16 WebSockets. Every 50 ms it schedules four timer callbacks, 10 ms apart. Only the send pattern differs between phases:
none: no callback sends.one: callback 0 sends 1 message per socket.same: callback 0 sends 4 messages per socket, in one turn.split: each callback sends 1 message per socket, so 4 turns.
run.mjs starts workerd with --expose-gc --trace-gc-nvp --trace-gc-object-stats and connects the sockets from Node. It forces a full GC at the start and at the end of each 30 s phase. For each phase it reports two things:
- The dead objects per instance type at the full GCs, from
--trace-gc-object-stats. - The growth of the old generation between GCs, from
--trace-gc-nvp. That growth is what the isolate allocated directly in the old generation.
In the table, FTI means FunctionTemplateInfo. Results for workerd 1.20261002.1 on macOS arm64:
16 sockets, 30 s per phase, 4 timer callbacks 10 ms apart every 50 ms in every phase
phase msgs/s send turns/s dead KB/s FUNCTION_TEMPLATE_INFO/s SHARED_FUNCTION_INFO/s old KB/s FTI per turn old KB per turn
none-accept 0 0 9.1 0.1 0.1 0.7 - -
one-accept 308 308 196.6 1232.5 1232.5 173.3 4.00 0.56
same-accept 1229 307 195.4 1228.5 1228.5 172.8 4.00 0.56
split-accept 1231 1231 852.8 4924.7 4924.7 692.6 4.00 0.56
one-hibernate 309 309 197.7 1234.9 1234.9 173.7 4.00 0.56
split-hibernate 1222 1222 814.7 4887.7 4887.7 687.4 4.00 0.56
- Every phase shows 4.00 dead
FunctionTemplateInfoand 4.00 deadSharedFunctionInfoper send turn, and 0.56 KB of old-generation growth per send turn. samesends 4 times as many messages asoneand costs the same.splitsends as many messages assamein 4 times as many turns, and costs 4 times as much.- Sockets accepted with
ctx.acceptWebSocketcost the same as sockets accepted withaccept(). - With one send turn per socket every 50 ms, 16 sockets grow the old generation by about 173 KB/s this way.
- workerd 1.20261014.1, which embeds V8 15.6.75 and includes Report wrapper cppgc allocations to V8 outside the no-GC scope #7649, also gives 4.00
FunctionTemplateInfoand 0.56 KB per send turn in every phase.
Repro
Put all files in one directory and run:
npm i workerd@1.20261002.1
node run.mjs # Node 22 or newer, about 4 minutesconfig.capnp
using Workerd = import "/workerd/workerd.capnp";
const config :Workerd.Config = (
services = [(name = "main", worker = .worker)],
sockets = [(name = "http", address = "127.0.0.1:8787", http = (), service = "main")],
v8Flags = ["--expose-gc", "--trace-gc-nvp", "--trace-gc-object-stats"],
);
const worker :Workerd.Worker = (
modules = [(name = "worker.js", esModule = embed "worker.js")],
compatibilityDate = "2026-10-01",
durableObjectNamespaces = [(className = "Pumper", uniqueKey = "pumper")],
durableObjectStorage = (inMemory = void),
bindings = [(name = "PUMPER", durableObjectNamespace = "Pumper")],
);worker.js
// A Durable Object that holds WebSockets and sends to each every 50 ms.
// Every 50 ms it runs four timer callbacks 10 ms apart in every mode, so only the
// send pattern differs between modes:
// none no sends
// one callback 0 sends 1 message
// same callback 0 sends 4 messages (one turn)
// split callbacks 0-3 send 1 message each (four turns)
import { DurableObject } from "cloudflare:workers";
const SENDS = {
none: [0, 0, 0, 0],
one: [1, 0, 0, 0],
same: [4, 0, 0, 0],
split: [1, 1, 1, 1],
};
export class Pumper extends DurableObject {
sockets = new Set();
mode = "none";
interval = undefined;
fetch(request) {
const url = new URL(request.url);
this.mode = url.searchParams.get("mode") ?? "none";
const hibernate = url.searchParams.get("api") === "hibernate";
const [client, server] = Object.values(new WebSocketPair());
if (hibernate) {
this.ctx.acceptWebSocket(server);
} else {
server.accept();
server.addEventListener("close", () => this.drop(server));
}
this.sockets.add(server);
this.interval ??= setInterval(() => this.onInterval(), 50);
return new Response(null, { status: 101, webSocket: client });
}
webSocketClose(ws) {
this.drop(ws);
}
drop(ws) {
this.sockets.delete(ws);
if (this.sockets.size === 0) {
clearInterval(this.interval);
this.interval = undefined;
}
}
onInterval() {
SENDS[this.mode].forEach((count, i) => {
setTimeout(() => {
for (const ws of this.sockets) {
for (let n = 0; n < count; n++) ws.send("x");
}
}, i * 10);
});
}
}
export default {
fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === "/gc") {
gc();
return new Response("ok");
}
return env.PUMPER.get(env.PUMPER.idFromName("one")).fetch(request);
},
};run.mjs
// Starts workerd, runs each phase with SOCKETS clients for SECONDS, and forces a full GC at
// the start and end of each phase. Per phase it prints the objects that died in between
// (--trace-gc-object-stats) and the old generation's growth between GCs, which is what was
// allocated directly in the old generation (--trace-gc-nvp).
// Usage: WORKERD=path/to/workerd node run.mjs [seconds] [sockets]
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 SECONDS = Number(process.argv[2] ?? 30);
const SOCKETS = Number(process.argv[3] ?? 16);
const BASE = "127.0.0.1:8787";
const PHASES = [
["none", "accept"],
["one", "accept"],
["same", "accept"],
["split", "accept"],
["one", "hibernate"],
["split", "hibernate"],
];
// messages per send turn, so send turns/s (pump starts/s) = messages received/s / this
const PER_TURN = { none: Infinity, one: 1, same: 4, split: 1 };
const TYPES = ["FUNCTION_TEMPLATE_INFO_TYPE", "SHARED_FUNCTION_INFO_TYPE"];
const log = createWriteStream("gc.log");
const workerd = spawn(WORKERD, ["serve", "config.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) =>
new Promise((resolve, reject) => {
const req = http.get(`http://${BASE}${path}`, { agent: false, timeout: 10_000 }, (res) => {
res.resume();
res.on("end", resolve);
});
req.on("timeout", () => req.destroy(new Error(`timeout: ${path}`)));
req.on("error", reject);
});
for (let i = 0; ; i++) {
try {
await get("/gc"); // forced full GC 0
break;
} catch (error) {
if (i === 50) throw error;
await sleep(100);
}
}
// Phase p runs between forced full GCs 2p+1 and 2p+2.
const received = [];
for (const [mode, api] of PHASES) {
const sockets = Array.from({ length: SOCKETS }, () => new WebSocket(`ws://${BASE}/?mode=${mode}&api=${api}`));
await Promise.all(sockets.map((ws) => new Promise((resolve) => ws.addEventListener("open", resolve))));
let count = 0;
for (const ws of sockets) ws.addEventListener("message", () => count++);
await sleep(2000); // warm up before the measured window
await get("/gc");
count = 0;
await sleep(SECONDS * 1000);
received.push(count);
await get("/gc");
for (const ws of sockets) ws.close();
await sleep(1000);
}
// 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("gc.log", "utf8").match(/"gc":"mc".*"reason":"testing".*\}\n/g) ?? []).length;
for (let i = 0; forcedInLog() < 2 + 2 * PHASES.length; i++) {
if (i === 300) throw new Error("the GC trace never reached the log");
await get("/gc");
await sleep(100);
}
workerd.kill();
await new Promise((resolve) => log.end(resolve));
// Both traces go through the same stdout, in order: the n-th mark-compact record of
// --trace-gc-nvp and the n-th GC id of --trace-gc-object-stats are the same GC.
const records = []; // every GC, from --trace-gc-nvp
const deadById = new Map(); // GC id -> instance type -> { count, bytes }, in order of appearance
const timeById = new Map();
for (const line of readFileSync("gc.log", "utf8").split("\n")) {
const nvp = line.match(/ms: GC: (\{.*\})$/);
if (nvp) {
records.push(JSON.parse(nvp[1]));
continue;
}
if (!line.startsWith('{ "isolate"')) continue;
let r;
try {
r = JSON.parse(line);
} catch {
continue; // the last line can be cut off when workerd exits
}
if (r.type === "gc_descriptor") timeById.set(r.id, r.time);
if (!deadById.has(r.id)) deadById.set(r.id, {});
if (r.type === "instance_type_data" && r.key === "dead") {
deadById.get(r.id)[r.instance_type_name] = { count: r.count, bytes: r.overall };
}
}
const fullGcs = records.flatMap((r, index) => (r.gc === "mc" ? [{ ...r, index }] : []));
const ids = [...deadById.keys()];
// the newest GC can be missing from one of the two traces when workerd exits
if (Math.abs(fullGcs.length - ids.length) > 1) {
throw new Error(`${fullGcs.length} full GCs in nvp, ${ids.length} in object stats`);
}
const forced = fullGcs.flatMap((gc, n) => (gc.reason === "testing" ? [n] : []));
if (forced.length < 1 + 2 * PHASES.length) throw new Error(`only ${forced.length} forced full GCs in the trace`);
const results = PHASES.map(([mode, api], p) => {
const [from, to] = [forced[2 * p + 1], forced[2 * p + 2]];
const seconds = (timeById.get(ids[to]) - timeById.get(ids[from])) / 1000;
const dead = {};
for (let n = from + 1; n <= to; n++) {
for (const [type, { count, bytes }] of Object.entries(deadById.get(ids[n]))) {
dead[type] ??= { count: 0, bytes: 0 };
dead[type].count += count;
dead[type].bytes += bytes;
}
}
// growth between the end of one GC and the start of the next is direct old-generation allocation
let directOld = 0;
for (let i = fullGcs[from].index + 1; i <= fullGcs[to].index; i++) {
directOld += records[i].start_old_gen_consumed_size - records[i - 1].end_old_gen_consumed_size;
}
return {
label: `${mode}-${api}`,
messages: received[p] / SECONDS,
turns: Math.round(received[p] / SECONDS / PER_TURN[mode]),
deadKb: Object.values(dead).reduce((sum, e) => sum + e.bytes, 0) / 1024 / seconds,
counts: TYPES.map((t) => (dead[t]?.count ?? 0) / seconds),
oldKb: directOld / 1024 / seconds,
};
});
const idleOldKb = results[0].oldKb;
console.log(`${SOCKETS} sockets, ${SECONDS} s per phase, 4 timer callbacks 10 ms apart every 50 ms in every phase`);
console.log(
"phase msgs/s send turns/s dead KB/s " + TYPES.map((t) => t.replace("_TYPE", "") + "/s").join(" ") +
" old KB/s FTI per turn old KB per turn",
);
for (const r of results) {
const cols = r.counts.map((c, i) => c.toFixed(1).padStart(TYPES[i].length - 3));
console.log(
`${r.label.padEnd(16)} ${r.messages.toFixed(0).padStart(6)} ${String(r.turns).padStart(12)} ${
r.deadKb.toFixed(1).padStart(9)
} ${cols.join(" ")} ${r.oldKb.toFixed(1).padStart(8)} ${
(r.turns ? (r.counts[0] / r.turns).toFixed(2) : "-").padStart(12)
} ${(r.turns ? ((r.oldKb - idleOldKb) / r.turns).toFixed(2) : "-").padStart(15)}`,
);
}Impact
Our application is a Durable Object with 18 WebSocket clients, and it sends to each client every 50 ms. In local tests its old generation grew by 54 KB per 50 ms, and 34 KB of that was allocated directly in the old generation. Its 52 send turns per 50 ms account for about 29 KB, at 0.56 KB each.
We deployed a test build to Cloudflare with two changes. They send each client's messages in one turn and drop replies that the clients ignore. Locally they cut the send turns from 52 to 20 per 50 ms, and the old-generation growth from 54 to 33 KB per 50 ms. On Cloudflare, the time between the long pauses that we attribute to full GCs grew from about 10 s to about 16 s, close to the factor of 1.6 that the local numbers predicted. So production seems to pay a similar cost.
With both changes, about 20 send turns per 50 ms remain, roughly one per socket, or about 11 KB. We can't remove those in our own code.
Possible fixes
I don't know the constraints here, so take these as ideas only:
- In
thenImpl, create one function per callback type, once per isolate or context, and pass the per-call state another way. A.thenwould then allocate only short-lived objects. - In
ensurePumping, don't use a JS promise for the internal pump completion. Its continuation only releases the socket or reports an error.
Related
- Implement jsg::LazyPromise<T> #5878 proposed
jsg::LazyPromiseto create fewer promises per stream write, and was closed without merging. It did not touch this path. - Restrict GCs at inopportune moments. #7387 and Report wrapper cppgc allocations to V8 outside the no-GC scope #7649 changed how wrapper allocations interact with GC. The results above are the same on 1.20261014.1, which includes both.
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