perf(workers): incremental message-passing cleanups (#35108)
Incremental, low-risk optimizations to the worker `postMessage`/receive
hot path. They do not change the architecture of how messages are
delivered; they remove redundant work and an inconsistency in the
receive loops, and are independently mergeable. Companion to the larger
architectural change in #11561.
### 1. Drain symmetry — Web `Worker` main side drains queued messages
within the turn
The worker global (`runtime/js/99_main.js` `pollForMessages`) and the
`node:worker_threads` polyfill (`ext/node/polyfills/worker_threads.ts`
`#pollMessages`) drain already-queued messages with the cheap sync op
(`op_*_recv_message_sync()`) after an async receive, instead of taking
the full async op + Promise path for each. The Web `Worker` main-side
loop (`runtime/js/11_workers.js` `#pollMessages`) did not — every
message went through an async op round-trip.
This adds the same drain to `11_workers.js`. It does not help a strict
ping-pong (the queue is empty between turns, so the drain exits
immediately and stays on the fast path) but speeds up any burst or
fan-in delivered to the main thread of a Web `Worker`, processing the
whole burst within one event-loop turn.
**Correctness — microtask checkpoint between drained messages.** Each
message dispatch is its own task, so a microtask checkpoint must run
between them. WPT `workers/Worker-structure-message.html` posts two
messages from the worker in one turn and re-assigns `onmessage` inside a
`.then` between them; without a checkpoint the second message is
delivered to the stale handler and lost (the test hangs). A
*synchronous* checkpoint can't help — the drain runs while already
inside V8's microtask checkpoint (the `await` continuation), and V8
won't run microtasks reentrantly. So the drain peeks the next message
with the sync op and, only when one is queued, yields a microtask
(`await new Promise(queueMicrotask)`, the same trick the MessagePort
`start()` loop uses) before delivering it. This same yield is applied to
all three receive loops so they drain identically and correctly (the
worker-global and node loops had the same latent hazard).
### 2. Trim per-message allocations in message dispatch
The three `MessageEvent` dispatch sites — `dispatchWorkerMessage`
(`99_main.js`), `#dispatchWorkerMessage` (`11_workers.js`) and
`dispatchPortMessageData` (`13_message_port.js`) — ran an
`ArrayPrototypeFilter` over the transferables to extract `ports` for
**every** message. For the overwhelmingly common no-transferables case
this is pure churn.
They now pass `ports: undefined` when there are no transferables, which
lets the `MessageEvent` constructor take its existing cheap `ports ==
null` branch: a single frozen empty array with no iterator-validation
loop. This removes both the per-message filtered-array allocation and
the validation walk. Spec-observable behavior for the transferables case
(`event.ports`, multiple `addEventListener` listeners, the trusted flag)
is unchanged — the `MessageEvent` is still constructed and dispatched
through the full `EventTarget` machinery.
### 3. Avoid serde_v8 struct serialization on the receive op return
`op_host_recv_message` and the worker/port equivalents returned
`Option<JsMessageData>` via `#[serde]`, so serde_v8 rebuilt a JS object
per message even though the payload is essentially a `DetachedBuffer`
plus a usually-empty transferables list.
`JsMessageData` now derives `deno_core::ToV8` (with `#[to_v8(serde)]` on
the `data` buffer and the transferables list). The derive builds the
result object directly — interned `data`/`transferables` keys and a
single `Object::with_prototype_and_properties` call — instead of routing
the whole struct through serde_v8's `Serializer`. The `#[serde]` return
annotation is removed from the seven receive ops
(`op_message_port_recv_message`/`_sync`,
`op_worker_recv_message`/`_sync`, `op_host_recv_message`/`_sync`,
`op_node_worker_thread_recv_message`). `Deserialize` is kept for the
post-message ops (which take `JsMessageData` as input) and `Serialize`
for the one-shot worker-metadata bootstrap in `web_worker.rs`.
### Tests
- `tests/unit/worker_test.ts`: a Web `Worker` that synchronously posts a
burst of 500 messages to the host delivers all of them, in order, with
no drops (sync drain); and a worker that replies with two messages while
the host re-arms `onmessage` between them in a `.then` (mirrors WPT
`Worker-structure-message`, the microtask-yield case). New
`tests/testdata/workers/message_burst.ts` and `structure_message.ts`
fixtures.
- `tests/unit/message_channel_test.ts`: a no-transferables message
yields an empty, frozen `ports` array (dispatch fast path + `ToV8`
recv-op return).
Verified locally: `worker_test`, `message_channel_test` and
`unit_node::worker_threads_test` pass, and a local reproduction of WPT
`Worker-structure-message` passes.
### Benchmarks
Re-ran the #11561 ping-pong and fire-and-forget benchmarks and compared
against Node.js, as requested. **Node v24.8.0** is the reference runtime
from the issue; **Deno base** is stock `2.8.2`; **Deno patched** is this
branch as a release build. Each benchmark sends 100k messages; lower is
better.
Host: Linux x86_64 GCP VM. Absolute numbers differ from the macOS arm64
machine in the original investigation, so the useful signal is the
before→after delta and the gap to Node on the same host.
| Benchmark | Node v24.8.0 | Deno base | Deno patched | Delta vs base |
Node gap |
|---|---:|---:|---:|---:|---:|
| `node:worker_threads` ping-pong, payload=0 | 2.293s | 5.125s |
**4.349s** | **-15%** | 2.23x -> **1.90x** |
| `node:worker_threads` ping-pong, payload=100 | 4.532s | 8.460s |
**7.608s** | **-10%** | 1.87x -> **1.68x** |
| `node:worker_threads` fire-and-forget | 0.129s | 0.752s | **0.350s** |
**-53%** | 5.83x -> **2.71x** |
| Web `Worker` ping-pong | n/a | 5.290s | **4.267s** | **-19%** | n/a |
| Web `Worker` fan-in/burst | n/a | 0.379s | **0.358s** | **-6%** | n/a
|
The largest win is the one-way throughput path, where skipping
no-transferables dispatch churn and returning recv-op data without
serde_v8 more than doubles throughput. Ping-pong improves 10-19%; the
remaining gap is the larger architectural work tracked in #35025. The
Web Worker fan-in benchmark is roughly neutral because the sync drain
removes async-op overhead but must yield a microtask between drained
messages to preserve task boundaries.
Refs #35024
Closes denoland/divybot#560
---------
Co-authored-by: divybot <divybot@users.noreply.github.com>
Co-authored-by: Divy Srivastava <me@littledivy.com>