Found by the package performance audit (real npm packages compiled from source, profiled against Node 26.5.1) and
re-measured on Perry 7661bc0 (v0.5.1589), Linux x64. When a net.Socket 'data' handler defers its next write()
to a microtask (queueMicrotask, promise.then, process.nextTick) or to setImmediate, each request/response turn
waits about 2.2 ms in epoll_wait before the queued JS runs. The loop is 50–60× slower than Node, and the process is
idle for ~95 % of its wall time.
Reproduction
bench.ts is self-contained. An in-process echo server plus a client does N round trips.
import * as net from "node:net";
const variant = process.argv[2] || "queueMicrotask"; const N = Number(process.argv[3] || "1000");
const defer: (f: () => void) => void =
variant === "direct" ? (f) => f() : variant === "queueMicrotask" ? (f) => queueMicrotask(f)
: variant === "promise" ? (f) => { Promise.resolve().then(f); } : variant === "nextTick" ? (f) => process.nextTick(f)
: (f) => { setImmediate(f); };
const server = net.createServer((c) => { c.setNoDelay(true); c.on("data", (d) => c.write(d)); });
server.listen(0, "127.0.0.1", () => {
const port = (server.address() as net.AddressInfo).port;
const s = new net.Socket(); s.setNoDelay(true);
let i = 0; let bytes = 0; let t0 = 0;
const send = () => s.write("ping" + i);
s.on("data", (d) => {
bytes += d.length;
if (++i === N) {
const ms = performance.now() - t0;
console.log(`variant=${variant} N=${N} checksum=${N} ms=${ms.toFixed(1)} rtt_per_s=${(N / ms * 1000).toFixed(0)}`);
s.destroy(); server.close();
} else defer(send);
});
s.connect(port, "127.0.0.1", () => { t0 = performance.now(); send(); });
});
PERRY_NO_AUTO_OPTIMIZE=1 perry compile bench.ts -o bench
for v in direct queueMicrotask promise nextTick setImmediate; do node bench.ts $v 1000; ./bench $v 1000; done
strace -f -T -e trace=epoll_wait ./bench queueMicrotask 200 2>&1 | grep 'epoll_wait(.*, 2) = 0'
Measurements
Median of 3 runs on a shared, loaded host (load average ~100 on 64 threads). Loop = 1,000 round trips. Perry CPU is
task-clock for the whole process.
| variant |
Node loop ms |
Perry loop ms |
ratio |
Perry CPU ms |
Perry instructions |
Node wall |
Perry wall |
| direct write (control) |
34.7 |
36.8 |
1.1× |
47 |
0.09 G |
153 ms |
94 ms |
queueMicrotask(send) |
40.0 |
2,206 |
55× |
121 |
0.12 G |
181 ms |
2,273 ms |
Promise.resolve().then(send) |
36.6 |
2,214 |
60× |
125 |
0.12 G |
235 ms |
2,290 ms |
process.nextTick(send) |
46.7 |
2,259 |
48× |
124 |
0.12 G |
215 ms |
2,333 ms |
setImmediate(send) |
41.6 |
2,213 |
53× |
112 |
0.12 G |
181 ms |
2,275 ms |
Checksums are identical.
- Perry's CPU cost for the deferred loop is still only ~120 ms, close to the direct loop's 47 ms. The ~2.1 s difference is
all idle wall time, 2.2 ms per round trip.
strace shows the stall. Each turn contains epoll_wait(3, [], 1024, 2) = 0 <0.002072>, a 2 ms kernel timeout (the 1 ms
tokio timer rounded up) that expires with no events.
strace -c over 200 round trips: 1,014 epoll_wait calls in the deferred loop against 811 in the direct loop.
Impact
This stall is the largest single cost in the database drivers. Figures are from the audit profile (I/O group,
v0.5.1587, offcputime plus /usr/bin/time):
- pg 8.22.0 (
pool.query('SELECT $1::int',[i]) ×5000, 70.7× Node): 3.32 ms wall per query but only 1.22 ms CPU.
63 % of wall is idle, and 2.41 s of a 4 s window sits in main → stdlib_fast_drive → block_on → time driver park → epoll_wait.
- mysql2 3.23.2 (
pool.execute ×5000, 51× Node): 47 % of wall is idle, 99.8 % of it in the same stack.
- The audit estimated the effect of removing the stall alone: pg ~3.3 → ~1.25 ms/query, mysql2 ~4.5 → ~2.4 ms/query.
Both drivers hit the stall the same way. The socket 'data' handler resolves the query promise, and the application's
await continuation issues the next query, so the next write() always runs from a microtask. Any protocol client
built on net with promise-based request/response has this shape (Redis, MongoDB, SMTP and custom RPC clients).
A node:http server whose handler does await before res.end() did not stall in the same test (500 keep-alive
requests: 41 ms vs 45 ms sync). The stall is specific to the path that drives net sockets.
Mechanism
crates/perry-runtime/src/event_pump.rs:544 (verified): js_wait_for_event takes the fast path at line 577 when
NOTIFIED is set or js_microtasks_pending() > 0. It calls invoke_wait_driver_fast() (line 581) and returns, so
the pending JS runs only after the driver returns.
crates/perry-stdlib/src/common/async_bridge.rs:370-386 (verified): the registered fast driver stdlib_fast_drive
(installed at line 477) checks native_fast_drive_needed (blocking tasks in flight, or js_aux_has_active(), which
is true while an extension such as net has a live handle). If that returns true, it runs
RUNTIME.block_on(timeout(Duration::from_millis(1), EVENT_READY.notified())) (line 384).
- With a request/response protocol nothing native can arrive during that wait, because the peer is still waiting for the
request that sits in the microtask queue. So every turn pays the full timer.
- The comment above the fast path explains why it exists: it gives in-flight native tasks a turn so constant promise
churn cannot starve them (the fetch hang). Its budget, though, is a blocking 1 ms timeout rather than a non-blocking
poll.
- (inferred) The same stall applies to any
js_aux_has_active contributor, not only net.
What fast looks like
When JS work is already pending, the fast drive should poll the reactor without blocking: run ready tasks and I/O
events, then return immediately. It should keep a zero or near-zero timeout, and use a bounded tick count rather than a
wall-clock budget to preserve the anti-starvation guarantee.
Targets:
- Microbenchmark: each deferred variant within 1.2× of the direct-write variant and within 1.5× of Node (≤ ~60 ms per
1,000 round trips here).
- Wall vs CPU: wall ≈ CPU, with no
epoll_wait(..., 2) = 0 in the steady-state strace.
- Workloads: pg and mysql2 idle share of wall under 10 %.
Notes
Found by the package performance audit (real npm packages compiled from source, profiled against Node 26.5.1) and
re-measured on Perry 7661bc0 (v0.5.1589), Linux x64. When a
net.Socket'data'handler defers its nextwrite()to a microtask (
queueMicrotask,promise.then,process.nextTick) or tosetImmediate, each request/response turnwaits about 2.2 ms in
epoll_waitbefore the queued JS runs. The loop is 50–60× slower than Node, and the process isidle for ~95 % of its wall time.
Reproduction
bench.tsis self-contained. An in-process echo server plus a client does N round trips.Measurements
Median of 3 runs on a shared, loaded host (load average ~100 on 64 threads). Loop = 1,000 round trips. Perry CPU is
task-clock for the whole process.
queueMicrotask(send)Promise.resolve().then(send)process.nextTick(send)setImmediate(send)Checksums are identical.
all idle wall time, 2.2 ms per round trip.
straceshows the stall. Each turn containsepoll_wait(3, [], 1024, 2) = 0 <0.002072>, a 2 ms kernel timeout (the 1 mstokio timer rounded up) that expires with no events.
strace -cover 200 round trips: 1,014epoll_waitcalls in the deferred loop against 811 in the direct loop.Impact
This stall is the largest single cost in the database drivers. Figures are from the audit profile (I/O group,
v0.5.1587,
offcputimeplus/usr/bin/time):pool.query('SELECT $1::int',[i])×5000, 70.7× Node): 3.32 ms wall per query but only 1.22 ms CPU.63 % of wall is idle, and 2.41 s of a 4 s window sits in
main → stdlib_fast_drive → block_on → time driver park → epoll_wait.pool.execute×5000, 51× Node): 47 % of wall is idle, 99.8 % of it in the same stack.Both drivers hit the stall the same way. The socket
'data'handler resolves the query promise, and the application'sawaitcontinuation issues the next query, so the nextwrite()always runs from a microtask. Any protocol clientbuilt on
netwith promise-based request/response has this shape (Redis, MongoDB, SMTP and custom RPC clients).A
node:httpserver whose handler doesawaitbeforeres.end()did not stall in the same test (500 keep-aliverequests: 41 ms vs 45 ms sync). The stall is specific to the path that drives
netsockets.Mechanism
crates/perry-runtime/src/event_pump.rs:544(verified):js_wait_for_eventtakes the fast path at line 577 whenNOTIFIEDis set orjs_microtasks_pending() > 0. It callsinvoke_wait_driver_fast()(line 581) and returns, sothe pending JS runs only after the driver returns.
crates/perry-stdlib/src/common/async_bridge.rs:370-386(verified): the registered fast driverstdlib_fast_drive(installed at line 477) checks
native_fast_drive_needed(blocking tasks in flight, orjs_aux_has_active(), whichis true while an extension such as
nethas a live handle). If that returns true, it runsRUNTIME.block_on(timeout(Duration::from_millis(1), EVENT_READY.notified()))(line 384).request that sits in the microtask queue. So every turn pays the full timer.
churn cannot starve them (the fetch hang). Its budget, though, is a blocking 1 ms timeout rather than a non-blocking
poll.
js_aux_has_activecontributor, not onlynet.What fast looks like
When JS work is already pending, the fast drive should poll the reactor without blocking: run ready tasks and I/O
events, then return immediately. It should keep a zero or near-zero timeout, and use a bounded tick count rather than a
wall-clock budget to preserve the anti-starvation guarantee.
Targets:
1,000 round trips here).
epoll_wait(..., 2) = 0in the steady-statestrace.Notes
wait driver and the gated fast drive.
stdlib_fast_drive'stimeout(from_millis(1), …)unchanged in its tokio arm (thediff only adds loop-stats counters around it). Whether its turnloop arm avoids this stall was not measured. This
microbenchmark is a direct acceptance test for it.
socket.cork()not batching writes (perf:socket.cork()/uncork()are no-ops, so 5 corked writes cost 5sendtocalls where Node makes 1writev(1.6× slower loop) #10526) is a separate, smaller per-query cost in the same drivers.