Skip to content

CONCEPT Cited by 1 source

Microtask hop cost

Microtask hop cost is the per-invocation CPU + memory overhead of resolving a Promise through JavaScript's microtask queue — even when the resolution value is already available. Promises resolve via the microtask queue rather than synchronously, so every await on a resolved Promise still costs one queue enqueue + dequeue + callback invocation hop.

The shape of the cost

When await promise runs:

  1. Current function suspends, captures its continuation.
  2. .then(continuation) is called on the Promise.
  3. If Promise is already resolved: microtask enqueued immediately. If pending: microtask enqueued on resolution.
  4. Current synchronous work continues (or call stack unwinds).
  5. Microtask queue drained: continuation invoked with value.

Steps 3-5 are the hop. Even when the Promise is pre-resolved (Promise.resolve(value)), the hop still runs — JavaScript spec requires it. This is what makes await Promise.resolve(x) not a no-op.

Why it matters on streaming hot paths

A streaming pipeline running await reader.read() per chunk pays this hop per chunk. At streaming SSR scale (millions of chunks per hour per instance), the hop cost compounds:

"Consider what happens when you call reader.read() on a native WebStream in Node.js. Even if data is already sitting in the buffer: [...] resolution goes through the microtask queue. That is four allocations and a microtask hop to return data that was already there." (sources/2026-04-21-vercel-we-ralph-wiggumed-webstreams-to-make-them-10x-faster)

The hop itself isn't free:

  • Closure allocation — the .then callback capturing continuation state.
  • Queue entry — a small object in the microtask queue.
  • Queue drain + invocation — cost scales with how full the queue is under load.
  • Allocation pressure → GC scavenger runs more often, stealing request CPU.

Not visible on synthetic benchmarks

At single-call latency, the hop is ~nanoseconds and invisible. The cost only surfaces when you're paying it per item in a large pipeline — which is why it can be missed in naive await someRead() benchmarks but dominates when a streaming SSR pipeline enqueues thousands of chunks.

Cannot be eliminated, only reduced

Unlike ReadableStreamDefaultReadRequest allocation, which is implementation-discretionary, the microtask hop is language-level. Promise.resolve(x) still schedules through the microtask queue to preserve Promise semantics (the consumer's callback must run asynchronously, even if the value is immediately available, so side-effect ordering in between is preserved).

Mitigations target batching rather than elimination:

  • Yield batches per hop. Uint8Array[] per iteration instead of one chunk per iteration — one hop per batch of N chunks, not N hops. new-streams uses this by default.
  • Skip hops in composition. When source + all transforms + sink are known at pipe-construction time, the implementation can fuse into a single Node-level stream.pipeline() call with zero WHATWG-level hops per chunk. See patterns/record-pipe-links-resolve-at-sink.
  • Synchronous-style APIs. Stream.pullSync in new-streams; synchronous Iterator::next() analogs in other languages.

Relationship to promise-allocation-overhead

concepts/promise-allocation-overhead is the umbrella cost category — allocation + microtask + GC + coordination promises. Microtask hop cost is the scheduling sub-class. You can reduce allocation without reducing hops (e.g. reuse a pre-allocated Promise) and still pay the hop. You can reduce hops without reducing allocation (e.g. batch reads but still allocate new {value, done} per batch).

Cross-runtime generality

Seen in

Last updated · 476 distilled / 1,218 read