Skip to content

PATTERN Cited by 1 source

Record pipe links, resolve at sink

Pattern

In a streaming API where pipeThrough returns a new stream and pipeTo terminates the chain at a sink, don't start piping when pipeThrough is called. Instead, record the upstream-to-downstream link as metadata. Only when pipeTo() (or getReader()) runs at the end of the chain, walk the collected link graph and resolve the entire pipeline in a single, optimised call — typically by delegating to a faster underlying pipe primitive.

Canonical wiki instance — fast-webstreams (2026-04-21)

fast-webstreams uses this pattern to collapse chained pipeThrough / pipeTo between fast streams into a single Node.js stream.pipeline() call. The fast-path chain:

source
  .pipeThrough(transform1)   // records link, no piping yet
  .pipeThrough(transform2)   // records link, no piping yet
  .pipeTo(sink);              // walks upstream, collects Nodes,
                              // issues ONE stream.pipeline() call

Zero Promises per chunk. Data flows through Node's C++-optimised pipe path. Measured: ~6,200 MB/s for a chained fast-to-fast pipeThrough, vs native Web Streams at ~630 MB/s9.8× faster. (sources/2026-04-21-vercel-we-ralph-wiggumed-webstreams-to-make-them-10x-faster)

Why deferred resolution works

Web Streams' pipeThrough is specified to return a new stream (the downstream end of the transform). An implementation has latitude to delay what actually happens when pipeThrough is called — as long as the returned stream behaves correctly when consumed.

Three observations making the pattern safe:

  1. No observer sees piping start. Until something reads from the returned stream's sink side, no data flows. Delaying flow until pipeTo or getReader is observationally equivalent.
  2. The whole chain is discoverable at sink time. Walking upstream links gives the implementation the full graph before committing to a pipe strategy.
  3. Underlying fast primitive exists. For fast-webstreams, that primitive is stream.pipeline() on native Node streams; for other contexts it might be a kernel-level splice(), a gRPC bidirectional pipe, or an async-iteration async generator.

Fallback when chain is mixed

The pattern requires the whole chain to be fast. If any link is native (e.g. a built-in CompressionStream mixed into a fast-webstreams chain), the collected graph includes a non-native hop, and the implementation cannot fuse into stream.pipeline() — falling back to the spec-compliant pipeTo or native pipeThrough implementation for the heterogeneous segment.

Vercel specifically restricts pipeline() use to homogeneous-fast chains because "pipeline() causes 72 WPT failures. The error propagation, stream locking, and cancellation semantics are fundamentally different." See concepts/spec-compliant-optimization for the discipline that enforces this fallback.

Fetch-body variant

Response.prototype.body is a native byte stream owned by Node's HTTP layer. When patchGlobalWebStreams() is active, it's wrapped in a fast shell that records pipeThrough links without starting piping. At sink, the library resolves the chain: one Promise at the native-boundary pull (to drive data in), then zero Promises through the transform chain, then synchronous reads at output.

This is the pattern's most-impactful instance in production workloads — most server streams don't start from new ReadableStream(...) but from fetch(). Measured on fetch() → 3 transforms: native 260 MB/s → fast 830 MB/s = 3.2×.

Sibling patterns

  • patterns/lazy-pull-pipeline — same defer-until-consumed principle, but at the pull-semantics API altitude (new-streams). This pattern is the same idea applied inside an otherwise push-based API to extract compositional fusion.
  • Query-planner phase ordering (concepts/planner-phase-ordering) — sibling at the database altitude. SQL planners don't execute intermediate joins when a parser sees them; they collect the logical plan and compose physical execution once the whole query is known. Same record-then-resolve-at-sink shape.
  • Pipe fusion in compilers — shell pipes fused into a single fork/exec graph; streams fused into a single codegen'd loop. This is the JS-stream-layer analogue.

Forces

  • Composition-at-a-distance. pipeThrough returns a stream; downstream code is often far from where the pipe chain started. The implementation cannot plan fusion at pipeThrough call time because the chain isn't complete yet.
  • API contract preservation. The observable behaviour of pipeThrough must be preserved — it still returns a stream that behaves correctly when consumed.
  • Underlying fast primitive must exist. Without a stream.pipeline() analogue at some lower layer, there's nothing to fuse into.

Counter-indications

  • Chain is heterogeneous — mixed fast + native streams cannot fuse safely; fall back to spec-compliant pipe.
  • Stream is consumed immediately. If pipeThrough is followed synchronously by a consumer reading from the returned stream, deferring resolution doesn't save work — you just have to fire piping anyway, now.
  • Chain is short. A 1-deep chain doesn't benefit from fusion; the pattern's wins scale with chain depth. Measured by Vercel: 3 transforms = 9.7×, 8 transforms = 8.7× on native Web Streams, fused path scales similarly.

Consequences

  • Zero per-chunk Promises in the fused case.
  • Deeper chains scale better — pattern fuses arbitrary depth into O(1) pipe calls.
  • Added complexity at pipe-construction time — the link-recording and fallback-detection code is non-trivial.
  • Debug footgun: stack traces at pipe-start time don't show up at pipeThrough call site; they show up at pipeTo call site. Error attribution needs care.

Seen in

Last updated · 476 distilled / 1,218 read