Skip to content

RFC: gzip-compress serialized payload refs (specVersion 5)#2394

Open
pranaygp wants to merge 4 commits into
mainfrom
pgp/ref-compression
Open

RFC: gzip-compress serialized payload refs (specVersion 5)#2394
pranaygp wants to merge 4 commits into
mainfrom
pgp/ref-compression

Conversation

@pranaygp

@pranaygp pranaygp commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

RFC: Compress serialized payload refs with gzip

Summary

Worlds now store gzip-compressed payloads. Every serialized payload (step inputs/outputs, workflow arguments/return values, errors, hook payloads) is wrapped in a new composable gzip format prefix before it reaches the World storage layer, cutting stored payload bytes by ~70–87% on real-world-style workloads (benchmarks below). 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 gzip as a sibling layer in packages/core/src/serialization/compression.ts, mirroring encryption.ts:

serialize:    codec → 'devl' prefix → compress ('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, uncompressed, encrypted, and any nesting of the three — new SDKs read both old and new data structurally, not via special cases.

Conditional compression

  • Payloads < 1 KB are passed through (gzip 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 gzip 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 gzip 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: a gzip entry in FORMAT_VERSION_TABLE (capabilities.ts) keyed on the target run's workflowCoreVersion, exactly like encr and framedByteStreams before 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:

Workload Uncompressed Compressed Savings
AI chat history (60 messages) 73.4 KB 19.1 KB 74.0%
API response (250 users) 92.5 KB 12.4 KB 86.6%
E-commerce order (30 items) 6.6 KB 1.5 KB 76.8%
Scraped document (~27 KB text) 27.4 KB 7.5 KB 72.5%
Time series (2000 points) 57.2 KB 18.1 KB 68.3%
Random binary (256 KB) 341.4 KB 257.2 KB 24.7%
Tiny payload (<1 KB) 53 B 53 B (passthrough)

Simulated 10-step AI agent run (event log total): 402.1 KB → 108.4 KB (73.0% smaller). Incompressible binary still wins 24.7% because devalue base64-encodes Uint8Array (4/3×) and gzip 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 (Web CompressionStream('gzip')), µs per op, on an M-series laptop:

Workload serialize off → on deserialize off → on
AI chat history (73 KB) 251µs → 1388µs 43µs → 195µs
API response (92 KB) 1277µs → 1720µs 285µs → 442µs
E-commerce order (6.6 KB) 100µs → 168µs 22µs → 73µs
Scraped document (27 KB) 80µs → 328µs 18µs → 96µs
Time series (57 KB) 1601µs → 2207µs 210µs → 352µs
Random binary (256 KB) 1559µs → 6389µs 207µs → 1422µ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 125ms 237ms +113ms
5,000 622ms 1175ms +553ms
10,000 1254ms 2367ms +1.1s

So even a 10,000-event run adds only ~1.1s of total CPU spread across its entire lifetime. Costs scale with payload size; the <1 KB threshold makes small payloads free.

Algorithm comparison (informational, node:zlib sync — not the shipping path): the script also compares gzip levels 1/6/9, brotli, and deflate. Headline finding: CompressionStream('gzip') runs at ≈ zlib level 6, but gzip level 1 is ~8× faster for only 2–3 pp less savings (e.g. AI chat: 123µs/71.1% vs 1051µs/74.0%). CompressionStream exposes no level knob, so capturing that would mean dropping to node:zlib (losing edge/browser portability) — filed as a follow-up, not done here.

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(gzip(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 gzip 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 gzip prefix, 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 the gzip codec applied / was present
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 encrgzip) 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

  • Faster gzip / zstd: the CPU benchmark shows CompressionStream('gzip') runs at ≈ level 6 and that level 1 (or zstd) would cut the CPU ~8× for ~2–3 pp less savings — but CompressionStream has no level knob, so this means node:zlib (loses edge/browser portability) or a future zsd1 codec. The open-ended prefix system means any such codec needs no migration machinery. gzip via CompressionStream (Node 18+/browsers/edge, zero deps) is the portable baseline shipped here.
  • 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(gzip(devl)) nesting order (white-box), small-payload passthrough, incompressible-discard, kill switch, mode serializers, o11y sync+async hydration, capability-table gating.
  • spec-version.test.ts: spec 5 constants, requiresNewerWorld accept/reject matrix, v4-reader simulation.
  • Full unit suites green across @workflow/core (1209 tests), @workflow/world, @workflow/world-local, @workflow/world-vercel, @workflow/world-postgres, workflow, @workflow/cli.
  • Full e2e suite run against a local nextjs-turbopack dev server with compression active.
  • Benchmarks (size + CPU + 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: 78a9a3e

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

This PR includes changesets to release 21 packages
Name Type
@workflow/core Minor
@workflow/world-vercel Minor
@workflow/world Minor
@workflow/builders Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
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
@workflow/ai Major

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 14, 2026 8:07am
example-nextjs-workflow-webpack Ready Ready Preview, Comment Jun 14, 2026 8:07am
example-workflow Ready Ready Preview, Comment Jun 14, 2026 8:07am
workbench-astro-workflow Ready Ready Preview, Comment Jun 14, 2026 8:07am
workbench-express-workflow Ready Ready Preview, Comment Jun 14, 2026 8:07am
workbench-fastify-workflow Ready Ready Preview, Comment Jun 14, 2026 8:07am
workbench-hono-workflow Ready Ready Preview, Comment Jun 14, 2026 8:07am
workbench-nitro-workflow Ready Ready Preview, Comment Jun 14, 2026 8:07am
workbench-nuxt-workflow Ready Ready Preview, Comment Jun 14, 2026 8:07am
workbench-sveltekit-workflow Ready Ready Preview, Comment Jun 14, 2026 8:07am
workbench-tanstack-start-workflow Ready Ready Preview, Comment Jun 14, 2026 8:07am
workbench-vite-workflow Ready Ready Preview, Comment Jun 14, 2026 8:07am
workflow-docs Ready Ready Preview, Comment, Open in v0 Jun 14, 2026 8:07am
workflow-swc-playground Ready Ready Preview, Comment Jun 14, 2026 8:07am
workflow-tarballs Ready Ready Preview, Comment Jun 14, 2026 8:07am
workflow-web Ready Ready Preview, Comment Jun 14, 2026 8:07am

@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 🥇 Express 0.041s (-2.8%) 1.005s (~) 0.964s 10 1.00x
💻 Local Nitro 0.046s (-4.0%) 1.006s (~) 0.960s 10 1.11x
💻 Local Next.js (Turbopack) 0.061s (~) 1.005s (~) 0.944s 10 1.49x
🐘 Postgres Nitro 0.062s (-3.1%) 1.013s (~) 0.951s 10 1.51x
🐘 Postgres Express 0.067s (+10.9% 🔺) 1.013s (~) 0.946s 10 1.63x
🐘 Postgres Next.js (Turbopack) 0.068s (+1.8%) 1.011s (~) 0.943s 10 1.64x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 0.222s (-15.4% 🟢) 1.959s (-16.9% 🟢) 1.737s 10 1.00x
▲ Vercel Next.js (Turbopack) 0.308s (-23.2% 🟢) 2.187s (-14.2% 🟢) 1.878s 10 1.39x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 1 step

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 1.088s (-1.2%) 2.007s (~) 0.919s 10 1.00x
💻 Local Nitro 1.097s (~) 2.007s (~) 0.910s 10 1.01x
🐘 Postgres Express 1.114s (+0.6%) 2.010s (~) 0.897s 10 1.02x
🐘 Postgres Nitro 1.128s (+1.6%) 2.012s (~) 0.884s 10 1.04x
💻 Local Next.js (Turbopack) 1.132s (-1.3%) 2.008s (~) 0.876s 10 1.04x
🐘 Postgres Next.js (Turbopack) 1.138s (-1.5%) 2.010s (~) 0.872s 10 1.05x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 1.586s (-3.8%) 3.511s (-8.4% 🟢) 1.925s 10 1.00x
▲ Vercel Nitro 1.648s (+0.6%) 3.562s (-6.2% 🟢) 1.914s 10 1.04x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 10 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 10.495s (-0.6%) 11.021s (~) 0.526s 3 1.00x
💻 Local Nitro 10.521s (~) 11.023s (~) 0.502s 3 1.00x
🐘 Postgres Express 10.566s (~) 11.017s (~) 0.451s 3 1.01x
🐘 Postgres Nitro 10.584s (~) 11.018s (~) 0.434s 3 1.01x
💻 Local Next.js (Turbopack) 10.813s (~) 11.022s (~) 0.209s 3 1.03x
🐘 Postgres Next.js (Turbopack) 10.861s (-0.7%) 11.016s (-3.0%) 0.155s 3 1.03x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 13.743s (-44.6% 🟢) 15.177s (-41.9% 🟢) 1.434s 2 1.00x
▲ Vercel Next.js (Turbopack) 13.894s (-9.1% 🟢) 15.269s (-12.1% 🟢) 1.375s 2 1.01x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 13.718s (-0.6%) 14.028s (~) 0.310s 5 1.00x
💻 Local Express 13.734s (~) 14.027s (~) 0.293s 5 1.00x
🐘 Postgres Nitro 13.841s (~) 14.021s (-1.4%) 0.180s 5 1.01x
🐘 Postgres Express 13.850s (+0.5%) 14.021s (~) 0.171s 5 1.01x
💻 Local Next.js (Turbopack) 14.412s (-0.8%) 15.029s (~) 0.617s 4 1.05x
🐘 Postgres Next.js (Turbopack) 14.599s (-1.1%) 15.019s (-1.7%) 0.420s 4 1.06x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 21.816s (+4.6%) 23.412s (+2.9%) 1.596s 3 1.00x
▲ Vercel Nitro 22.442s (+6.5% 🔺) 24.610s (+7.2% 🔺) 2.168s 3 1.03x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 50 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 12.454s (~) 13.024s (~) 0.571s 7 1.00x
💻 Local Nitro 12.478s (-0.8%) 13.025s (~) 0.547s 7 1.00x
🐘 Postgres Express 12.595s (+1.1%) 13.019s (~) 0.424s 7 1.01x
🐘 Postgres Nitro 12.656s (+1.1%) 13.021s (~) 0.364s 7 1.02x
💻 Local Next.js (Turbopack) 13.689s (+0.6%) 14.027s (~) 0.338s 7 1.10x
🐘 Postgres Next.js (Turbopack) 13.867s (~) 14.302s (~) 0.436s 7 1.11x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 31.046s (+3.4%) 33.222s (+2.8%) 2.176s 3 1.00x
▲ Vercel Next.js (Turbopack) 31.602s (+3.9%) 33.920s (+4.2%) 2.318s 3 1.02x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 1.214s (+2.1%) 2.007s (~) 0.792s 15 1.00x
🐘 Postgres Express 1.215s (+0.7%) 2.007s (~) 0.792s 15 1.00x
🐘 Postgres Nitro 1.245s (+3.2%) 2.007s (~) 0.762s 15 1.03x
🐘 Postgres Next.js (Turbopack) 1.278s (~) 2.007s (~) 0.729s 15 1.05x
💻 Local Express 1.279s (+3.8%) 2.006s (~) 0.728s 15 1.05x
💻 Local Next.js (Turbopack) 1.354s (+3.4%) 2.006s (~) 0.652s 15 1.12x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.466s (+1.3%) 3.973s (-1.8%) 1.507s 8 1.00x
▲ Vercel Next.js (Turbopack) 2.747s (+3.4%) 4.083s (-6.8% 🟢) 1.336s 8 1.11x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.371s (-0.9%) 2.394s (~) 1.023s 13 1.00x
🐘 Postgres Express 1.445s (+6.3% 🔺) 2.224s (-7.1% 🟢) 0.779s 14 1.05x
🐘 Postgres Next.js (Turbopack) 1.553s (-1.3%) 2.316s (+1.0%) 0.763s 13 1.13x
💻 Local Nitro 1.721s (-12.3% 🟢) 2.006s (-13.4% 🟢) 0.285s 15 1.26x
💻 Local Express 1.728s (+6.4% 🔺) 2.006s (~) 0.278s 15 1.26x
💻 Local Next.js (Turbopack) 1.747s (-1.3%) 2.007s (~) 0.260s 15 1.27x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 3.434s (-4.6%) 5.028s (-7.7% 🟢) 1.594s 6 1.00x
▲ Vercel Next.js (Turbopack) 3.997s (+28.7% 🔺) 5.799s (+11.9% 🔺) 1.802s 6 1.16x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.654s (+2.8%) 4.010s (-3.3%) 2.356s 8 1.00x
🐘 Postgres Express 1.739s (+9.8% 🔺) 4.298s (+7.1% 🔺) 2.559s 7 1.05x
🐘 Postgres Next.js (Turbopack) 3.329s (+1.6%) 4.017s (~) 0.688s 8 2.01x
💻 Local Express 4.600s (+8.6% 🔺) 5.154s (+9.1% 🔺) 0.555s 7 2.78x
💻 Local Next.js (Turbopack) 4.929s (+1.7%) 5.513s (+3.1%) 0.583s 6 2.98x
💻 Local Nitro 5.119s (-7.0% 🟢) 5.679s (-5.6% 🟢) 0.560s 6 3.10x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 3.698s (-28.8% 🟢) 5.465s (-21.8% 🟢) 1.767s 6 1.00x
▲ Vercel Nitro 3.756s (-17.3% 🟢) 5.499s (-12.6% 🟢) 1.743s 6 1.02x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.race with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.210s (+0.8%) 2.008s (~) 0.797s 15 1.00x
🐘 Postgres Nitro 1.217s (~) 2.008s (~) 0.791s 15 1.01x
🐘 Postgres Next.js (Turbopack) 1.288s (~) 2.006s (~) 0.719s 15 1.06x
💻 Local Next.js (Turbopack) 1.383s (~) 2.006s (~) 0.623s 15 1.14x
💻 Local Nitro 1.540s (-7.0% 🟢) 2.006s (~) 0.466s 15 1.27x
💻 Local Express 1.599s (+5.8% 🔺) 2.007s (~) 0.407s 15 1.32x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.544s (-13.6% 🟢) 3.974s (-18.3% 🟢) 1.430s 8 1.00x
▲ Vercel Next.js (Turbopack) 2.632s (-24.1% 🟢) 4.294s (-15.4% 🟢) 1.662s 7 1.03x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.329s (-6.0% 🟢) 2.393s (+3.3%) 1.065s 13 1.00x
🐘 Postgres Nitro 1.355s (~) 2.393s (-7.7% 🟢) 1.038s 13 1.02x
🐘 Postgres Next.js (Turbopack) 1.536s (-5.3% 🟢) 2.394s (+4.3%) 0.858s 13 1.16x
💻 Local Nitro 1.862s (-12.8% 🟢) 2.222s (-14.3% 🟢) 0.360s 14 1.40x
💻 Local Next.js (Turbopack) 2.001s (-3.4%) 2.591s (-8.3% 🟢) 0.590s 12 1.51x
💻 Local Express 2.071s (+18.7% 🔺) 2.591s (+20.5% 🔺) 0.519s 12 1.56x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 3.163s (+20.4% 🔺) 4.545s (+12.2% 🔺) 1.382s 7 1.00x
▲ Vercel Next.js (Turbopack) 3.173s (+21.0% 🔺) 5.012s (+16.3% 🔺) 1.839s 7 1.00x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.703s (+5.7% 🔺) 4.139s (+10.0% 🔺) 2.435s 8 1.00x
🐘 Postgres Express 1.729s (+10.1% 🔺) 4.143s (+3.3%) 2.414s 8 1.01x
🐘 Postgres Next.js (Turbopack) 3.439s (+25.8% 🔺) 4.299s (+24.3% 🔺) 0.860s 7 2.02x
💻 Local Nitro 5.194s (-11.3% 🟢) 5.683s (-11.4% 🟢) 0.490s 6 3.05x
💻 Local Next.js (Turbopack) 5.406s (+10.0% 🔺) 6.014s (+16.0% 🔺) 0.608s 5 3.17x
💻 Local Express 5.562s (+18.7% 🔺) 6.015s (+16.2% 🔺) 0.454s 6 3.27x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 3.000s (-32.9% 🟢) 4.905s (-18.6% 🟢) 1.905s 7 1.00x
▲ Vercel Next.js (Turbopack) 3.618s (+2.4%) 5.263s (-3.1%) 1.645s 6 1.21x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.611s (+8.5% 🔺) 1.023s (+1.7%) 0.413s 59 1.00x
🐘 Postgres Nitro 0.618s (+7.6% 🔺) 1.007s (-1.6%) 0.388s 60 1.01x
💻 Local Express 0.623s (~) 1.005s (-1.6%) 0.381s 60 1.02x
💻 Local Nitro 0.631s (~) 1.005s (~) 0.374s 60 1.03x
🐘 Postgres Next.js (Turbopack) 0.861s (+1.4%) 1.041s (-1.7%) 0.180s 58 1.41x
💻 Local Next.js (Turbopack) 0.916s (+4.1%) 1.116s (+7.4% 🔺) 0.200s 54 1.50x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 4.493s (-26.7% 🟢) 6.489s (-16.7% 🟢) 1.996s 10 1.00x
▲ Vercel Nitro 4.495s (-12.2% 🟢) 6.229s (-8.8% 🟢) 1.735s 10 1.00x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.458s (+7.6% 🔺) 2.008s (-1.1%) 0.550s 45 1.00x
🐘 Postgres Nitro 1.506s (+11.2% 🔺) 2.030s (+1.1%) 0.524s 45 1.03x
💻 Local Express 1.556s (+4.1%) 2.006s (~) 0.451s 45 1.07x
💻 Local Nitro 1.598s (+2.5%) 2.006s (~) 0.409s 45 1.10x
🐘 Postgres Next.js (Turbopack) 2.054s (+3.1%) 2.884s (+20.0% 🔺) 0.829s 32 1.41x
💻 Local Next.js (Turbopack) 2.176s (+0.7%) 3.008s (+1.1%) 0.831s 30 1.49x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 11.371s (-21.8% 🟢) 12.893s (-21.5% 🟢) 1.522s 7 1.00x
▲ Vercel Next.js (Turbopack) 12.056s (-26.6% 🟢) 13.579s (-26.7% 🟢) 1.523s 7 1.06x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: 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 🥇 Nitro 2.995s (+11.0% 🔺) 3.343s (+8.4% 🔺) 0.348s 36 1.00x
🐘 Postgres Express 3.000s (+10.2% 🔺) 3.342s (+7.4% 🔺) 0.343s 36 1.00x
💻 Local Nitro 3.445s (+2.1%) 4.009s (~) 0.564s 30 1.15x
💻 Local Express 3.478s (+9.1% 🔺) 4.009s (+0.8%) 0.531s 30 1.16x
🐘 Postgres Next.js (Turbopack) 4.266s (+10.6% 🔺) 5.011s (+23.9% 🔺) 0.745s 24 1.42x
💻 Local Next.js (Turbopack) 4.540s (+5.3% 🔺) 5.010s (~) 0.471s 24 1.52x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 22.847s (-15.4% 🟢) 24.662s (-14.7% 🟢) 1.816s 5 1.00x
▲ Vercel Next.js (Turbopack) 28.047s (-5.8% 🟢) 30.304s (-5.2% 🟢) 2.257s 4 1.23x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 0.358s (+21.5% 🔺) 1.006s (~) 0.648s 60 1.00x
🐘 Postgres Nitro 0.384s (+58.7% 🔺) 1.006s (~) 0.622s 60 1.07x
🐘 Postgres Express 0.390s (+60.2% 🔺) 1.023s (+1.7%) 0.633s 59 1.09x
💻 Local Express 0.508s (+27.8% 🔺) 1.005s (~) 0.497s 60 1.42x
💻 Local Nitro 0.564s (+35.8% 🔺) 1.005s (~) 0.441s 60 1.57x
💻 Local Next.js (Turbopack) 0.702s (+18.4% 🔺) 1.039s (~) 0.338s 58 1.96x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.186s (+16.5% 🔺) 3.884s (+6.6% 🔺) 1.697s 16 1.00x
▲ Vercel Next.js (Turbopack) 2.232s (+9.8% 🔺) 3.952s (+1.0%) 1.721s 16 1.02x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: 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.635s (+59.3% 🔺) 1.053s (+3.5%) 0.418s 86 1.00x
🐘 Postgres Nitro 0.640s (+58.6% 🔺) 1.053s (+3.5%) 0.413s 86 1.01x
🐘 Postgres Next.js (Turbopack) 0.967s (+45.5% 🔺) 1.436s (+14.6% 🔺) 0.469s 63 1.52x
💻 Local Nitro 2.938s (+34.1% 🔺) 3.610s (+26.5% 🔺) 0.672s 25 4.63x
💻 Local Next.js (Turbopack) 3.112s (+34.5% 🔺) 3.801s (+20.8% 🔺) 0.690s 24 4.90x
💻 Local Express 3.239s (+57.9% 🔺) 3.886s (+48.9% 🔺) 0.647s 24 5.10x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.588s (-9.3% 🟢) 4.382s (-12.1% 🟢) 1.794s 21 1.00x
▲ Vercel Next.js (Turbopack) 3.587s (+9.2% 🔺) 5.488s (+3.6%) 1.901s 17 1.39x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: 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.918s (+20.9% 🔺) 1.340s (-4.5%) 0.422s 90 1.00x
🐘 Postgres Nitro 0.944s (+20.4% 🔺) 1.311s (-7.0% 🟢) 0.367s 92 1.03x
🐘 Postgres Next.js (Turbopack) 3.846s (+23.4% 🔺) 4.334s (+8.0% 🔺) 0.488s 28 4.19x
💻 Local Express 10.409s (+17.1% 🔺) 11.212s (+18.2% 🔺) 0.803s 11 11.34x
💻 Local Nitro 10.484s (+5.7% 🔺) 11.211s (+7.3% 🔺) 0.726s 11 11.42x
💻 Local Next.js (Turbopack) 11.874s (+13.0% 🔺) 12.329s (+8.2% 🔺) 0.455s 10 12.94x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.929s (-52.4% 🟢) 4.650s (-41.1% 🟢) 1.721s 26 1.00x
▲ Vercel Next.js (Turbopack) 5.414s (-41.0% 🟢) 7.373s (-34.9% 🟢) 1.959s 17 1.85x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

Stream Benchmarks (includes TTFB metrics)
workflow with stream

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 1.165s (+1.1%) 2.004s (~) 0.011s (+6.1% 🔺) 2.017s (~) 0.852s 10 1.00x
🐘 Postgres Nitro 1.170s (~) 1.998s (~) 0.001s (+30.0% 🔺) 2.011s (~) 0.841s 10 1.00x
🐘 Postgres Express 1.178s (+1.5%) 1.997s (~) 0.002s (+66.7% 🔺) 2.011s (~) 0.833s 10 1.01x
💻 Local Nitro 1.179s (+0.5%) 2.005s (~) 0.011s (-13.6% 🟢) 2.018s (~) 0.839s 10 1.01x
💻 Local Next.js (Turbopack) 1.210s (-0.6%) 2.003s (~) 0.010s (-1.0%) 2.018s (~) 0.808s 10 1.04x
🐘 Postgres Next.js (Turbopack) 1.233s (~) 2.002s (~) 0.001s (-7.7% 🟢) 2.011s (~) 0.777s 10 1.06x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 2.478s (+9.3% 🔺) 3.352s (-7.8% 🟢) 1.241s (+37.9% 🔺) 5.187s (+2.5%) 2.709s 10 1.00x
▲ Vercel Nitro 2.515s (+4.6%) 3.545s (+0.9%) 1.216s (-9.4% 🟢) 5.172s (-2.6%) 2.658s 10 1.01x
▲ Vercel Express ⚠️ missing - - - - -

🔍 Observability: Next.js (Turbopack) | Nitro

stream pipeline with 5 transform steps (1MB)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 1.565s (-0.8%) 2.010s (~) 0.013s (+4.9%) 2.025s (~) 0.460s 30 1.00x
💻 Local Nitro 1.601s (~) 2.011s (~) 0.013s (+4.0%) 2.026s (~) 0.425s 30 1.02x
🐘 Postgres Express 1.608s (+2.5%) 2.005s (~) 0.005s (-3.8%) 2.026s (~) 0.418s 30 1.03x
🐘 Postgres Nitro 1.627s (+2.8%) 2.001s (~) 0.005s (-2.5%) 2.026s (~) 0.400s 30 1.04x
💻 Local Next.js (Turbopack) 1.715s (-0.6%) 2.009s (~) 0.013s (+15.6% 🔺) 2.026s (~) 0.311s 30 1.10x
🐘 Postgres Next.js (Turbopack) 1.787s (~) 2.010s (~) 0.005s (-2.4%) 2.027s (~) 0.239s 30 1.14x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 6.799s (+13.0% 🔺) 8.380s (+14.6% 🔺) 0.191s (-28.5% 🟢) 8.993s (+10.1% 🔺) 2.194s 7 1.00x
▲ Vercel Next.js (Turbopack) 7.011s (-11.7% 🟢) 8.366s (-10.5% 🟢) 0.294s (-15.0% 🟢) 9.376s (-9.4% 🟢) 2.365s 7 1.03x
▲ Vercel Express ⚠️ missing - - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

10 parallel streams (1MB each)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 0.783s (+5.2% 🔺) 1.126s (+7.7% 🔺) 0.000s (-100.0% 🟢) 1.139s (+6.7% 🔺) 0.356s 53 1.00x
🐘 Postgres Express 0.808s (+5.4% 🔺) 1.059s (+0.9%) 0.000s (-100.0% 🟢) 1.079s (+1.8%) 0.271s 56 1.03x
🐘 Postgres Next.js (Turbopack) 1.014s (-3.1%) 1.486s (-2.5%) 0.000s (NaN%) 1.495s (-2.5%) 0.481s 41 1.29x
💻 Local Express 1.412s (+2.0%) 2.014s (~) 0.000s (~) 2.016s (~) 0.604s 30 1.80x
💻 Local Nitro 1.421s (~) 2.014s (~) 0.000s (-35.7% 🟢) 2.016s (~) 0.595s 30 1.81x
💻 Local Next.js (Turbopack) 1.506s (+4.2%) 2.013s (~) 0.000s (+33.3% 🔺) 2.016s (~) 0.511s 30 1.92x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 3.256s (-1.5%) 4.666s (-4.1%) 0.001s (+500.0% 🔺) 5.358s (~) 2.102s 12 1.00x
▲ Vercel Next.js (Turbopack) 3.411s (-21.2% 🟢) 5.070s (-12.5% 🟢) 0.001s (+Infinity% 🔺) 5.563s (-11.9% 🟢) 2.152s 11 1.05x
▲ Vercel Express ⚠️ missing - - - - -

🔍 Observability: Nitro | 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.687s (+2.3%) 2.221s (+3.7%) 0.000s (+Infinity% 🔺) 2.231s (+1.7%) 0.544s 27 1.00x
🐘 Postgres Nitro 1.715s (-0.7%) 2.304s (+2.1%) 0.000s (NaN%) 2.317s (+2.2%) 0.602s 26 1.02x
🐘 Postgres Next.js (Turbopack) 2.154s (-1.1%) 2.730s (+4.5%) 0.000s (+4.5%) 2.738s (+4.2%) 0.584s 22 1.28x
💻 Local Next.js (Turbopack) 2.913s (~) 3.410s (-4.1%) 0.001s (-12.8% 🟢) 3.420s (-3.9%) 0.507s 18 1.73x
💻 Local Express 3.133s (+3.0%) 3.899s (+4.6%) 0.001s (~) 3.906s (+4.6%) 0.772s 16 1.86x
💻 Local Nitro 3.212s (+1.6%) 3.966s (+3.3%) 0.001s (+90.9% 🔺) 3.971s (+3.3%) 0.759s 16 1.90x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 4.502s (-18.1% 🟢) 6.304s (-11.1% 🟢) 0.000s (-11.1% 🟢) 6.860s (-9.4% 🟢) 2.358s 9 1.00x
▲ Vercel Next.js (Turbopack) 5.231s (-20.6% 🟢) 6.628s (-20.4% 🟢) 0.000s (NaN%) 7.191s (-18.8% 🟢) 1.960s 9 1.16x
▲ Vercel Express ⚠️ missing - - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

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

Winner determined by most benchmark wins

Framework 🥇 Fastest World Wins
Express 🐘 Postgres 14/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


Some benchmark jobs failed:

  • Local: success
  • Postgres: success
  • Vercel: failure

Check the workflow run for details.

@github-actions

github-actions Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

Passed Failed Skipped Total
❌ ▲ Vercel Production 1385 2 219 1606
✅ 💻 Local Development 1679 0 219 1898
✅ 📦 Local Production 1825 0 219 2044
✅ 🐘 Local Postgres 1811 0 233 2044
✅ 📋 Other 844 0 178 1022
Total 7544 2 1068 8614

❌ Failed Tests

▲ Vercel Production (2 failed)

example (1 failed):

  • AbortController abortFromStepWorkflow: step abort cancels an in-flight sibling step

nextjs-turbopack (1 failed):

  • outputStreamWorkflow positive startIndex (skips first chunk)

Details by Category

❌ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 120 0 26
❌ example 119 1 26
✅ express 120 0 26
✅ fastify 120 0 26
✅ hono 120 0 26
❌ nextjs-turbopack 143 1 2
✅ nextjs-webpack 144 0 2
✅ nitro 120 0 26
✅ nuxt 120 0 26
✅ sveltekit 139 0 7
✅ vite 120 0 26
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 121 0 25
✅ express-stable 121 0 25
✅ fastify-stable 121 0 25
✅ hono-stable 121 0 25
✅ nextjs-turbopack-canary 127 0 19
✅ nextjs-turbopack-stable-lazy-discovery-disabled 146 0 0
✅ nextjs-turbopack-stable-lazy-discovery-enabled 146 0 0
✅ nextjs-webpack-canary 127 0 19
✅ nextjs-webpack-stable-lazy-discovery-enabled 146 0 0
✅ nitro-stable 121 0 25
✅ nuxt-stable 121 0 25
✅ sveltekit-stable 140 0 6
✅ vite-stable 121 0 25
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 121 0 25
✅ express-stable 121 0 25
✅ fastify-stable 121 0 25
✅ hono-stable 121 0 25
✅ nextjs-turbopack-canary 127 0 19
✅ nextjs-turbopack-stable-lazy-discovery-disabled 146 0 0
✅ nextjs-turbopack-stable-lazy-discovery-enabled 146 0 0
✅ nextjs-webpack-canary 127 0 19
✅ nextjs-webpack-stable-lazy-discovery-disabled 146 0 0
✅ nextjs-webpack-stable-lazy-discovery-enabled 146 0 0
✅ nitro-stable 121 0 25
✅ nuxt-stable 121 0 25
✅ sveltekit-stable 140 0 6
✅ vite-stable 121 0 25
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 120 0 26
✅ express-stable 120 0 26
✅ fastify-stable 120 0 26
✅ hono-stable 120 0 26
✅ nextjs-turbopack-canary 126 0 20
✅ nextjs-turbopack-stable-lazy-discovery-disabled 145 0 1
✅ nextjs-turbopack-stable-lazy-discovery-enabled 145 0 1
✅ nextjs-webpack-canary 126 0 20
✅ nextjs-webpack-stable-lazy-discovery-disabled 145 0 1
✅ nextjs-webpack-stable-lazy-discovery-enabled 145 0 1
✅ nitro-stable 120 0 26
✅ nuxt-stable 120 0 26
✅ sveltekit-stable 139 0 7
✅ vite-stable 120 0 26
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 121 0 25
✅ e2e-local-dev-tanstack-start- 121 0 25
✅ e2e-local-postgres-nest-stable 120 0 26
✅ e2e-local-postgres-tanstack-start- 120 0 26
✅ e2e-local-prod-nest-stable 121 0 25
✅ e2e-local-prod-tanstack-start- 121 0 25
✅ e2e-vercel-prod-tanstack-start 120 0 26

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: failure
  • Local Prod: success
  • Local Postgres: success
  • Windows: failure

Check the workflow run for details.

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'
);
}
if (!(data instanceof Uint8Array)) return data;
if (peekFormatPrefix(data) !== SerializationFormat.GZIP) return data;

if (!isCompressionAvailable()) {
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`).
*/

@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>
Track gzip payload compression on both the serialize (write) and
deserialize (read) paths via span attributes:
workflow.serialization.{operation,compressed,uncompressed_bytes,
stored_bytes,compression_ratio}. Sizes are measured at the compression
boundary (pre-encryption), so they reflect compression's effect rather
than the at-rest size.

The compression codec stays pure — compress/decompress optionally
populate a CompressionStats sink, threaded through CodecOptions to the
mode serializers and read by the dehydrate/hydrate wrappers, which set
attributes on the active span. Telemetry failures are swallowed so they
can never break the serialize/deserialize data path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

3 participants