Skip to content

RFC: compress serialized payload refs — zstd (gzip fallback), specVersion 5#2394

Merged
pranaygp merged 6 commits into
mainfrom
pgp/ref-compression
Jun 16, 2026
Merged

RFC: compress serialized payload refs — zstd (gzip fallback), specVersion 5#2394
pranaygp merged 6 commits into
mainfrom
pgp/ref-compression

Conversation

@pranaygp

@pranaygp pranaygp commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

RFC: Compress serialized payload refs (zstd, gzip fallback)

Summary

Worlds now store compressed payloads. Every serialized payload (step inputs/outputs, workflow arguments/return values, errors, hook payloads) is wrapped in a composable codec format prefix before it reaches the World storage layer, cutting stored bytes by ~73–89% on real-world-style workloads (benchmarks below). zstd is the preferred codec — 3–7× faster than gzip at an equal-or-better ratio — with gzip as the portable fallback; reads dispatch on the prefix so both are always decodable. Compression is gated on a new specVersion 5 so the compatibility contract is explicit and testable.

Motivation

The event log re-serializes full payloads at every step boundary — an AI agent workflow that threads a growing chat history through 10 steps stores the conversation 10 times. Payloads are devalue-encoded JSON-ish text, which is highly compressible, and several storage backends amplify the bytes further (DynamoDB inline refs and world-local JSON files base64-encode binary, a 4/3× penalty that compression also claws back). Smaller payloads also push more Vercel-world refs under the 3750-byte inline cutoff, which means fewer S3 round-trips on replay — a latency win, not just storage.

Design

Compression must live in the SDK, not the server

On Vercel, payloads are AES-256-GCM encrypted client-side with a per-run key before the world ever sees them. Encrypted bytes are incompressible, so server-side compression (e.g. in remote-ref.ts S3/DynamoDB writes) would be a no-op for the dominant case. Compressing in @workflow/core's serialization pipeline — before encryption — is the only placement that works, and it benefits every world (vercel, postgres, local) from one seam.

A composable format layer, mirroring encryption

The serialization pipeline already supports composable format prefixes (devl, encr). This PR adds compression as a sibling layer in packages/core/src/serialization/compression.ts, mirroring encryption.ts:

serialize:    codec → 'devl' prefix → compress ('zstd'|'gzip' wrap) → encrypt ('encr' wrap)
deserialize:  decrypt → decompress → codec        (each layer dispatches on prefix)

Because readers dispatch on the prefix at every layer, one read path transparently handles compressed (either codec), uncompressed, encrypted, and any nesting of these — new SDKs read both old and new data structurally, not via special cases.

Codec choice: zstd preferred, gzip fallback

compress() picks zstd when node:zlib zstd is available (Node ≥ 22.15 — the production runtime), since it benchmarks 3–7× faster than gzip at an equal-or-better ratio and compression runs at every step boundary. It falls back to gzip via the portable CompressionStream on runtimes without node:zlib zstd, and WORKFLOW_COMPRESSION_CODEC=gzip forces the portable codec (handy for A/B or pinning). Both codecs are always decodable on read — the per-payload prefix means a mixed-codec event log is a non-event. gzip and zstd read support co-ship, so a single specVersion-5 capability gate covers both (no per-codec version skew is possible).

Conditional compression

  • Payloads < 1 KB are passed through (codec overhead isn't worth it).
  • If compression doesn't shave ≥ 5%, the uncompressed original is kept — already-compressed binary (images, archives) never pays a decompression tax or inflates.
  • WORKFLOW_DISABLE_COMPRESSION=1 is a write-side kill switch. Reads are unaffected.

specVersion 5: the compatibility contract

SPEC_VERSION_CURRENT is bumped to 5 (SPEC_VERSION_SUPPORTS_COMPRESSION). The contract:

  • Runs at spec ≥ 5 may contain compressed payloads. Writers only compress into spec-5 runs.
  • Old SDKs (spec ≤ 4) reject spec-5 runs up front via the existing requiresNewerWorld()RunNotSupportedError machinery — a clear, typed "this run requires a newer SDK" instead of a cryptic per-payload format error. Since v5 is still in beta, this is the natural cut point: the v4 SDK cannot read/write/cancel v5 runs, but the first non-beta v5 client handles both v4 and v5 runs.
  • v5 SDKs never write compressed payloads into spec-4 runs, so a run created by a v4 SDK stays fully v4-readable for its entire lifetime.

Write-side gating, per call site

Path Gate
start() workflow arguments new run's resolved specVersion ≥ 5, AND (same-deployment ‖ cross-deployment capability probe supports gzip)
Step arguments (suspension handler) run.specVersion ≥ 5 (run record in scope)
Step outputs/errors (step executor) run.specVersion ≥ 5 threaded via StepExecutorParams.runSpecVersion
Step outputs/errors (V1 step handler) step entity's specVersion ≥ 5 (stamped by the same-deployment orchestrator)
Workflow return value run.specVersion ≥ 5
Run errors (run_failed) run.specVersion ≥ 5 where the run record is in scope; otherwise uncompressed
Hook payloads (resumeHook) target run.specVersion ≥ 5 AND target deployment capability (getRunCapabilities) — same pattern as the existing encr gate

Cross-deployment writes reuse the existing capabilities machinery: gzip and zstd entries in FORMAT_VERSION_TABLE (capabilities.ts) keyed on the target run's workflowCoreVersion, exactly like encr and framedByteStreams before it. (The table column in this section reads "supports compression"; since the codecs co-ship, the gate is one boolean.)

Read paths (incl. browser zstd via WASM)

zstd is Node-only (the Web CompressionStream/DecompressionStream has no zstd), so each reader decodes appropriately:

  • Node (runtime replay, CLI, server o11y): node:zlib zstd/gzip, resolved through process.getBuiltinModule — no static Node dependency, so the modules stay browser-safe.
  • Browser (web o11y decrypt flow): gzip via the web-standard DecompressionStream; zstd via a WASM decoder (@tootallnate/zstd-wasm, ~160 KB, compiled lazily on first use) that @workflow/web-shared registers with core through a new registerZstdDecoder hook. Core stays free of the WASM dependency; the o11y host supplies it.

Observability

  • hydrateData (sync, used by the CLI and server o11y on Node) decompresses synchronously via node:zlib resolved through process.getBuiltinModule — no static Node dependency, so the module stays browser-safe.
  • hydrateDataWithKey (async, used by the web UI's decrypt flow) decompresses via the web-standard DecompressionStream, handling encr(gzip(devl)) and bare gzip(devl).
  • A new isCompressedData() helper mirrors isEncryptedData() for UI affordances.
  • Browser-side sync hydration of unencrypted compressed payloads passes the data through untouched (like encrypted data) rather than throwing; on Vercel production payloads are encrypted and already go through the async path.

Backwards/forwards compatibility

Reader \ Data v4 run (uncompressed) v5 run (may be compressed)
v4 SDK / CLI ✅ works as today ❌ rejected up front with RunNotSupportedError
v5 SDK / CLI ✅ reads, and writes only uncompressed payloads into it

Known edge (documented tradeoff): in local dev, upgrading the SDK mid-run and continuing an old spec-4 run keeps its payloads uncompressed on the orchestrator paths (run-record gating) but the V1 step-handler path gates on the step entity's writer-stamped specVersion, which can compress step outputs of an old run after an upgrade. Deployed runs are pinned to their deployment (skew protection), so this cannot happen in production. The same writer-stamped behavior already exists for encr and byte-stream framing.

Benchmarks

All benchmark code lives in packages/core/scripts/ and is reproducible — shared deterministic workloads in lib/workloads.mjs, run instructions in scripts/README.md. Two dimensions: storage size and CPU cost.

Storage size — benchmark-compression-size.mjs

Raw serialized payload bytes handed to World storage, compression off vs on:

Raw serialized payload bytes handed to World storage, off vs on (zstd, the shipped codec):

Workload Uncompressed Compressed Savings
AI chat history (60 messages) 73.4 KB 19.5 KB 73.5%
API response (250 users) 92.5 KB 10.6 KB 88.6%
E-commerce order (30 items) 6.6 KB 1.4 KB 78.2%
Scraped document (~27 KB text) 27.4 KB 7.5 KB 72.6%
Time series (2000 points) 57.2 KB 15.5 KB 72.9%
Random binary (256 KB) 341.4 KB 256.6 KB 24.8%
Tiny payload (<1 KB) 53 B 53 B (passthrough)

Simulated 10-step AI agent run (event log total): 402.1 KB → 109.4 KB (72.8% smaller). Incompressible binary still wins ~25% because devalue base64-encodes Uint8Array (4/3×) and compression recovers that. Backends that base64 binary (DynamoDB inline refs, world-local JSON) see ~33% larger absolute savings. Text workloads are seeded non-repetitive prose to avoid overstating ratios.

CPU cost — benchmark-compression-cpu.mjs

Compression is a world-independent CPU cost on the serialize (write) and deserialize (read) paths — the same @workflow/core code runs before any World is touched. So these absolute numbers hold for every backend; the world only sets the baseline the cost is compared against. Real shipping path (zstd), µs per op, on an M-series laptop:

Workload serialize off → on deserialize off → on
AI chat history (73 KB) 251µs → 389µs 42µs → 101µs
API response (92 KB) 1261µs → 1309µs 288µs → 325µs
E-commerce order (6.6 KB) 97µs → 112µs 22µs → 29µs
Scraped document (27 KB) 78µs → 136µs 18µs → 44µs
Time series (57 KB) 1554µs → 1658µs 211µs → 251µs
Random binary (256 KB) 1522µs → 1832µs 200µs → 457µs
Tiny payload (<1 KB) 2.9µs → 2.9µs (passthrough) 1.3µs → 1.3µs

Stress — thousands of events (≈6.6 KB e-commerce payload, ser+deser per event, modelling a long workflow + replay):

Events off on added CPU
1,000 120ms 143ms +23ms
5,000 610ms 720ms +110ms
10,000 1200ms 1455ms +254ms

A 10,000-event run adds only ~250ms of total CPU across its entire lifetime — ~4× less than gzip (which added ~1.1s). Costs scale with payload size; the <1 KB threshold makes small payloads free.

zstd vs gzip (the reason for the switch; node:zlib sync, level 3 zstd vs level 6 gzip = what each codec actually ships):

Workload gzip‑6 ratio / time zstd‑3 ratio / time Speedup
AI chat (73 KB) 74.0% / 1047µs 73.5% / 141µs 7.4×
API (92 KB) 86.6% / 389µs 88.6% / 98µs 4.0× + better ratio
Document (27 KB) 72.5% / 207µs 72.7% / 63µs 3.3×
Time series (57 KB) 68.3% / 568µs 72.9% / 106µs 5.4× + better ratio

zstd is faster on both compress and decompress with equal-or-better ratios; the win compounds because compression runs at every step boundary. zstd‑19 was far too slow (14–23 ms) to consider; level 3 (default) is the sweet spot. The script also reports gzip levels 1/9 and brotli for reference.

End-to-end runtime — bench.bench.ts (pnpm bench:local)

The existing stress-workflow harness, run twice against a local nextjs-turbopack dev server — compression on (default) vs off (WORKFLOW_DISABLE_COMPRESSION=1), diffing bench-timings-*.json:

Case off on delta
10 sequential steps ×10 KB 1179ms 1309ms +11.1%
25 sequential steps ×10 KB 3057ms 2874ms −6.0%
50 sequential steps ×10 KB 6292ms 5875ms −6.6%
10 concurrent steps ×10 KB 431ms 456ms +5.9%

End-to-end, compression's CPU is within run-to-run noise (±10%) — and net faster on the larger cases, because the highly compressible 10 KB payload shrinks enough that smaller filesystem IO outweighs the gzip CPU. (Caveat: the harness's 'x'.repeat(10240) payload is pathologically compressible; the microbenchmark above with realistic payloads is the cleaner CPU signal.) The takeaway: orchestration + queue + IO dominate per-step wall-clock, so the sub-millisecond gzip CPU disappears in the noise.

Vercel

Now enabled. @workflow/world-vercel advertises specVersion: 5 (this PR), so new Vercel runs are created compressible — payloads on Vercel are now encr(zstd(devl)). This is unblocked by the server-side companion vercel/workflow-server#520 (merged), which formally declared spec-5 support (payloads stay opaque to the server; the bump is the contract that lets the SDK stamp spec-5 runs). The ordering held: server first, then this SDK bump.

Measurement: the bench.bench.ts harness runs against a real Vercel labs deployment via the Benchmark Vercel (nextjs-turbopack / nitro-v3 / express) jobs in .github/workflows/benchmarks.yml on every PR, comparing this branch against the main baseline (spec-4, no compression) and posting the delta as a sticky PR comment — that comment is the Vercel compression result. For ad-hoc A/B on a real app, deploy the PR tarball and toggle WORKFLOW_DISABLE_COMPRESSION=1 as a Vercel project env var (off baseline) vs unset (on); scripts/README.md documents the command. Expectation: the relative impact on Vercel is the smallest of any backend — a Vercel step's wall-clock is dominated by queue dispatch, AES-GCM encryption, S3/DynamoDB writes, and HTTP round-trips (100s of ms), so the sub-ms compression CPU is a tiny fraction.

Verified end-to-end against the nextjs-turbopack workbench (world-local): new runs are created with specVersion: 5, large step outputs and error payloads appear on disk with the zstd prefix (gzip when forced), small payloads stay as readable devl passthrough, and workflows complete and replay correctly.

Observability

The serialize (write) and deserialize (read) paths emit OpenTelemetry span attributes so compression's impact shows up per-step in any OTel backend (incl. Vercel's), without manual storage inspection:

Attribute Meaning
workflow.serialization.operation serialize (write) or deserialize (read)
workflow.serialization.compressed whether a codec applied / was present
workflow.serialization.codec which codec applied (zstd / gzip / none)
workflow.serialization.uncompressed_bytes logical (devalue) payload size
workflow.serialization.stored_bytes post-compression size (pre-encryption)
workflow.serialization.compression_ratio fraction saved, 0..1 (only when compressed)

Sizes are at the compression boundary (pre-encryption), so they measure compression's effect, not at-rest size (which adds the encr envelope + base64 on some backends). Attributes land on the active span — typically the dedicated step.dehydrate / step.hydrate span, otherwise the enclosing run/start span. The compression codec stays pure: compress/decompress populate a CompressionStats sink threaded through CodecOptions; the dehydrate/hydrate wrappers set the attributes. Telemetry failures are swallowed and never affect serialization.

A "Compressed / saved N%" badge in the web trace viewer (deriving from hydrateDataWithKey after it peels encr→codec) is a possible follow-up; the attributes above are the foundation.

Open question: should world-local compress? (please discuss)

This PR compresses uniformly across all worlds, including world-local — no special casing. That's a deliberate simplification, but it's up for debate:

  • Local world JSON files were never truly greppable: since spec v2, every payload is base64-encoded binary (fs.ts jsonReplacer), so compression doesn't change much in practice — base64(devalue) was already opaque to grep.
  • Option A (follow-up): make world-local actually human/AI-debuggable — skip compression there (via a World capability flag) and store unencrypted devl text payloads as plain readable strings in the JSON files. Best DX for local debugging and LLM agents grepping run data.
  • Option B (follow-up): double down on binary — accept that local data files are machine-read only and switch world-local storage from JSON+base64 to CBOR for further size/speed wins (the schema layer already supports CBOR in world-postgres).

Either follow-up is cheap; the read path handles both formats forever regardless.

Out of scope / future work

  • Rollout note (read it before merging): zstd writes require zstd-capable readers. Same-deployment runtime replay and the co-shipped CLI/web o11y are covered; cross-deployment SDK skew is gated by FORMAT_VERSION_TABLE. But independently-deployed older dashboards/CLI have no zstd decoder until updated, so they'd show a zstd payload un-hydrated (degrades to "can't display", not a crash) until redeployed. Acceptable while spec 5 is beta; WORKFLOW_COMPRESSION_CODEC=gzip forces the portable codec if a fleet needs it.
  • Further codecs / levels: the prefix system makes any future codec (brotli, a higher zstd level, dictionary compression) migration-free. zstd level 3 is the shipped default; the benchmark script makes re-evaluating trivial.
  • Stream frame compression: transient data, small chunks, low ROI.
  • Server-side compression of the unencrypted minority in remote-ref.ts (workflow-server): only worth revisiting if DynamoDB WCU data says so.
  • A handful of rare error-write paths without a run record in scope (max-deliveries exceeded, replay-budget exhaustion) stay uncompressed — error payloads are typically under the 1 KB threshold anyway.

Testing

  • compression.test.ts: round-trips, encr(zstd(devl)) nesting order (white-box), zstd-preferred + gzip-fallback codec selection, mixed-codec decode, small-payload passthrough, incompressible-discard, kill switch, mode serializers, o11y sync+async hydration, capability-table gating (both codecs).
  • compression-telemetry.test.ts: serialize/deserialize span attributes incl. codec.
  • zstd-decoder.test.ts (web-shared): the WASM decoder round-trips node:zlib zstd output — locks the cross-codec contract for the browser path.
  • spec-version.test.ts: spec 5 constants, requiresNewerWorld accept/reject matrix, v4-reader simulation.
  • Full unit suites green across @workflow/core (1225 tests), @workflow/world, @workflow/world-local, @workflow/world-vercel, @workflow/world-postgres, workflow, @workflow/cli, @workflow/web-shared.
  • e2e against a local nextjs-turbopack dev server: spec-5 runs store zstd-prefixed payloads on disk (verified magic bytes) and replay/complete correctly.
  • Benchmarks (size + CPU + zstd-vs-gzip + end-to-end) reproducible via packages/core/scripts/ — see scripts/README.md.

🤖 Generated with Claude Code

Add a composable 'gzip' format prefix layer to the serialization
pipeline (compress before encrypt: encr(gzip(devl))), cutting stored
payload bytes by ~70-87% on real-world-style workloads. Compression is
gated on run specVersion 5 (new SPEC_VERSION_SUPPORTS_COMPRESSION) and
on target-deployment capabilities for cross-deployment writes; payloads
under 1KB or that don't compress meaningfully are stored unchanged.
Reads dispatch on the format prefix so both compressed and uncompressed
data are always readable. WORKFLOW_DISABLE_COMPRESSION=1 disables
writes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@pranaygp pranaygp requested a review from a team as a code owner June 13, 2026 00:06
Copilot AI review requested due to automatic review settings June 13, 2026 00:06
@changeset-bot

changeset-bot Bot commented Jun 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2df737a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 20 packages
Name Type
@workflow/core Minor
@workflow/world-vercel Minor
@workflow/world Minor
@workflow/web-shared Minor
@workflow/builders Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web Minor
workflow Minor
@workflow/world-testing Patch
@workflow/world-local Patch
@workflow/world-postgres Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch
@workflow/nuxt Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview, Comment Jun 16, 2026 8:49pm
example-nextjs-workflow-webpack Ready Ready Preview, Comment Jun 16, 2026 8:49pm
example-workflow Ready Ready Preview, Comment Jun 16, 2026 8:49pm
workbench-astro-workflow Ready Ready Preview, Comment Jun 16, 2026 8:49pm
workbench-express-workflow Ready Ready Preview, Comment Jun 16, 2026 8:49pm
workbench-fastify-workflow Ready Ready Preview, Comment Jun 16, 2026 8:49pm
workbench-hono-workflow Ready Ready Preview, Comment Jun 16, 2026 8:49pm
workbench-nitro-workflow Ready Ready Preview, Comment Jun 16, 2026 8:49pm
workbench-nuxt-workflow Ready Ready Preview, Comment Jun 16, 2026 8:49pm
workbench-sveltekit-workflow Ready Ready Preview, Comment Jun 16, 2026 8:49pm
workbench-tanstack-start-workflow Ready Ready Preview, Comment Jun 16, 2026 8:49pm
workbench-vite-workflow Ready Ready Preview, Comment Jun 16, 2026 8:49pm
workflow-docs Ready Ready Preview, Comment, Open in v0 Jun 16, 2026 8:49pm
workflow-swc-playground Ready Ready Preview, Comment Jun 16, 2026 8:49pm
workflow-tarballs Ready Ready Preview, Comment Jun 16, 2026 8:49pm
workflow-web Ready Ready Preview, Comment Jun 16, 2026 8:49pm

@github-actions

github-actions Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 0.041s (-2.6%) 1.006s (~) 0.965s 10 1.00x
💻 Local Express 0.049s (+0.8%) 1.006s (~) 0.958s 10 1.20x
🐘 Postgres Express 0.056s (-8.0% 🟢) 1.012s (~) 0.956s 10 1.38x
💻 Local Next.js (Turbopack) 0.062s (+1.3%) 1.007s (~) 0.945s 10 1.51x
🐘 Postgres Nitro 0.063s (-3.4%) 1.012s (~) 0.949s 10 1.53x
🐘 Postgres Next.js (Turbopack) 0.068s (-6.7% 🟢) 1.011s (~) 0.943s 10 1.66x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 0.275s (-75.2% 🟢) 2.016s (-43.7% 🟢) 1.740s 10 1.00x
▲ Vercel Next.js (Turbopack) 0.313s (-84.2% 🟢) 2.172s (-47.1% 🟢) 1.859s 10 1.14x
▲ Vercel Nitro 0.361s (-39.8% 🟢) 2.239s (-15.8% 🟢) 1.878s 10 1.31x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 1 step

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 1.093s (~) 2.007s (~) 0.913s 10 1.00x
💻 Local Express 1.104s (~) 2.006s (~) 0.903s 10 1.01x
🐘 Postgres Express 1.109s (-0.6%) 2.010s (~) 0.901s 10 1.01x
🐘 Postgres Nitro 1.114s (~) 2.010s (~) 0.896s 10 1.02x
💻 Local Next.js (Turbopack) 1.140s (~) 2.006s (~) 0.866s 10 1.04x
🐘 Postgres Next.js (Turbopack) 1.140s (-1.5%) 2.010s (~) 0.869s 10 1.04x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 1.739s (-38.4% 🟢) 3.397s (-25.3% 🟢) 1.658s 10 1.00x
▲ Vercel Express 1.802s (-32.8% 🟢) 3.511s (-29.8% 🟢) 1.710s 10 1.04x
▲ Vercel Next.js (Turbopack) 1.812s (-32.4% 🟢) 3.336s (-26.3% 🟢) 1.524s 10 1.04x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 10 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 10.496s (~) 11.021s (~) 0.525s 3 1.00x
💻 Local Express 10.549s (~) 11.024s (~) 0.475s 3 1.01x
🐘 Postgres Express 10.562s (~) 11.019s (~) 0.457s 3 1.01x
🐘 Postgres Nitro 10.583s (~) 11.019s (~) 0.436s 3 1.01x
💻 Local Next.js (Turbopack) 10.774s (~) 11.021s (~) 0.247s 3 1.03x
🐘 Postgres Next.js (Turbopack) 10.925s (+0.7%) 11.351s (+3.0%) 0.426s 3 1.04x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 14.252s (-21.5% 🟢) 15.475s (-22.1% 🟢) 1.224s 2 1.00x
▲ Vercel Next.js (Turbopack) 14.766s (-31.0% 🟢) 16.135s (-30.6% 🟢) 1.369s 2 1.04x
▲ Vercel Express 14.778s (-9.1% 🟢) 16.581s (-11.0% 🟢) 1.803s 2 1.04x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

workflow with 25 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 13.716s (-0.9%) 14.028s (~) 0.312s 5 1.00x
💻 Local Express 13.805s (~) 14.027s (~) 0.221s 5 1.01x
🐘 Postgres Express 13.818s (~) 14.018s (~) 0.200s 5 1.01x
🐘 Postgres Nitro 13.825s (~) 14.019s (~) 0.193s 5 1.01x
💻 Local Next.js (Turbopack) 14.348s (~) 15.028s (~) 0.680s 4 1.05x
🐘 Postgres Next.js (Turbopack) 14.545s (~) 15.018s (~) 0.473s 4 1.06x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 22.393s (-35.8% 🟢) 24.116s (-34.7% 🟢) 1.723s 3 1.00x
▲ Vercel Nitro 22.610s (-31.4% 🟢) 23.920s (-31.4% 🟢) 1.311s 3 1.01x
▲ Vercel Next.js (Turbopack) 24.095s (-21.7% 🟢) 25.850s (-21.3% 🟢) 1.754s 3 1.08x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 12.394s (-1.4%) 13.025s (~) 0.631s 7 1.00x
💻 Local Express 12.577s (~) 13.027s (~) 0.450s 7 1.01x
🐘 Postgres Express 12.645s (~) 13.017s (~) 0.372s 7 1.02x
🐘 Postgres Nitro 12.738s (~) 13.164s (+1.1%) 0.427s 7 1.03x
💻 Local Next.js (Turbopack) 13.651s (~) 14.027s (~) 0.376s 7 1.10x
🐘 Postgres Next.js (Turbopack) 14.109s (+1.0%) 14.450s (+2.0%) 0.341s 7 1.14x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 32.925s (-5.2% 🟢) 35.119s (-5.3% 🟢) 2.194s 3 1.00x
▲ Vercel Next.js (Turbopack) 33.872s (-3.5%) 36.036s (-3.9%) 2.164s 3 1.03x
▲ Vercel Nitro 34.219s (-9.7% 🟢) 36.587s (-7.4% 🟢) 2.368s 3 1.04x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Promise.all with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 1.206s (+1.2%) 2.006s (~) 0.800s 15 1.00x
🐘 Postgres Express 1.206s (~) 2.008s (~) 0.802s 15 1.00x
🐘 Postgres Nitro 1.216s (~) 2.009s (~) 0.793s 15 1.01x
💻 Local Nitro 1.219s (+4.8%) 2.006s (~) 0.787s 15 1.01x
🐘 Postgres Next.js (Turbopack) 1.267s (-6.6% 🟢) 2.007s (-3.3%) 0.740s 15 1.05x
💻 Local Next.js (Turbopack) 1.391s (+4.6%) 2.006s (~) 0.616s 15 1.15x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 2.461s (-3.5%) 3.921s (-3.7%) 1.460s 8 1.00x
▲ Vercel Nitro 3.056s (-1.1%) 4.517s (-1.5%) 1.461s 7 1.24x
▲ Vercel Next.js (Turbopack) 3.261s (+38.9% 🔺) 4.614s (+14.9% 🔺) 1.353s 7 1.32x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.382s (+1.8%) 2.392s (-4.6%) 1.010s 13 1.00x
🐘 Postgres Nitro 1.519s (+13.3% 🔺) 2.736s (+9.1% 🔺) 1.217s 11 1.10x
🐘 Postgres Next.js (Turbopack) 1.578s (-3.7%) 2.222s (-11.4% 🟢) 0.644s 14 1.14x
💻 Local Nitro 1.710s (+4.9%) 2.005s (~) 0.296s 15 1.24x
💻 Local Next.js (Turbopack) 1.839s (+15.5% 🔺) 2.074s (+3.4%) 0.234s 15 1.33x
💻 Local Express 1.990s (+5.0% 🔺) 2.392s (+11.2% 🔺) 0.402s 13 1.44x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 3.233s (-21.4% 🟢) 4.926s (-18.7% 🟢) 1.693s 7 1.00x
▲ Vercel Next.js (Turbopack) 3.582s (+2.2%) 5.105s (-2.8%) 1.523s 6 1.11x
▲ Vercel Nitro 3.585s (-4.9%) 5.023s (-7.0% 🟢) 1.438s 6 1.11x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Promise.all with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.597s (-1.7%) 4.139s (~) 2.542s 8 1.00x
🐘 Postgres Express 1.639s (+1.0%) 3.760s (-3.2%) 2.121s 8 1.03x
🐘 Postgres Next.js (Turbopack) 3.052s (-20.3% 🟢) 3.887s (-12.6% 🟢) 0.835s 8 1.91x
💻 Local Nitro 4.705s (+17.0% 🔺) 5.347s (+16.7% 🔺) 0.642s 6 2.95x
💻 Local Next.js (Turbopack) 4.830s (+5.8% 🔺) 5.179s (~) 0.349s 6 3.02x
💻 Local Express 5.659s (+8.1% 🔺) 6.414s (+9.7% 🔺) 0.755s 5 3.54x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 4.220s (+11.4% 🔺) 6.237s (+15.3% 🔺) 2.017s 5 1.00x
▲ Vercel Nitro 4.353s (+2.5%) 6.091s (+6.3% 🔺) 1.738s 6 1.03x
▲ Vercel Express 5.383s (+17.7% 🔺) 7.778s (+18.6% 🔺) 2.396s 4 1.28x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

Promise.race with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.210s (~) 2.007s (~) 0.797s 15 1.00x
🐘 Postgres Nitro 1.217s (~) 2.007s (~) 0.790s 15 1.01x
🐘 Postgres Next.js (Turbopack) 1.291s (~) 2.007s (~) 0.716s 15 1.07x
💻 Local Next.js (Turbopack) 1.387s (+0.6%) 2.073s (+3.4%) 0.686s 15 1.15x
💻 Local Express 1.640s (+1.6%) 2.007s (~) 0.367s 15 1.36x
💻 Local Nitro 1.673s (+10.7% 🔺) 2.074s (+3.3%) 0.401s 15 1.38x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 2.599s (+12.7% 🔺) 4.405s (+11.6% 🔺) 1.806s 7 1.00x
▲ Vercel Nitro 2.793s (-56.9% 🟢) 4.322s (-46.2% 🟢) 1.529s 7 1.07x
▲ Vercel Express 2.909s (+27.9% 🔺) 4.584s (+19.0% 🔺) 1.675s 7 1.12x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

Promise.race with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.349s (-0.6%) 2.393s (-7.7% 🟢) 1.044s 13 1.00x
🐘 Postgres Express 1.376s (-1.4%) 2.151s (-12.9% 🟢) 0.775s 14 1.02x
🐘 Postgres Next.js (Turbopack) 1.490s (-3.0%) 2.150s (-3.3%) 0.660s 14 1.10x
💻 Local Next.js (Turbopack) 2.050s (+6.2% 🔺) 2.734s (+23.0% 🔺) 0.684s 11 1.52x
💻 Local Express 2.122s (-17.6% 🟢) 2.594s (-16.6% 🟢) 0.472s 12 1.57x
💻 Local Nitro 2.161s (+20.7% 🔺) 2.594s (+25.2% 🔺) 0.434s 12 1.60x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.856s (-23.7% 🟢) 4.391s (-19.6% 🟢) 1.535s 7 1.00x
▲ Vercel Express 2.937s (-11.8% 🟢) 4.902s (-8.3% 🟢) 1.965s 7 1.03x
▲ Vercel Next.js (Turbopack) 3.137s (-21.8% 🟢) 5.021s (-12.6% 🟢) 1.884s 7 1.10x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.643s (-6.3% 🟢) 4.298s (+4.0%) 2.655s 7 1.00x
🐘 Postgres Nitro 1.827s (+5.7% 🔺) 4.144s (+6.6% 🔺) 2.317s 8 1.11x
🐘 Postgres Next.js (Turbopack) 2.760s (-28.1% 🟢) 3.457s (-22.2% 🟢) 0.697s 9 1.68x
💻 Local Nitro 5.481s (+15.1% 🔺) 6.016s (+12.5% 🔺) 0.535s 5 3.34x
💻 Local Next.js (Turbopack) 5.711s (+7.9% 🔺) 6.212s (+3.3%) 0.501s 5 3.48x
💻 Local Express 5.956s (-6.1% 🟢) 6.416s (-8.6% 🟢) 0.460s 5 3.62x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 4.203s (-14.7% 🟢) 5.567s (-14.3% 🟢) 1.364s 6 1.00x
▲ Vercel Nitro 4.501s (-31.7% 🟢) 6.228s (-25.4% 🟢) 1.727s 5 1.07x
▲ Vercel Express 4.893s (-6.5% 🟢) 6.663s (-4.4%) 1.769s 5 1.16x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 0.575s (-10.8% 🟢) 1.006s (-3.4%) 0.431s 60 1.00x
🐘 Postgres Express 0.582s (-1.7%) 1.023s (+1.7%) 0.440s 59 1.01x
💻 Local Nitro 0.606s (+3.9%) 1.005s (~) 0.399s 60 1.05x
💻 Local Express 0.628s (-4.7%) 1.005s (-3.3%) 0.377s 60 1.09x
🐘 Postgres Next.js (Turbopack) 0.832s (-2.1%) 1.023s (~) 0.191s 59 1.45x
💻 Local Next.js (Turbopack) 0.867s (-2.0%) 1.021s (-3.4%) 0.154s 59 1.51x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 4.722s (-44.2% 🟢) 6.123s (-39.4% 🟢) 1.401s 10 1.00x
▲ Vercel Next.js (Turbopack) 4.787s (-35.5% 🟢) 6.242s (-33.2% 🟢) 1.456s 10 1.01x
▲ Vercel Express 4.906s (-25.4% 🟢) 6.400s (-25.8% 🟢) 1.494s 10 1.04x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.396s (-3.4%) 2.029s (+1.1%) 0.633s 45 1.00x
🐘 Postgres Express 1.397s (-2.7%) 2.029s (~) 0.632s 45 1.00x
💻 Local Nitro 1.497s (+1.3%) 2.006s (~) 0.509s 45 1.07x
💻 Local Express 1.670s (+5.4% 🔺) 2.053s (+2.3%) 0.382s 44 1.20x
🐘 Postgres Next.js (Turbopack) 1.940s (-4.8%) 2.150s (-16.1% 🟢) 0.211s 42 1.39x
💻 Local Next.js (Turbopack) 2.089s (-1.8%) 3.007s (~) 0.918s 30 1.50x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 10.105s (-49.8% 🟢) 11.857s (-45.8% 🟢) 1.752s 8 1.00x
▲ Vercel Nitro 10.392s (-44.7% 🟢) 11.969s (-42.3% 🟢) 1.577s 8 1.03x
▲ Vercel Next.js (Turbopack) 11.406s (-42.7% 🟢) 13.008s (-41.9% 🟢) 1.603s 7 1.13x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 2.725s (-2.0%) 3.111s (-0.8%) 0.386s 39 1.00x
🐘 Postgres Nitro 2.973s (+5.4% 🔺) 3.247s (+3.5%) 0.274s 38 1.09x
💻 Local Nitro 3.307s (+4.4%) 4.043s (+3.3%) 0.736s 30 1.21x
💻 Local Express 3.367s (+0.7%) 4.010s (~) 0.643s 30 1.24x
🐘 Postgres Next.js (Turbopack) 3.855s (-4.2%) 4.148s (-5.8% 🟢) 0.293s 29 1.41x
💻 Local Next.js (Turbopack) 4.339s (-4.4%) 5.010s (-0.8%) 0.671s 24 1.59x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 20.735s (-42.8% 🟢) 22.624s (-40.7% 🟢) 1.889s 6 1.00x
▲ Vercel Express 21.635s (-44.1% 🟢) 24.093s (-40.4% 🟢) 2.458s 5 1.04x
▲ Vercel Next.js (Turbopack) 24.320s (-45.8% 🟢) 26.218s (-43.9% 🟢) 1.898s 5 1.17x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.253s (-3.0%) 1.006s (~) 0.753s 60 1.00x
🐘 Postgres Nitro 0.264s (+1.1%) 1.007s (~) 0.743s 60 1.04x
🐘 Postgres Next.js (Turbopack) 0.282s (-8.4% 🟢) 1.006s (~) 0.724s 60 1.11x
💻 Local Nitro 0.396s (-8.3% 🟢) 1.005s (~) 0.609s 60 1.56x
💻 Local Express 0.435s (+1.7%) 1.005s (~) 0.570s 60 1.72x
💻 Local Next.js (Turbopack) 0.574s (-11.2% 🟢) 1.005s (-5.1% 🟢) 0.431s 60 2.27x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 1.359s (-62.0% 🟢) 2.798s (-50.0% 🟢) 1.439s 22 1.00x
▲ Vercel Nitro 1.410s (-51.1% 🟢) 2.859s (-43.2% 🟢) 1.448s 21 1.04x
▲ Vercel Next.js (Turbopack) 1.534s (-50.1% 🟢) 2.954s (-41.1% 🟢) 1.420s 21 1.13x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.326s (-20.6% 🟢) 1.029s (+2.3%) 0.703s 88 1.00x
🐘 Postgres Nitro 0.399s (-12.7% 🟢) 1.054s (-2.2%) 0.655s 86 1.22x
🐘 Postgres Next.js (Turbopack) 0.499s (-26.3% 🟢) 1.117s (-12.4% 🟢) 0.617s 81 1.53x
💻 Local Nitro 2.081s (+3.5%) 2.657s (+8.9% 🔺) 0.576s 34 6.38x
💻 Local Express 2.177s (-0.6%) 2.715s (-4.8%) 0.537s 34 6.67x
💻 Local Next.js (Turbopack) 2.323s (-9.2% 🟢) 3.078s (-5.6% 🟢) 0.755s 30 7.12x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 2.250s (-56.3% 🟢) 3.851s (-46.7% 🟢) 1.601s 24 1.00x
▲ Vercel Nitro 2.327s (-33.3% 🟢) 3.846s (-25.6% 🟢) 1.519s 24 1.03x
▲ Vercel Next.js (Turbopack) 2.684s (-29.4% 🟢) 4.267s (-25.6% 🟢) 1.583s 22 1.19x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.575s (-32.2% 🟢) 1.244s (-17.5% 🟢) 0.669s 97 1.00x
🐘 Postgres Nitro 0.614s (-27.2% 🟢) 1.271s (-13.6% 🟢) 0.658s 95 1.07x
🐘 Postgres Next.js (Turbopack) 2.215s (-28.3% 🟢) 3.277s (-14.4% 🟢) 1.063s 38 3.85x
💻 Local Nitro 8.972s (+2.8%) 9.490s (+2.5%) 0.517s 13 15.61x
💻 Local Express 9.521s (-3.5%) 10.032s (-2.4%) 0.511s 12 16.56x
💻 Local Next.js (Turbopack) 10.522s (~) 11.576s (+1.6%) 1.054s 11 18.30x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 3.782s (-44.0% 🟢) 5.802s (-32.8% 🟢) 2.020s 21 1.00x
▲ Vercel Next.js (Turbopack) 4.411s (-38.7% 🟢) 6.213s (-30.7% 🟢) 1.802s 20 1.17x
▲ Vercel Express 4.774s (-30.1% 🟢) 6.866s (-20.4% 🟢) 2.092s 18 1.26x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Stream Benchmarks (includes TTFB metrics)
workflow with stream

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.161s (-1.0%) 2.000s (~) 0.001s (~) 2.010s (~) 0.849s 10 1.00x
💻 Local Nitro 1.162s (~) 2.004s (~) 0.010s (~) 2.017s (~) 0.855s 10 1.00x
💻 Local Express 1.168s (~) 2.005s (~) 0.013s (+3.3%) 2.020s (~) 0.852s 10 1.01x
🐘 Postgres Nitro 1.182s (-1.3%) 2.000s (~) 0.001s (+16.7% 🔺) 2.010s (~) 0.828s 10 1.02x
💻 Local Next.js (Turbopack) 1.208s (-3.3%) 2.004s (~) 0.012s (+11.2% 🔺) 2.019s (~) 0.811s 10 1.04x
🐘 Postgres Next.js (Turbopack) 1.227s (-1.3%) 2.001s (~) 0.001s (-16.7% 🟢) 2.011s (~) 0.784s 10 1.06x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.415s (-5.2% 🟢) 3.354s (-9.7% 🟢) 1.193s (+61.2% 🔺) 4.950s (~) 2.535s 10 1.00x
▲ Vercel Express 2.631s (+11.4% 🔺) 3.684s (+4.4%) 1.030s (+46.5% 🔺) 5.103s (+6.5% 🔺) 2.471s 10 1.09x
▲ Vercel Next.js (Turbopack) 2.761s (+2.9%) 3.634s (-3.0%) 1.459s (+74.3% 🔺) 5.650s (+11.1% 🔺) 2.888s 10 1.14x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

stream pipeline with 5 transform steps (1MB)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 1.570s (~) 2.011s (~) 0.013s (+15.2% 🔺) 2.026s (~) 0.456s 30 1.00x
🐘 Postgres Express 1.586s (-1.2%) 2.006s (~) 0.004s (-10.8% 🟢) 2.023s (~) 0.437s 30 1.01x
💻 Local Express 1.598s (-0.7%) 2.010s (~) 0.013s (+8.3% 🔺) 2.026s (~) 0.428s 30 1.02x
🐘 Postgres Nitro 1.599s (-2.4%) 2.004s (~) 0.005s (-4.5%) 2.027s (~) 0.428s 30 1.02x
💻 Local Next.js (Turbopack) 1.721s (-1.4%) 2.009s (~) 0.013s (+1.8%) 2.026s (~) 0.305s 30 1.10x
🐘 Postgres Next.js (Turbopack) 1.777s (-0.8%) 2.011s (~) 0.005s (-2.4%) 2.027s (~) 0.250s 30 1.13x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 6.208s (-7.8% 🟢) 7.181s (-10.7% 🟢) 0.553s (+151.7% 🔺) 8.181s (-7.1% 🟢) 1.973s 8 1.00x
▲ Vercel Next.js (Turbopack) 6.567s (-4.3%) 7.796s (-9.1% 🟢) 0.245s (-21.6% 🟢) 8.496s (-9.4% 🟢) 1.929s 8 1.06x
▲ Vercel Express 6.835s (-0.9%) 8.186s (-2.0%) 0.717s (+108.2% 🔺) 9.292s (+0.7%) 2.457s 7 1.10x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

10 parallel streams (1MB each)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.782s (-1.9%) 1.017s (-3.0%) 0.000s (+Infinity% 🔺) 1.063s (~) 0.281s 57 1.00x
🐘 Postgres Nitro 0.837s (+3.0%) 1.084s (+1.9%) 0.000s (+103.6% 🔺) 1.098s (+1.7%) 0.261s 55 1.07x
🐘 Postgres Next.js (Turbopack) 1.019s (-0.8%) 1.500s (-7.5% 🟢) 0.000s (-7.5% 🟢) 1.508s (-7.5% 🟢) 0.489s 40 1.30x
💻 Local Express 1.403s (-1.1%) 2.013s (~) 0.000s (-14.3% 🟢) 2.015s (~) 0.612s 30 1.80x
💻 Local Nitro 1.449s (+7.2% 🔺) 2.014s (~) 0.000s (+85.7% 🔺) 2.016s (~) 0.567s 30 1.85x
💻 Local Next.js (Turbopack) 1.481s (-2.1%) 2.013s (~) 0.001s (+81.8% 🔺) 2.016s (~) 0.535s 30 1.89x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 3.015s (-26.7% 🟢) 4.013s (-30.1% 🟢) 0.000s (+Infinity% 🔺) 4.446s (-28.8% 🟢) 1.431s 14 1.00x
▲ Vercel Express 3.022s (-18.1% 🟢) 4.316s (-11.9% 🟢) 0.000s (NaN%) 4.738s (-14.7% 🟢) 1.716s 13 1.00x
▲ Vercel Next.js (Turbopack) 3.141s (-16.6% 🟢) 4.261s (-20.1% 🟢) 0.000s (-100.0% 🟢) 4.733s (-19.5% 🟢) 1.592s 13 1.04x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

fan-out fan-in 10 streams (1MB each)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.575s (-4.7%) 2.179s (~) 0.000s (+Infinity% 🔺) 2.187s (~) 0.612s 28 1.00x
🐘 Postgres Nitro 1.641s (-3.2%) 2.220s (+2.0%) 0.000s (+3.7%) 2.232s (+1.7%) 0.592s 27 1.04x
🐘 Postgres Next.js (Turbopack) 2.203s (-2.3%) 2.860s (+4.8%) 0.000s (NaN%) 2.867s (+4.7%) 0.664s 21 1.40x
💻 Local Next.js (Turbopack) 2.657s (-6.3% 🟢) 3.236s (-5.2% 🟢) 0.001s (+15.8% 🔺) 3.239s (-5.3% 🟢) 0.582s 19 1.69x
💻 Local Nitro 3.133s (+1.7%) 3.838s (-1.6%) 0.001s (+85.7% 🔺) 3.843s (-1.5%) 0.711s 16 1.99x
💻 Local Express 3.178s (-4.2%) 3.966s (-1.5%) 0.001s (-25.0% 🟢) 3.968s (-1.6%) 0.790s 16 2.02x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 4.452s (-9.5% 🟢) 5.506s (-10.7% 🟢) 0.000s (+Infinity% 🔺) 6.015s (-11.5% 🟢) 1.562s 10 1.00x
▲ Vercel Next.js (Turbopack) 4.506s (-36.2% 🟢) 5.596s (-32.0% 🟢) 0.000s (-100.0% 🟢) 6.012s (-31.4% 🟢) 1.505s 10 1.01x
▲ Vercel Express 4.513s (-25.6% 🟢) 5.770s (-24.0% 🟢) 0.000s (-100.0% 🟢) 6.258s (-23.6% 🟢) 1.745s 10 1.01x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World 🥇 Fastest Framework Wins
💻 Local Nitro 16/21
🐘 Postgres Express 17/21
▲ Vercel Nitro 10/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework 🥇 Fastest World Wins
Express 🐘 Postgres 15/21
Next.js (Turbopack) 🐘 Postgres 14/21
Nitro 🐘 Postgres 14/21
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Redis + BullMQ: Community world (local development)
  • 🌐 Cloudflare: Community world (local development)
  • 🌐 MySQL: Community world (local development)
  • 🌐 Azure: Community world (local development)
  • 🌐 NATS JetStream: Community world (local development)
  • 🌐 Upstash: Community world (local development)

📋 View full workflow run

@github-actions

github-actions Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

Passed Failed Skipped Total
✅ ▲ Vercel Production 1442 0 219 1661
✅ 💻 Local Development 1895 0 219 2114
✅ 📦 Local Production 1895 0 219 2114
✅ 🐘 Local Postgres 1881 0 233 2114
✅ 🪟 Windows 151 0 0 151
✅ 📋 Other 879 0 178 1057
Total 8143 0 1068 9211

Details by Category

✅ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 125 0 26
✅ example 125 0 26
✅ express 125 0 26
✅ fastify 125 0 26
✅ hono 125 0 26
✅ nextjs-turbopack 149 0 2
✅ nextjs-webpack 149 0 2
✅ nitro 125 0 26
✅ nuxt 125 0 26
✅ sveltekit 144 0 7
✅ vite 125 0 26
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 126 0 25
✅ express-stable 126 0 25
✅ fastify-stable 126 0 25
✅ hono-stable 126 0 25
✅ nextjs-turbopack-canary 132 0 19
✅ nextjs-turbopack-stable-lazy-discovery-disabled 151 0 0
✅ nextjs-turbopack-stable-lazy-discovery-enabled 151 0 0
✅ nextjs-webpack-canary 132 0 19
✅ nextjs-webpack-stable-lazy-discovery-disabled 151 0 0
✅ nextjs-webpack-stable-lazy-discovery-enabled 151 0 0
✅ nitro-stable 126 0 25
✅ nuxt-stable 126 0 25
✅ sveltekit-stable 145 0 6
✅ vite-stable 126 0 25
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 126 0 25
✅ express-stable 126 0 25
✅ fastify-stable 126 0 25
✅ hono-stable 126 0 25
✅ nextjs-turbopack-canary 132 0 19
✅ nextjs-turbopack-stable-lazy-discovery-disabled 151 0 0
✅ nextjs-turbopack-stable-lazy-discovery-enabled 151 0 0
✅ nextjs-webpack-canary 132 0 19
✅ nextjs-webpack-stable-lazy-discovery-disabled 151 0 0
✅ nextjs-webpack-stable-lazy-discovery-enabled 151 0 0
✅ nitro-stable 126 0 25
✅ nuxt-stable 126 0 25
✅ sveltekit-stable 145 0 6
✅ vite-stable 126 0 25
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 125 0 26
✅ express-stable 125 0 26
✅ fastify-stable 125 0 26
✅ hono-stable 125 0 26
✅ nextjs-turbopack-canary 131 0 20
✅ nextjs-turbopack-stable-lazy-discovery-disabled 150 0 1
✅ nextjs-turbopack-stable-lazy-discovery-enabled 150 0 1
✅ nextjs-webpack-canary 131 0 20
✅ nextjs-webpack-stable-lazy-discovery-disabled 150 0 1
✅ nextjs-webpack-stable-lazy-discovery-enabled 150 0 1
✅ nitro-stable 125 0 26
✅ nuxt-stable 125 0 26
✅ sveltekit-stable 144 0 7
✅ vite-stable 125 0 26
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 151 0 0
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 126 0 25
✅ e2e-local-dev-tanstack-start- 126 0 25
✅ e2e-local-postgres-nest-stable 125 0 26
✅ e2e-local-postgres-tanstack-start- 125 0 26
✅ e2e-local-prod-nest-stable 126 0 25
✅ e2e-local-prod-tanstack-start- 126 0 25
✅ e2e-vercel-prod-tanstack-start 125 0 26

📋 View full workflow run

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new composable gzip serialization format prefix in @workflow/core to gzip-compress serialized payload refs before encryption, gated behind specVersion 5 to keep compatibility explicit. It also bumps @workflow/world’s SPEC_VERSION_CURRENT to 5 and wires compression gating through runtime write paths (start args, step I/O, errors, hooks), including cross-deployment capability probing.

Changes:

  • Add a gzip compression/decompression layer to the serialization pipeline and integrate it across workflow/step/client serialization + hydration paths.
  • Bump spec version to 5 (SPEC_VERSION_SUPPORTS_COMPRESSION) and export it from @workflow/world, with unit tests validating the compatibility contract.
  • Add capability-table gating for cross-deployment payload writes, plus benchmarks and test coverage for compression behavior.

Reviewed changes

Copilot reviewed 24 out of 24 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/world/src/spec-version.ts Adds spec v5 constant for compression and bumps SPEC_VERSION_CURRENT to 5.
packages/world/src/spec-version.test.ts Tests new spec constants and requiresNewerWorld contract (incl. v4-reader simulation).
packages/world/src/index.ts Re-exports SPEC_VERSION_SUPPORTS_COMPRESSION.
packages/core/src/workflow.ts Gates workflow return-value payload compression on run specVersion >= 5.
packages/core/src/serialization/types.ts Adds SerializationFormat.GZIP format prefix constant.
packages/core/src/serialization/step.ts Applies compress-before-encrypt on step serialization; decompress-after-decrypt on reads.
packages/core/src/serialization/index.ts Re-exports compression utilities from serialization module.
packages/core/src/serialization/compression.ts Implements composable gzip wrapper + conditional compression thresholds/kill switch.
packages/core/src/serialization/compression.test.ts Adds tests for compression layer behavior, nesting with encryption, hydration, and capability gating.
packages/core/src/serialization/codec.ts Adds compression?: boolean option to write-side serialization options.
packages/core/src/serialization/client.ts Applies compress-before-encrypt and decompress-after-decrypt for client-mode serializer.
packages/core/src/serialization.ts Threads compression flag through dehydrate APIs; adds read-side decompression where appropriate.
packages/core/src/serialization-format.ts Adds browser-safe gzip detection and sync/async hydration support for gzip-prefixed values.
packages/core/src/runtime/suspension-handler.ts Gates step argument/metadata compression on run specVersion >= 5.
packages/core/src/runtime/step-handler.ts Gates step error/output compression on step entity specVersion >= 5.
packages/core/src/runtime/step-handler.test.ts Updates expectations for new dehydrateStepError call signature/args.
packages/core/src/runtime/step-executor.ts Threads run specVersion into step execution and gates compression accordingly.
packages/core/src/runtime/start.ts Adds cross-deployment capability probing for gzip support; compresses workflow args when safe.
packages/core/src/runtime/resume-hook.ts Gates hook payload compression on run specVersion and deployment capabilities.
packages/core/src/runtime.ts Gates run error compression where run specVersion is available; threads run specVersion into step execution.
packages/core/src/capabilities.ts Adds gzip to FORMAT_VERSION_TABLE with min core version gating.
packages/core/scripts/benchmark-compression.mjs Adds deterministic benchmark script to measure compression savings on representative workloads.
.changeset/gzip-ref-compression-world.md Changeset for @workflow/world spec version bump to 5.
.changeset/gzip-ref-compression-core.md Changeset for @workflow/core gzip-compressed serialized payload refs feature.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +108 to +118
/**
* Whether the current runtime can compress/decompress. CompressionStream
* is a web standard available in Node.js 18+, browsers, and edge
* runtimes; this guard exists for exotic runtimes only.
*/
function isCompressionAvailable(): boolean {
return (
typeof CompressionStream === 'function' &&
typeof DecompressionStream === 'function'
);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2bc368c. Split the availability checks: decompress()'s gzip branch now gates on isGzipDecompressAvailable() (only DecompressionStream), while compress() uses isGzipCompressAvailable() (CompressionStream). Reads no longer require compression support.

if (!(data instanceof Uint8Array)) return data;
if (peekFormatPrefix(data) !== SerializationFormat.GZIP) return data;

if (!isCompressionAvailable()) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 2bc368c — the gzip read path now checks DecompressionStream only (see the sibling thread). Thanks!

Comment on lines +163 to +172
/**
* Synchronously gunzip a payload when running on Node.js.
*
* This module is browser-safe, so `node:zlib` is resolved dynamically via
* `process.getBuiltinModule` (no static Node dependency, invisible to
* browser bundlers). Returns `undefined` when sync decompression isn't
* available in the current runtime — callers fall back to leaving the
* data un-hydrated (the async `hydrateDataWithKey` path handles
* decompression in browsers via `DecompressionStream`).
*/

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarified in 2bc368c. Renamed to decompressSyncIfAvailable with a doc that states it's best-effort, not "always on Node": it inflates only when process.getBuiltinModule('node:zlib') resolves and exposes the needed fn (gunzipSync; zstdDecompressSync on Node ≥ 22.15), and returns undefined otherwise (browser/edge, Node without getBuiltinModule, or zstd on Node < 22.15) — callers then fall back to the async path.

@TooTallNate TooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve — the right design at the right seam; three concrete convergence asks with the snapshot-runtime compression work

I built the branch, ran all the suites locally (20 compression tests, 55 world, 1209 core — all green), and reproduced the benchmark table exactly (deterministic/seeded as claimed, including the 402.1 KB → 108.4 KB simulated agent run). The design fundamentals are correct and well-argued:

  • SDK-side, pre-encryption placement is the only placement that works — encr(gzip(devl)), verified in step.ts/client.ts where compress() runs between prefixing and encryptData(). This matches the layering the snapshot-runtime compression work (#1300) arrived at independently, for the same reason: ciphertext doesn't compress.
  • The conditional logic is production-minded: 1 KB floor, ≥5% savings requirement (so already-compressed binary never pays a permanent decompression tax), env kill switch that only affects writes. The benchmark's "random binary still wins 24.7%" result — gzip clawing back devalue's base64 4/3× penalty almost exactly — is a great catch and a real argument for compressing even apparently-incompressible payloads.
  • pipeThroughTransform gets the classic CompressionStream deadlock right (write-before-read with a handled mirrored rejection) — large payloads would wedge a naive await write(); read() sequence.
  • specVersion 5 as the compatibility contract is the honest mechanism: old SDKs get a typed RunNotSupportedError up front instead of per-payload format errors. I verified the gating at every call site in the table (start's probe-AND-spec gate, runSpecVersion threading through StepExecutorParams, resume-hook's target-spec-AND-capability gate matching the encr precedent). Community worlds are unaffected since start() derives the run's spec from world.specVersion — a spec-4 world keeps creating spec-4 runs with no compression. The local-dev writer-stamped V1-step-handler edge is honestly documented and matches the existing encr/framing behavior.
  • The TODO(release) cutoff (5.0.0-beta.16) is correct as of today (beta.15 shipped yesterday) — and the marker pattern has already proven its worth twice on the framing PR.

Convergence with #1300 (snapshot-runtime compression) — three asks

#1300 carries its own packages/core/src/serialization/compression.ts with a different shape: sync node:zlib codecs, zstd-preferred with gzip fallback (feature-detected at zlib.zstdCompressSync, Node ≥ 22.15), prefix-dispatched reads. On its 8 MB QuickJS heap snapshots, zstd-3 beats gzip-6 on ratio (4.29× vs 4.02×) and compress speed (18 ms vs 127 ms — 7×). These two modules will collide at the same path, and the second to land has to reconcile. Asks:

  1. Reserve the zstd prefix now. Add ZSTD: 'zstd' to SerializationFormat (both serialization/types.ts and the browser-safe serialization-format.ts copy) even with no write path using it here. Cost: two lines. Benefit: the format namespace can't drift between the two branches, and a reader hitting a zstd payload from a snapshot-runtime writer gets a precise "zstd payload requires …" error instead of generic Unsupported serialization format.

  2. Fix the semantic conflict in the GZIP constant docs before they fork. This PR documents gzip as "inner payload has its own format prefix"; #1300 documents it as "inner is raw bytes". Same prefix, contradictory contracts. Both are locally true — which is exactly the resolution: compression prefixes mark the codec only; inner structure is caller-defined (refs recurse into hydrateData, snapshots hand raw bytes to the snapshot loader). One sentence of wording alignment now saves a confusing archaeology session later.

  3. Plan the module merge as one file, two APIs. The natural shape: this PR's async conditional gzip API for browser-reachable payload refs, plus #1300's sync zstd-preferred API for Node-only consumers — and the browser-safety trick is already in this PR: gunzipSyncIfAvailable resolves node:zlib via process.getBuiltinModule, which is precisely how the merged module can host sync zstd codecs without growing a static Node dependency.

On gzip-only for payload refs: I think it's the right call, but the stated reason should be sharpened. The out-of-scope note says zstd is "not a web standard" — the operative constraint is specifically the browser read path: the web o11y UI decompresses post-decrypt via DecompressionStream, which standardizes only gzip/deflate/deflate-raw. Snapshots never reach a browser, which is why #1300 can prefer zstd unconditionally and this path can't. Worth a sentence in the module docs, because it's the criterion a future "add zsd1 here?" decision turns on: when browsers ship zstd in DecompressionStream (or a wasm decoder becomes acceptable in the web bundle), the prefix system makes it a drop-in for refs too. Node-side availability is already a non-issue for writes.

Smaller notes

  • pipeThroughTransform/gunzipAsync are duplicated between compression.ts and serialization-format.ts. If deliberate (keeping serialization-format.ts dependency-free for browser bundles), a cross-reference comment would prevent drift; the module-merge in ask 3 is the natural moment to unify.
  • On the world-local open question: I'd keep it uniform (as this PR does). The "local files were greppable" ship sailed at spec v2 base64-encoding; hydrateData already gives the CLI sync decompression, so a --raw-style inspection view is cheap if local-debugging demand materializes. Option B (CBOR for world-local) is attractive but orthogonal — neither should block this.
  • The rare uncompressed error-write paths (max-deliveries, replay-budget) are fine as-is — error payloads under 1 KB would pass through anyway.

CI

Still in progress as I write this. The nextjs-webpack dev lane and express prod lane failures match this cycle's known baseline flakes; the nextjs-turbopack canary failure has no logs yet — worth a look once the run completes, since canary lanes exercise the freshest Next integration against these serialization changes. Flagging for a re-check, not blocking on it given the full e2e suite passed locally per the validation notes (and my local unit runs corroborate).

Approving — please land the zstd prefix reservation and the constant-docs alignment with this PR; the module merge can be whoever-lands-second's job as long as both sides know the plan.

@TooTallNate

Copy link
Copy Markdown
Member

Amending one conclusion from my review after discussing with Nathan: I framed the browser read path as the constraint keeping this gzip-only — that's a soft constraint, not a hard one. The web o11y UI only ever reads payloads, so the browser story needs only a decoder, and a wasm zstd decoder is a solved problem — Nathan has a working implementation with exactly the right shape: @tootallnate/zstd-wasm — a streaming ZstdDecompressStream that mirrors DecompressionStream (drop-in for this PR's gunzipAsync pattern), decoder-only at ~133 KB of wasm (zstd 1.5.7 single-file decoder via wasi-sdk), caller-supplied WASM sourcing (no fetch/fs assumptions, so the web bundler can lazy-load it as an asset), and compiled-module caching.

That changes the zstd calculus from "blocked on web standards" to "a follow-up with known cost":

  1. This PR ships as-is with gzip — plus the zstd prefix reservation from my review, which becomes load-bearing rather than just defensive.
  2. Follow-up, read-side first: zstd decode support everywhere readers live — node:zlib (zstdDecompressSync, ≥ 22.15) behind the same process.getBuiltinModule pattern gunzipSyncIfAvailable already uses for the sync/CLI path, and ZstdDecompressStream lazily loaded in the web UI's hydrateDataWithKey. Readers dispatching on the prefix means this is purely additive.
  3. Follow-up, write-side second: flip writers to zstd-preferred behind a zstd entry in FORMAT_VERSION_TABLE keyed on the SDK version that shipped step 2 — the exact machinery this PR already establishes for gzip. Writers fall back to gzip when the target run's deployment predates zstd read support, and the per-payload prefix means mixed-codec event logs are a non-event.

Why it's worth the follow-up: compression runs at every step boundary, so write-side CPU is a per-step tax — the snapshot-runtime benchmarks showed zstd ~7× faster than gzip at compression with a better ratio, and the ratio delta compounds with this PR's motivating workload (the same growing payload re-serialized N times). The wasm asset cost (~133 KB, lazy-loaded only when a zstd payload is actually encountered) is modest for an o11y dashboard.

None of this blocks the current PR — the prefix system is what makes the whole sequence migration-free, which is the strongest argument for the design as submitted. But "zstd: out of scope" should read as "zstd: staged follow-up with a working decoder in hand," and the module-merge with the snapshot-runtime compression work (ask 3 in my review) is the natural vehicle for step 2's Node side.

Split the compression benchmark into reproducible size and CPU scripts
sharing deterministic workloads (lib/workloads.mjs). The CPU benchmark
measures serialize/deserialize overhead per payload, total CPU across
thousands of events, and compares gzip levels/brotli/deflate. Documents
how to run the size, CPU, and end-to-end (bench.bench.ts) benchmarks
against local and Vercel in scripts/README.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Switch the payload compression codec to zstd, which benchmarks 3–7×
faster than gzip at an equal-or-better ratio on representative workloads
(compression runs at every step boundary, so the write CPU is a per-step
tax). zstd uses node:zlib (>= 22.15); gzip via the portable
CompressionStream remains the fallback when zstd is unavailable, and
WORKFLOW_COMPRESSION_CODEC=gzip forces it. Reads dispatch on the format
prefix, so 'zstd' and 'gzip' payloads are both always decodable.

zstd is Node-only (Web CompressionStream has no zstd), so the browser
o11y read path registers a WASM-backed decoder (@tootallnate/zstd-wasm)
via a new registerZstdDecoder hook; node:zlib handles Node-side reads
(runtime replay, CLI, server o11y). A new workflow.serialization.codec
span attribute reports which codec applied. gzip and zstd read support
co-ship, so the existing specVersion-5 capability gate is unchanged.

Verified end-to-end: spec-5 runs store zstd-prefixed payloads on disk
and replay/complete correctly; the WASM decoder round-trips node:zlib
zstd output. Benchmarks updated to compare zstd vs gzip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
data.length < COMPRESSION_MIN_BYTES ||
isCompressionDisabledByEnv()
) {
recordStats(stats, 'none', data.length, data.length);

@vercel vercel Bot Jun 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cross-deployment writes (start.ts, resume-hook.ts) can emit zstd-compressed payloads to a capability-approved target that cannot decode zstd, because the write-side codec is chosen from the writer's own Node runtime while zstd read support (Node 22.15+) is a runtime property not implied by the target's SDK version.

Fix on Vercel

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 2bc368c. Root engines allows Node 18/20, which lack node:zlib zstd, so the SDK version genuinely doesn't imply the reader's runtime can decode zstd. Cross-deployment writes (start({deploymentId}) and resumeHook) now pass a new compressionPortableOnly flag that restricts compress() to gzip (decodable on every supported runtime). Same-deployment writes keep zstd, since there the reader is the same runtime as the writer — a zstd-capable writer implies a zstd-capable reader. Added a unit test for the forced-gzip path.

* origin/main:
  Small detail panel cleanup (#2459)
  Fix lazy Next workflow HMR (#2438)
  Prevent peer dependency-only major bumps (#2437)
  fix(changesets): only major-bump peer dependents when out of range (#2439)
  Version Packages (beta) (#2428)
  otel: explicit traceparent injection + linked-trace mode for bounded per-invocation traces (#2363)
  [next] Clarify `serverExternalPackages` warning (#2417)
  Add .swc gitignore handling to builder (#2427)
  Version Packages (beta) (#2390)
  [ci] Increase dev.test.ts cleanup hook timeout (#2416)
  [world-vercel] Switch event endpoints to v4 wire format (#2055)
  docs: document run idempotency (#2011)
  Render attr_set events and run attributes in observability UI (#2393)
  [ci] Fix backport job model slug (#2403)
  [ci] Comment on PR when backport fails, revert to use opus 4.8 (#2400)
  Update queue client to 0.3.1 (#2399)
  fix(deps): upgrade esbuild to 0.28.1 (GHSA-gv7w-rqvm-qjhr) (#2395)
  test: e2e coverage for run-idempotency conflict-handling strategies (#2387)

# Conflicts:
#	pnpm-lock.yaml

@karthikscale3 karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Browser decompression review notes.


function loadWasmModule(): Promise<WebAssembly.Module> {
if (!modulePromise) {
const url = new URL('@tootallnate/zstd-wasm/zstd.wasm', import.meta.url);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bare package specifier does not look like a bundler asset URL. I ran a Vite production-build probe from packages/web, and Vite left new URL("@tootallnate/zstd-wasm/zstd.wasm", import.meta.url) unchanged; in the browser that resolves relative to the JS chunk, not to the package asset, so the WASM fetch will likely 404 in production. Can we switch this to a bundler-verified asset import, e.g. @tootallnate/zstd-wasm/zstd.wasm?url or equivalent, and add a browser/build smoke test that actually fetches and compiles the emitted WASM?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2bc368c. The bundler couldn't rewrite the bare specifier, so I vendor zstd.wasm into web-shared/dist/lib/ (build-script cp) and reference it relatively: new URL('./zstd.wasm', import.meta.url) — the form Vite/webpack/Turbopack all rewrite. Verified against a Vite production build of packages/web: it now emits build/client/assets/zstd-*.wasm and the decoder chunk references that hashed asset URL. Added a Node test compiling the emitted WASM and round-tripping node:zlib zstd output through it.

const { ensureZstdDecoderRegistered } = await import(
'./zstd-browser-decoder.js'
);
ensureZstdDecoderRegistered();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This registers the browser zstd decoder only inside the decrypt path, but compression can be enabled without encryption. serialize() compresses before optional encryption, and encrypt() returns the compressed data unchanged when no key exists; local worlds also advertise spec v5. In the web app, no-key paths call plain hydrateResourceIO(), whose browser sync path returns compressed Uint8Arrays untouched. That means an unencrypted zstd/gzip payload can render as raw bytes instead of hydrated data. Could we add an async web hydration path for compressed data even when no encryption key is present, or otherwise register/decompress in the normal web hydration path, plus a browser test for unencrypted compressed payloads?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2bc368c. hydrateResourceIOWithKey now takes an optional key and always registers the zstd decoder + decompresses, so it inflates compressed payloads with or without encryption. The web no-key paths that render resolved payloads (use-resource-data, run-detail-view event-data loaders) now route through it. (The trace-viewer/events-list use withData:false/resolveData:'none', so they carry no payload bodies and stay on the sync path.) Added a web-shared test hydrating an unencrypted zstd payload with no key.

@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​@​tootallnate/​zstd-wasm@​0.0.2741006786100

View full report

@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 5f0b845 (AI decision).

This feature builds on main-only infrastructure and a major-version compatibility contract that doesn't exist on stable: stable's SPEC_VERSION_CURRENT is still 3 (no spec-4 attributes, let alone spec-5), and the entire composable packages/core/src/serialization/ mode-serializer layer (client.ts/step.ts/codec.ts/compression.ts, CodecOptions, etc.) that this PR extends is absent on stable. The PR itself frames spec-5 compression as a v5-beta-only cut point where v4 SDKs reject the new runs, so it's a major change intended exclusively for the next major release rather than a self-contained fix backportable to the GA branch.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

5f0b845211152b6f2860c78d0dd4dccc9d4f0d97

@TooTallNate TooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Post-merge review — one shipping-blocking cutoff bug to fix before beta.18; design is otherwise sound

This merged in a much stronger form than what I reviewed (it was gzip-only then; it now ships zstd-preferred with gzip fallback plus a WASM browser decoder — closing exactly the loop from my pre-merge amendment, using the published @tootallnate/zstd-wasm). I re-reviewed the merged commit 5f0b84521 end to end: built core/world/web-shared, ran the suites (1236 core, 36 compression, 2 zstd-decoder, 5 spec-version — all green), and confirmed zstd round-trips on Node 24. The codec layering, prefix-dispatched reads, telemetry, and browser-decode wiring are all correct.

But there is one concrete bug that will cause silent payload corruption once compression activates, and it should be fixed before the next beta publishes.

🛑 The FORMAT_VERSION_TABLE cutoff is one beta too low

capabilities.ts gates both gzip and zstd at minVersion: '5.0.0-beta.16'. But:

  • workflow@5.0.0-beta.16 was tagged June 15 (Version Packages #2390)
  • workflow@5.0.0-beta.17 was tagged June 15 (Version Packages #2428)
  • this PR merged June 16git merge-base --is-ancestor 5f0b84521 workflow@5.0.0-beta.16 → NOT in beta.16; same for beta.17

So neither published beta contains the compression read path. The pending Version Packages PR (#2451) bumps @workflow/core to beta.18 — that's the first version that can decode these payloads.

With the cutoff at beta.16, getRunCapabilities() reports a target running beta.16 or beta.17 as compression-capable. A cross-deployment start() / resumeHook() to such a target (or the resilient-start probe resolving to one) will then write zstd/gzip payloads that the target cannot decode — a ReferenceError-class failure at replay, silent until it bites. This is precisely the hazard the TODO(release) comment on those lines warns about, and the same cutoff-lag bug that hit the byte-stream framing PR twice (it shipped without #1853 in beta.14, then again in beta.15).

Fix: bump both entries to 5.0.0-beta.18. I've prepared that change and will push it to a follow-up branch (see below) — flagging here so it's on record against this PR.

The cutoff being one-too-low is only dangerous once a published SDK actually starts writing compressed payloads, i.e. beta.18. Since beta.18 hasn't shipped, there's no corrupted data in the wild yet — but the fix must land in the same release that first enables compression, not after.

Everything else checks out

  • Codec selection (selectWriteCodec): zstd via process.getBuiltinModule('node:zlib') (≥22.15), gzip fallback via portable CompressionStream, WORKFLOW_COMPRESSION_CODEC override — bundler-safe (no static node:zlib import), which is what keeps the module importable in browser/edge targets where it correctly degrades to gzip/none.
  • Read dispatch is codec-agnostic and complete: Node sync path (serialization-format.ts:205), Node async/replay path (compression.ts decompress), and the browser path (hydration.tsensureZstdDecoderRegistered → WASM decoder, lazy + idempotent). The "gzip and zstd co-ship, so a run that reads one reads both" claim that justifies the single shared minVersion is structurally true — all three reader surfaces gained both codecs in this one PR.
  • @tootallnate/zstd-wasm@0.0.2 is the right pin: the browser only ever decodes, and 0.0.2 is decode-only — so the heavier compression code added in v0.1.0 is correctly not pulled into the web bundle. (Noting for the record since the v0.1.0 publish is what prompted this look: nothing here needs it — the write side uses Node's native zstd, never the WASM module.)
  • Compress-before-encrypt preserved (encr(zstd(devl))), conditional gating intact (1KB floor, ≥5% savings, env kill switch), telemetry sizes measured at the compression boundary (pre-encryption) as documented.
  • world-vercel stamps new runs specVersion: 5 with a clear comment pointing at the server-side spec-5 support (workflow-server#520) — the cross-repo coordination is documented.
  • specVersion-5 contract, requiresNewerWorld reject matrix, and CPU/size benchmark scripts all landed as described.

Bottom line

Strong implementation that correctly absorbed the zstd follow-up. The single must-fix is the beta.16beta.18 cutoff bump, which I'm pushing as a follow-up since this PR is already merged. Once that lands in the beta.18 release, this is good.

@TooTallNate

Copy link
Copy Markdown
Member

Post-merge review — one shipping-blocking cutoff bug to fix before beta.18; design is otherwise sound

This merged in a much stronger form than what I reviewed (it was gzip-only then; it now ships zstd-preferred with gzip fallback plus a WASM browser decoder — closing exactly the loop from my pre-merge amendment, using the published @tootallnate/zstd-wasm). I re-reviewed the merged commit 5f0b84521 end to end: built core/world/web-shared, ran the suites (1236 core, 36 compression, 2 zstd-decoder, 5 spec-version — all green), and confirmed zstd round-trips on Node 24. The codec layering, prefix-dispatched reads, telemetry, and browser-decode wiring are all correct.

But there is one concrete bug that will cause silent payload corruption once compression activates, and it should be fixed before the next beta publishes.

🛑 The FORMAT_VERSION_TABLE cutoff is one beta too low

capabilities.ts gates both gzip and zstd at minVersion: '5.0.0-beta.16'. But:

  • workflow@5.0.0-beta.16 was tagged June 15 (Version Packages #2390)
  • workflow@5.0.0-beta.17 was tagged June 15 (Version Packages #2428)
  • this PR merged June 16git merge-base --is-ancestor 5f0b84521 workflow@5.0.0-beta.16 → NOT in beta.16; same for beta.17

So neither published beta contains the compression read path. The pending Version Packages PR (#2451) bumps @workflow/core to beta.18 — that's the first version that can decode these payloads.

With the cutoff at beta.16, getRunCapabilities() reports a target running beta.16 or beta.17 as compression-capable. A cross-deployment start() / resumeHook() to such a target (or the resilient-start probe resolving to one) will then write zstd/gzip payloads that the target cannot decode — a ReferenceError-class failure at replay, silent until it bites. This is precisely the hazard the TODO(release) comment on those lines warns about, and the same cutoff-lag bug that hit the byte-stream framing PR twice (it shipped without #1853 in beta.14, then again in beta.15).

Fix: bump both entries to 5.0.0-beta.18. I've prepared that change and will push it to a follow-up branch (see below) — flagging here so it's on record against this PR.

The cutoff being one-too-low is only dangerous once a published SDK actually starts writing compressed payloads, i.e. beta.18. Since beta.18 hasn't shipped, there's no corrupted data in the wild yet — but the fix must land in the same release that first enables compression, not after.

Everything else checks out

  • Codec selection (selectWriteCodec): zstd via process.getBuiltinModule('node:zlib') (≥22.15), gzip fallback via portable CompressionStream, WORKFLOW_COMPRESSION_CODEC override — bundler-safe (no static node:zlib import), which is what keeps the module importable in browser/edge targets where it correctly degrades to gzip/none.
  • Read dispatch is codec-agnostic and complete: Node sync path (serialization-format.ts:205), Node async/replay path (compression.ts decompress), and the browser path (hydration.tsensureZstdDecoderRegistered → WASM decoder, lazy + idempotent). The "gzip and zstd co-ship, so a run that reads one reads both" claim that justifies the single shared minVersion is structurally true — all three reader surfaces gained both codecs in this one PR.
  • @tootallnate/zstd-wasm@0.0.2 is the right pin: the browser only ever decodes, and 0.0.2 is decode-only — so the heavier compression code added in v0.1.0 is correctly not pulled into the web bundle. (Noting for the record since the v0.1.0 publish is what prompted this look: nothing here needs it — the write side uses Node's native zstd, never the WASM module.)
  • Compress-before-encrypt preserved (encr(zstd(devl))), conditional gating intact (1KB floor, ≥5% savings, env kill switch), telemetry sizes measured at the compression boundary (pre-encryption) as documented.
  • world-vercel stamps new runs specVersion: 5 with a clear comment pointing at the server-side spec-5 support (workflow-server#520) — the cross-repo coordination is documented.
  • specVersion-5 contract, requiresNewerWorld reject matrix, and CPU/size benchmark scripts all landed as described.

Bottom line

Strong implementation that correctly absorbed the zstd follow-up. The single must-fix is the beta.16beta.18 cutoff bump, which I'm pushing as a follow-up since this PR is already merged. Once that lands in the beta.18 release, this is good.

@TooTallNate

Copy link
Copy Markdown
Member

Follow-up fix for the cutoff bug is up: #2470 (bumps gzip/zstd minVersion beta.16beta.18, with a regression test asserting beta.16/beta.17 are treated as compression-incapable). It needs to land in the same beta.18 release that first enables compression.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants