node:events: one emit() for emitters that capture rejections
Problem
- With
captureRejections,emitter.emit()never calls a subclass'semit(), and it bypasses a replacedEventEmitter.prototype.emit.Object.keys(emitter)ends with"emit". Node v26.3.0 does neither. - The constructor stores a second function,
emitWithRejectionCapture, as an ownemitproperty (src/js/node/events.ts:204), so that other emitters read no flag. - No user reported it. A review of #31828 asked for it, and node:domain needs it.
Fix
- One
EventEmitter.prototype.emit. When a listener returns a promise, it readsthis[kCapture], as node'saddCatchdoes. Nothing storesemit. - Correct because the flag decides, when node reads it.
super.emitand a wrapper capture too. - Verified:
test/js/node/events/emitters-that-capture-rejections.test.ts(main fails 10 of 12). It inverts the test from #45631 that held the ownemit. - Self-reviewed: rework asked for (Notes). Draft until the design is settled.
Background
kCaptureis the flag that the constructor sets from thecaptureRejectionsoption.bun build --compile --aotcompilesevents.tswith no profile, soemit()has a second text there.test/aot/perfholds pins of its instruction counts, outside CI.- Rejected a flag read at the head of
emit(): a symbol lookup per emit. Rejected a swap of the prototype'semitin the setter (#45597): the ownemitstays.
Downsides
- Ahead of time, 37 of 126 event rows exceed the +2% bar of
src/aot/CLAUDE.md. Four rows with two listeners cost 37% to 54% and exceed two pins, which stay as they are. - Interpreter: a listener that returns a promise costs 1,918 to 2,419 instructions per emit.
- JIT: an emit that is not inlined costs 62.0 instructions, 68.2 on main. The first commit alone gives 58.9.
Notes
State on 2026-10-31. The first commit of this branch is now a PR of its own, #47520. A rework of this PR on top of it is written and not pushed: the non-enumerable flag on the prototype as node has it, no noInline marks, four more ledger rows. It waits for the answer to https://github.com/oven-sh/bun/pull/47381#issuecomment-6520392535. The numbers below are of the first version.
Rework. A full review of this PR measured costs that this body did not count, and it built a second design. Both are below. A second design review weighs them now, and the PR changes with its result.
Costs of this text that were not counted:
- Ahead of time, no row of table 6 has a listener that returns a promise. With 48 such rows, 47 are over +2%: one listener that returns a settled promise takes 64 to 131 instructions per emit, one async listener 87 to 154.
bun run: a handledemit("error")takes 28 to 92 instructions, and 57 to 354 with one array per emit when a method of a class emits it. The wrapper of the first commit causes that.bun run: the kept result letsemit()lose its top-tier code in two new ways.test/aot/perf/plain-bun-costs.measure.ts"the top tier compiles no function twice" fails forevents-and-streams.js.- Without the first commit, the change costs +5.8% and +6.7% on two JIT rows of
plain-bun-coststhat are bounded at 3%. - The three
noInlinemarks decide few programs: 2 to 4 of 18 capturing profiles that the review tried. - Node's gate for a thenable (#45597) would charge every listener that returns a value, on every emitter.
The second design, capture at registration: emit() stays the text of main. on() wraps the listener when the emitter captures, and the wrapper reads the flag when the listener returns a promise. Nothing stores emit. Built by the review on this branch:
- The capture tests of this PR pass 12 of 12, and 137 tests of
test/js/node/eventspass. - Ahead of time, 2 of 126 rows are over +2% (
on-and-off, +2.2%), and no pin is exceeded. The emit rows ofbun rundo not move. on()reads the flag: anon()/off()pair takes 4,508 to 4,934 instructions interpreted and 124.1 to 130.5 under the JIT. That read is not tuned yet.- An emitter that captures pays one more call per listener: 1,901 to 2,911 instructions per emit interpreted.
- It is not node where this text is:
emitter._events.x === listeneris false on an emitter that captures, a listener stored by hand into_eventsis not captured, and a listener registered before the flag turned on is not captured.
Behaviour of this text that the review found:
EventEmitter.prototype[kCapture]is enumerable in Bun and not in node.Object.assign({}, EventEmitter.prototype)made while the default is on copies the flag, so such an object captures here. Node and main do not. The fix is node's definition of the property.- A response that captures now reaches Bun's own
ServerResponse.prototype.emit. A program's ownres.emit("close")on a live response then ends the exchange, as it does on main withoutcaptureRejections. Node delivers the response in both cases.
Where the ask comes from. No issue reports this defect. These are the written statements, in order of time:
- cirospaciari on #31828, 2026-06-06 (https://github.com/oven-sh/bun/pull/31828#discussion_r3366464750): "events.ts's per-instance swap keeps the common non-capture emit path free of a per-call kCapture check, and reworking that hot path belongs in its own change if ever."
- alii, review of #31828, 2026-07-07 (https://github.com/oven-sh/bun/pull/31828#pullrequestreview-4649595683): "the fix belongs in events.ts". The review asks for one
EventEmitter.prototype.emitand for nothis.emitstore. - alii, review of #31828, 2026-07-09 (https://github.com/oven-sh/bun/pull/31828#pullrequestreview-4659080972): it asks for the numbers of
bench/emitterand says "Do NOT restore the two-emit split". - #45631, 2026-10-27: it adds the test "an emitter with captureRejections has an
emitof its own, andemitter.emit()calls that one" and stores the ownemitin the ahead-of-time constructors too. - dylan-conway, body of #45597 (open): "The setter swaps
EventEmitter.prototype.emitbetween the two built-in variants, soemitwithout capture still pays nothing."
This PR is the "own change" of the first statement. It keeps one part of the reason for the own emit: in bun run, a listener that returns nothing pays one test of the returned value and no read of the flag (tables 2 and 3). It does not keep the other part: a listener that returns a promise pays the read of the flag on every emitter, and the ahead-of-time rows of table 6 cost more. An emitter that captures also makes one copy of the arguments for each promise that a listener returns, where main handed the one array of the emit to each.
What this PR inverts. On main, test/js/node/events/emitters-that-capture-rejections.test.ts:6 expects the own emit, and expects that the emit() of the class is not called. Three expected files change with it: test/js/node/http/node-http-own-keys-through-an-exchange.expected.txt, test/js/node/stream/node-stream-proxy-traps.expected.txt and test/js/node/stream/stream-reads-seen-without-proxy-receiver.expected.txt. So does the output that test/aot/parallel/library-modules/stream-reads-seen-stream-made-awaited.js carries. Two more tests held main's behaviour: test/js/node/http/node-http-proxy-traps.test.ts expected two stores of kCapture, and a program of test/aot/missing-bun-types.test.ts expected that the emit() of its class is not called.
Other open PRs on these lines.
- #45597 captures a thenable and the promise of an
'error'listener, which this PR leaves as on main. It keeps thethis.emitstore and swapsEventEmitter.prototype.emitin the setter ofEventEmitter.captureRejections. The two PRs change the same lines, so the second to land needs a rebase. On top of this PR, #45597 needs no swap and no store. - #45234: the first commit here is its change to
emit("error"), with a Co-authored-by trailer. - #31828 (node:domain) replaces
EventEmitter.prototype.emit. Its own one-emit commits go away when it is rebased on this PR. - #33369 makes the browser polyfill capture a thenable. It changes lines of
src/node-fallbacks/events.jsthat this PR changes too, so the second to land needs a rebase.
The pins. bun test ./test/aot/perf/pinned-everyday-idioms-{1..8}.measure.ts -t "everyday-emitters/" on linux-x64, instructions per turn, typed and untyped:
| pin | stored | PR |
|---|---|---|
everyday-emitters/two-listeners | 151, 147 | 226, 222 |
everyday-emitters/one-listener-and-two-listeners-in-turns | 122, 118 | 162, 159 |
The PR does not change the pin files. test/aot/perf/README.md says that a pin only goes down, and the owner of the compiler decides whether the text or the pin changes. Two more pins of the group fail on this machine, on main and on the PR alike: many-emitters-events-and-listeners (246.4 against 217.5) and emitter-made-listened-to-and-dropped (843.4 against 500.4). everyday-pins.linux-arm64.json holds the same two pins (154, 150 and 128, 125). They were not measured, because no arm64 machine was at hand. The budgets that CI holds pass. test/aot/servers/budgets-of-node.test.ts counts what it counts on main, with two exceptions: the bytes of code (3,976,368 to 3,959,980) and one count of closures (1,808 to 1,806).
Other ahead-of-time texts that were measured, on the 96 rows of built-in/events-emit and everyday-emitters. About 50 texts were built. These two are the ends of the range:
-
The flag read at the head, in one test with
"error"(if (type === "error" || this[kCapture])), and main's text behind it. 47 rows are over +2%, and the worst is +30% (no-listener, 40 to 52). The small rows with two listeners cost +5%. A row with one listener costs about +10% (built-in/events-emit: 58 to 64). It is not node's timing: it misses a flag that a listener switches on, and a Proxy sees one moregetper emit.bun runand--aotwould differ there. -
The capture called in the place of the listener, so that the loop has no call of its own. The small rows with two listeners cost +3% to +7%, but
built-in/events-emitcosts +17% (58 to 68) andno-listener+47% (40 to 59). -
The flag read once in the loop function, and main's loop behind it for an emitter that does not capture (a review asked for it). The program with two listeners takes 230 instructions per emit, against 222 with this PR and 140 on main. The loop function is still not inlined: one direct call in it keeps it out of
emit(), and a call in a rare block, a call in tail position and$callNoInlinechange nothing.
The text of this PR has 37 of those 96 rows over +2%.
An executable without bun:jsc. bun run marks three small functions with noInline of bun:jsc at the first captured promise, so that the JIT keeps emit() small enough to inline (measurement 4). Plain --trim keeps bun:jsc, and --aot has no JIT. Only Bun.build({ compile: { trim: { remove: ["bun-jsc"] } } }) without aot has a JIT and no bun:jsc. There the marks are skipped: a rejection is captured all the same, but a plain emit then takes about 120 to 138 instructions where it takes 32 to 56 with the marks. No test covers that executable yet.
Review. The ahead-of-time text and the pins are a question for Jarred-Sumner. #45597 is dylan-conway's. #31828 is cirospaciari's, and alii reviewed it.
How the numbers were taken. Release builds with LTO for linux-x64 of main 000ef50373 and of this branch. A number is the count of instructions of the first thread in user mode (perf_event_open), as test/aot/perf/start/measure-start.ts counts them. "Commit 1" is the first commit of this branch alone, measured on the previous base (73b53109f7), where main measured within 0.3% of what it measures now.
1. Arrays per 10,000 emits once the top tier compiled emit() (bun:jsc heapStats().objectTypeCounts.Array. One listener, two listeners, and a listener that returns a promise give the same counts).
emit() | main | PR |
|---|---|---|
| inlined into its caller, constant name | 3 | 3 |
noInline(emitter.emit) | 10,003 | 3 |
| name read out of an array | 10,003 | 3 |
not inlined, after another emitter captured 800 promises and emitted 'error' 200 times | 10,003 | 3 |
2. test/aot/perf/plain-bun-costs programs, million instructions.
| row | main | commit 1 | PR | PR against main |
|---|---|---|---|---|
| interpreter emit-to-one-listener.js | 3,792.4 | 3,782.8 | 3,755.0 | -1.0% |
| jit emit-to-one-listener.js | 526.9 | 321.5 | 340.5 | -35.4% |
| interpreter emit-to-two-listeners.js | 4,615.2 | 4,608.3 | 4,530.0 | -1.8% |
| jit emit-to-two-listeners.js | 737.7 | 444.3 | 474.1 | -35.7% |
| interpreter events-and-streams.js | 4,112.2 | 4,112.4 | 4,082.9 | -0.7% |
| jit events-and-streams.js | 1,075.6 | 1,053.2 | 1,079.6 | +0.4% |
Against commit 1 the three JIT rows cost +5.8%, +6.6% and +2.3%. In emit-to-one-listener.js that is 58.9 to 62.0 instructions per emit and 204 to 216 million of compile time on the program's own thread.
3. Instructions per emit (the difference of a loop of 900,000 and one of 300,000 emits).
| listeners | interpreter main | interpreter PR | JIT main | JIT PR |
|---|---|---|---|---|
| none for the event | 1,373.1 | 1,343.2 | 24.9 | 24.9 |
| one, returns nothing | 1,896.4 | 1,884.8 | 31.8 | 32.0 |
| one, four arguments | 2,423.5 | 2,400.6 | 32.7 | 33.0 |
| two, return nothing | 2,783.3 | 2,734.0 | 80.0 | 76.0 |
| one, returns a number | 1,860.2 | 1,943.3 | 32.1 | 32.0 |
| one, returns a promise | 1,918.2 | 2,419.3 | 31.8 | 39.0 |
The interpreter has no inline cache for a read by symbol, so this[kCapture] costs about 500 instructions there. It is read only behind the promise test.
4. A plain emitter after another emitter ran first in the process (JIT, one listener, one argument, instructions per emit).
| what ran first | main | PR |
|---|---|---|
| another plain emitter | 43.4 | 43.0 |
| an emitter that captures, listeners return nothing | 22.0 | 42.9 |
| an emitter that captured 800 promises | 22.0 | 43.1 |
the same, and 200 'error' emits | 21.7 | 51.0 |
On main an emitter that captures runs a function of its own, so it leaves the profile of emit() alone. Here it counts like any other emitter. The FTL inlines emit() into a caller only while its graph has at most 89 nodes. It has 32 to 70 nodes here and 85 to 89 once promises were captured. It stays under the limit because the first capture marks addCatch, arrayOf and emitErrorApplied with noInline of bun:jsc. Without the marks it has 113 nodes and costs 133.5 instructions per emit.
5. Construction, instructions per new.
| interpreter main | interpreter PR | JIT main | JIT PR | |
|---|---|---|---|---|
new EventEmitter() | 4,464.9 | 3,492.8 | 6.1 | 6.0 |
| a class that extends it | 5,561.6 | 4,564.6 | 110.7 | 110.3 |
{ captureRejections: true } | 3,563.2 | 3,350.7 | 76.9 | 76.0 |
new Readable() | 12,239.3 | 11,186.8 | 62.7 | 62.2 |
bun:jsc describe(): a plain emitter and one that captures now share one Structure (5 of 6 inline fields, nothing out of line). On main the one that captures has a sixth field, emit.
6. Ahead of time. bun test/aot/perf/compare-builds.ts --only 'built-in/events-|everyday-emitters|stream-new' --servers none, 126 rows, instructions per turn.
| rows | |
|---|---|
| within 2% | 82 |
| faster by 2.0% to 5.3% | 7 |
| slower by 2% to 5% | 12 |
| slower by 5% to 12% | 20 |
small typed everyday-emitters/a-class-below-emits-to-itself | 71.0 to 83.0 (+16.9%) |
small typed everyday-emitters/one-listener-and-two-listeners-in-turns | 118.0 to 162.0 (+37.3%) |
small untyped everyday-emitters/one-listener-and-two-listeners-in-turns | 115.0 to 159.0 (+38.3%) |
small typed everyday-emitters/two-listeners | 148.0 to 226.0 (+52.7%) |
small untyped everyday-emitters/two-listeners | 144.0 to 222.0 (+54.2%) |
built-in/events-emit, small and called | 58.0 to 61.0 and 60.0 to 63.0 (+5%) |
built-in/events-emit, very large | 184.0 to 188.0 (+2.2%) |
What was learned about the compiler on the way, and why the text is as it is:
- A rest array stays out of the heap while every function that takes it keeps no reference to it. A function that keeps it makes every emit allocate, in cold code too. So the capture road copies the arguments by a loop (
addCatchOfCopy). $applyof the array counts as a use that the compiler cannot see. So the ahead-of-time text callsemitError(this, args)as main does, and onlybun runapplies.- A call in a rare block still counts against inlining. One more call site in
emit()cost the inlining ofemit()where the event name is no constant (79 to 163). So the promise of one listener leaves through the call ofemitToEachthatemit()already has. - The loop function gains one rare call, and that costs its inlining in a small program. That is the 37% to 54% above. A text that reads the flag before the listeners run keeps the loop inlined (148 to 155), but costs every emit 6 instructions and is not what node does when a listener changes the flag.
7. bench/emitter, wall clock, median of 10 interleaved runs of main and the PR. No difference is above the spread of the runs, which this machine does not bring under 30%.
| bench | main | spread | PR | spread |
|---|---|---|---|---|
microbench.mjs, single emit | 11.96 ns | 29.9% | 10.95 ns | 54.2% |
microbench.mjs, on x 10_000 (handler) | 2.635 ms | 13.3% | 2.635 ms | 5.7% |
microbench_once.mjs, test 1 | 149.28 ns | 44.7% | 121.23 ns | 167.9% |
microbench_once.mjs, test 2 | 112.70 ns | 54.2% | 125.70 ns | 46.4% |
realworld_stream.mjs, stream simulation | 14.70 µs | 57.3% | 14.18 µs | 50.1% |
8. Text. build/release/js/node/events.js: 26,403 to 25,130 bytes.
What stays as on main. A thenable that is no promise is not captured, and a promise that an 'error' listener returns is not captured. Node captures both. #45597 is about those.
Suites run. Debug build: the four test files of this PR, with main's src/ (they fail) and with this branch's (they pass), and node-http-proxy-traps.test.ts. Release build: 218 test files under test/js/node (http, events, stream, async_hooks, net, tls, http2, child_process, worker_threads and others: the five files that fail there fail on main alike), the 21 files of test/js/first_party/ws, 42 upstream files test-event-*, test-events-*, test-eventemitter-* and *capture-rejection*, the 145 programs of test/aot/parallel/library-modules, and bun src/codegen/check-types-of-modules.ts.
The commits. The first keeps the arguments of emit("error") out of the heap, after #45234. The second is the change. The third stores kCapture once where the constructor stored it twice, and can be judged apart. Three small ones follow from review: the debug log line of emit() no longer reads this.constructor (a debug build showed one more get to a Proxy than a release build), the marks are skipped where bun:jsc is not in the executable, and each comment is one line.