Skip to content

perf(core): memoize step return value hydration across inline replays#2472

Open
pranaygp wants to merge 3 commits into
mainfrom
pgp/perf-memoize-step-hydration
Open

perf(core): memoize step return value hydration across inline replays#2472
pranaygp wants to merge 3 commits into
mainfrom
pgp/perf-memoize-step-hydration

Conversation

@pranaygp

@pranaygp pranaygp commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Summary

The inline replay loop (runtime.tsrunWorkflow, workflow.ts) re-executes the workflow body and re-consumes the full event log on every iteration. For each already-completed step, the step consumer (step.ts, step_completed path) re-ran hydrateStepReturnValue — AES-GCM decrypt + devalue-parse of the serialized result — on every replay, even though that exact result was already hydrated on every prior replay.

For a sequential workflow of N steps, replay K hydrates K results, so the aggregate cost across a single invocation is O(N²) decrypt+parse operations.

This PR adds a per-run memoization cache so a completed step's hydrated result is returned in O(1) on subsequent replays within the same invocation, making the aggregate cost O(N).

Before / after

  • Before: replay 1 hydrates 1 result, replay 2 hydrates 2, …, replay K hydrates K → Σ = O(N²) decrypt+parse over a sequential run.
  • After: each completed step's result is hydrated once and memoized for the rest of the invocation → O(N) total. Replay K hydrates only the one newly-completed step; the K−1 prior results are cache hits.

Cache scope & keying

  • Lifetime / scope: owned by the inline loop in runtime.ts (created once per run invocation, alongside cachedEvents), threaded into runWorkflow(..., stepHydrationCache?) and stored on WorkflowOrchestratorContext.stepHydrationCache. A fresh context is created each loop iteration, so the cache deliberately lives outside the per-iteration context to survive across iterations of the same run. It is never shared across unrelated runs or process-level invocations.
  • Keying: by the persisted step_completed event's eventId — a stable, world-assigned id. The same event carries the same immutable serialized bytes across every replay, so a hit is guaranteed to correspond to identical input.
  • Optional / backward compatible: the parameter and context field are optional. Callers/harnesses that omit them (and the many runWorkflow(...) unit tests) degrade to re-hydrating every replay — identical to previous behavior.

Memory characteristic

A cached entry holds the decrypted/devalue-parsed plaintext of a step result, retained for the rest of the invocation on top of the serialized bytes already held in cachedEvents — so for large primitive results it roughly doubles peak retained memory for those results during the run. This residual is:

  • Scoped to one invocation — the Map is created per run and GC'd when the invocation returns; nothing accumulates across runs or process-level invocations (a much weaker concern than a process-wide cache, where the dominant residency — the full event log in cachedEvents — already exists for the same lifetime).
  • Bounded by the primitive-returning completed-step count — at most one small entry per such step.
  • Byte-bounded. Most primitives (numbers, booleans, null/undefined, symbols, short ids/strings) are tiny. The only primitive that can be large is a string (or a pathologically long bigint), so a string/bigint result longer than MAX_MEMOIZED_PRIMITIVE_LENGTH (4 KiB) is not memoized — it falls through to the existing per-replay re-hydrate path. Large payloads are cheap to re-hydrate relative to their footprint, so this caps the worst case at negligible cost. The cap only ever reduces what is cached, so deterministic replay is unaffected.

Ordering safety analysis

The cache lookup replaces only the await hydrateStepReturnValue(...) call inside the existing ctx.promiseQueue.then(async () => { ... }) slot. Everything else is byte-for-byte unchanged:

  • ctx.pendingDeliveries++ / -- accounting is untouched.
  • The hydrate (or cache hit) still happens inside the same serial promiseQueue slot, at the same log position, and still resolves via the same resolve(...).
  • The lookup helper always returns a Promise and awaits even on the miss path, so a cache hit occupies the exact position in the ordered delivery chain a re-hydrate would have.

So delivery order, pendingDeliveries-gated suspensions, the pendingDeliveryBarriers / awaitEarlierDeliveries machinery, and Promise.race/Promise.all replay determinism are all unaffected. Hook, wait, and abort hydration paths are intentionally not cached (they're the ordering-sensitive paths and not the O(N²) hotspot).

Identity / immutability safety

hydrateStepReturnValue (devalue.parse) returns a fresh object graph on every call, and each replay iteration runs in a fresh workflow VM. Today the workflow therefore receives a brand-new value on every replay. If we cached and returned the same object reference across replays, workflow code that mutates a step result (const r = await step(); r.count++) would observe a previous replay's mutation on the next replay — a non-deterministic divergence. (structuredClone on each hit is both lossy — revivers reconstruct stream handles, step-function proxies, Request/Response, and AbortController/AbortSignal class instances — and still O(size).)

Decision: only primitives are memoized (string, number, boolean, bigint, symbol, null, undefined). Primitives are immutable and compared by value, so sharing the reference is provably indistinguishable from re-parsing. Any non-primitive result falls through to a full re-hydrate every replay, preserving current behavior exactly. Errors are never cached, so a rejected hydrate re-attempts on the next replay (no parked rejected promise). This trades away the optimization in the object-returning case to keep deterministic replay airtight — correctness over speed.

What I verified

  • Unit: step-hydration-cache.test.ts (14 tests: primitive detection, memoization, non-primitive eviction/fresh-object, falsy primitives, keying, error non-caching, no-cache passthrough, plus the size-bound — at-bound string is a hit, oversized string/bigint are not memoized and cache.size stays 0) and step-hydration-memoization.test.ts (3 tests through the real createUseStep consumer: hydrate-skipped-on-replay-2 via spy, event-log ordering preserved on cache hits, fresh object per replay for object results).
  • Full core suite: cd packages/core && pnpm test1253 passed / 56 files, including async-deserialization-ordering.test.ts, workflow.test.ts (79 tests), runtime.test.ts, hook-sleep-interaction, abort-consistency. No regressions.
  • Build / format / typecheck: pnpm build (full repo, 27/27), @workflow/core build + tsc --noEmit clean; Biome format applied; new files Biome-clean (the only lint errors were import-ordering, auto-fixed; remaining warnings are pre-existing noExcessiveCognitiveComplexity on functions I only edited).
  • E2E (local nextjs-turbopack dev server, the determinism-sensitive subset): promiseAllWorkflow, promiseRaceWorkflow, promiseAnyWorkflow, sleepWinsRaceWorkflow, stepWinsRaceWorkflow, promiseRaceStressTestWorkflow, hookWorkflow, webhookWorkflow, parallel-steps-then-webhook replay race, sleepingWorkflow, parallelSleepWorkflow, retry/error/catchability suite, fetchWorkflowall passed.

Risks / deferred

  • Only primitive step results are accelerated; object-returning steps still re-hydrate each replay (intentional, for determinism). A future safe extension could deep-freeze + share frozen object graphs, but that needs care around reviver-produced special objects and is out of scope here.
  • Large (>4 KiB) string/bigint results are intentionally not memoized to bound peak retained memory (see Memory characteristic); they re-hydrate each replay.
  • Hook/wait/abort hydration paths are uncached by design.

🤖 Generated with Claude Code

The inline replay loop re-executes the workflow body and re-consumes the
full event log on every iteration. For each already-completed step, the
step consumer re-decrypted and re-devalue-parsed the serialized result on
every replay — O(N^2) decrypt+parse operations across a single
invocation of a sequential N-step workflow.

Add a per-run memoization cache, owned by the inline loop in runtime.ts
(alongside cachedEvents) so it survives across replay iterations of the
same run but never leaks across runs. It is threaded into runWorkflow and
stored on the orchestrator context, and consulted in the step_completed
path keyed by the persisted event id. This makes a completed step's
hydrated result O(1) on subsequent replays, turning the aggregate cost
into O(N).

Determinism is preserved: the cache lookup happens inside the existing
ctx.promiseQueue slot and still resolves via the same resolve(), so a
cache hit occupies the identical position in the ordered delivery chain a
re-hydrate would have — pendingDeliveries accounting, delivery barriers,
and Promise.race/all replay are untouched.

Identity safety: hydrateStepReturnValue returns a fresh object graph each
call and each replay runs in a fresh VM, so sharing an object reference
across replays could let one replay's mutation leak into the next. Only
primitive results are memoized (immutable, reference-share == re-parse);
non-primitives re-hydrate fresh every replay, exactly as before. Hook,
wait, and abort hydration paths are intentionally left uncached.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Copilot AI review requested due to automatic review settings June 17, 2026 01:47
@pranaygp pranaygp requested a review from a team as a code owner June 17, 2026 01:47

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@vercel

vercel Bot commented Jun 17, 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 18, 2026 6:29am
example-nextjs-workflow-webpack Ready Ready Preview, Comment Jun 18, 2026 6:29am
example-workflow Ready Ready Preview, Comment Jun 18, 2026 6:29am
workbench-astro-workflow Ready Ready Preview, Comment Jun 18, 2026 6:29am
workbench-express-workflow Ready Ready Preview, Comment Jun 18, 2026 6:29am
workbench-fastify-workflow Ready Ready Preview, Comment Jun 18, 2026 6:29am
workbench-hono-workflow Ready Ready Preview, Comment Jun 18, 2026 6:29am
workbench-nitro-workflow Ready Ready Preview, Comment Jun 18, 2026 6:29am
workbench-nuxt-workflow Ready Ready Preview, Comment Jun 18, 2026 6:29am
workbench-sveltekit-workflow Ready Ready Preview, Comment Jun 18, 2026 6:29am
workbench-tanstack-start-workflow Ready Ready Preview, Comment Jun 18, 2026 6:29am
workbench-vite-workflow Ready Ready Preview, Comment Jun 18, 2026 6:29am
workflow-docs Ready Ready Preview, Comment, Open in v0 Jun 18, 2026 6:29am
workflow-swc-playground Ready Ready Preview, Comment Jun 18, 2026 6:29am
workflow-tarballs Ready Ready Preview, Comment Jun 18, 2026 6:29am
workflow-web Ready Ready Preview, Comment Jun 18, 2026 6:29am

@changeset-bot

changeset-bot Bot commented Jun 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3ca022f

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

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

@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

Passed Failed Skipped Total
❌ ▲ Vercel Production 1441 1 230 1672
✅ 💻 Local Development 1909 0 219 2128
✅ 📦 Local Production 1909 0 219 2128
❌ 🐘 Local Postgres 1894 1 233 2128
✅ 🪟 Windows 152 0 0 152
✅ 📋 Other 885 0 179 1064
Total 8190 2 1080 9272

❌ Failed Tests

▲ Vercel Production (1 failed)

nitro (1 failed):

  • startFromWorkflow - calling start() directly inside a workflow function with hook communication | wrun_01KVCQ35C5K211Z7CFGRHG4655 | 🔍 observability
🐘 Local Postgres (1 failed)

nextjs-turbopack-stable-lazy-discovery-enabled (1 failed):

  • wellKnownAgentWorkflow (.well-known/agent) | wrun_01KVCPM9CVA5BNHKTABQFTWTWF

Details by Category

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

📋 View full workflow run


Some E2E test jobs failed:

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

Check the workflow run for details.

@github-actions

github-actions Bot commented Jun 17, 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.042s (-2.5%) 1.006s (~) 0.963s 10 1.00x
💻 Local Nitro 0.045s (+10.1% 🔺) 1.006s (~) 0.962s 10 1.06x
💻 Local Next.js (Turbopack) 0.062s (-5.5% 🟢) 1.007s (~) 0.944s 10 1.47x
🐘 Postgres Express 0.067s (-8.0% 🟢) 1.013s (~) 0.946s 10 1.58x
🐘 Postgres Next.js (Turbopack) 0.070s (-0.9%) 1.013s (~) 0.943s 10 1.65x
🐘 Postgres Nitro 0.073s (+15.6% 🔺) 1.013s (~) 0.940s 10 1.72x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 0.271s (-28.1% 🟢) 2.235s (-2.6%) 1.964s 10 1.00x
▲ Vercel Nitro 0.319s (-14.9% 🟢) 2.203s (-2.0%) 1.883s 10 1.18x
▲ Vercel Next.js (Turbopack) 0.351s (+20.6% 🔺) 2.531s (+33.8% 🔺) 2.180s 10 1.29x

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

workflow with 1 step

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 1.094s (-0.7%) 2.006s (~) 0.912s 10 1.00x
💻 Local Nitro 1.095s (+0.9%) 2.007s (~) 0.912s 10 1.00x
🐘 Postgres Nitro 1.108s (-1.1%) 2.009s (~) 0.901s 10 1.01x
🐘 Postgres Express 1.110s (~) 2.008s (~) 0.898s 10 1.02x
💻 Local Next.js (Turbopack) 1.132s (-1.4%) 2.007s (~) 0.875s 10 1.03x
🐘 Postgres Next.js (Turbopack) 1.141s (~) 2.009s (~) 0.868s 10 1.04x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 1.723s (-11.0% 🟢) 3.716s (+1.1%) 1.993s 10 1.00x
▲ Vercel Next.js (Turbopack) 1.761s (~) 3.708s (~) 1.947s 10 1.02x
▲ Vercel Nitro 1.788s (-22.8% 🟢) 3.409s (-14.6% 🟢) 1.621s 10 1.04x

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

workflow with 10 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 10.527s (~) 11.022s (~) 0.495s 3 1.00x
💻 Local Express 10.566s (~) 11.023s (~) 0.457s 3 1.00x
🐘 Postgres Express 10.585s (~) 11.023s (~) 0.438s 3 1.01x
🐘 Postgres Nitro 10.598s (+0.6%) 11.022s (~) 0.424s 3 1.01x
💻 Local Next.js (Turbopack) 10.812s (~) 11.022s (~) 0.210s 3 1.03x
🐘 Postgres Next.js (Turbopack) 10.829s (~) 11.017s (~) 0.189s 3 1.03x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 13.691s (-24.2% 🟢) 15.347s (-23.1% 🟢) 1.656s 2 1.00x
▲ Vercel Nitro 13.728s (-27.6% 🟢) 15.441s (-27.5% 🟢) 1.713s 2 1.00x
▲ Vercel Next.js (Turbopack) 14.688s (+2.6%) 16.926s (+3.9%) 2.238s 2 1.07x

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

workflow with 25 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 13.690s (~) 14.027s (~) 0.337s 5 1.00x
🐘 Postgres Express 13.756s (-0.7%) 14.019s (~) 0.263s 5 1.00x
💻 Local Express 13.798s (~) 14.028s (~) 0.231s 5 1.01x
🐘 Postgres Nitro 13.832s (~) 14.023s (~) 0.191s 5 1.01x
💻 Local Next.js (Turbopack) 14.383s (~) 15.030s (~) 0.647s 4 1.05x
🐘 Postgres Next.js (Turbopack) 14.396s (~) 15.017s (~) 0.621s 4 1.05x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 23.028s (-4.9%) 25.270s (-3.5%) 2.242s 3 1.00x
▲ Vercel Next.js (Turbopack) 24.609s (-30.5% 🟢) 26.392s (-28.3% 🟢) 1.783s 3 1.07x
▲ Vercel Nitro 24.684s (-20.8% 🟢) 26.313s (-19.2% 🟢) 1.629s 3 1.07x

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

workflow with 50 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 12.373s (+2.5%) 13.027s (+1.1%) 0.654s 7 1.00x
💻 Local Express 12.444s (-0.8%) 13.024s (~) 0.580s 7 1.01x
🐘 Postgres Express 12.512s (+0.6%) 13.016s (~) 0.503s 7 1.01x
🐘 Postgres Nitro 12.712s (+1.6%) 13.021s (~) 0.310s 7 1.03x
💻 Local Next.js (Turbopack) 13.662s (~) 14.027s (~) 0.365s 7 1.10x
🐘 Postgres Next.js (Turbopack) 13.897s (~) 14.308s (+1.0%) 0.411s 7 1.12x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 27.276s (-29.9% 🟢) 29.146s (-28.8% 🟢) 1.870s 4 1.00x
▲ Vercel Next.js (Turbopack) 27.460s (-22.0% 🟢) 29.404s (-20.4% 🟢) 1.944s 4 1.01x
▲ Vercel Express 28.389s (-19.0% 🟢) 30.612s (-17.0% 🟢) 2.223s 3 1.04x

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

Promise.all with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.204s (-0.8%) 2.007s (~) 0.803s 15 1.00x
💻 Local Nitro 1.222s (+4.5%) 2.006s (~) 0.784s 15 1.01x
💻 Local Express 1.231s (+5.6% 🔺) 2.007s (~) 0.776s 15 1.02x
🐘 Postgres Nitro 1.251s (+4.4%) 2.009s (~) 0.757s 15 1.04x
🐘 Postgres Next.js (Turbopack) 1.256s (-2.3%) 2.007s (~) 0.751s 15 1.04x
💻 Local Next.js (Turbopack) 1.405s (+8.4% 🔺) 2.006s (~) 0.602s 15 1.17x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 2.571s (+15.9% 🔺) 4.329s (+11.4% 🔺) 1.757s 8 1.00x
▲ Vercel Nitro 3.123s (+7.3% 🔺) 4.469s (+1.4%) 1.346s 7 1.21x
▲ Vercel Next.js (Turbopack) 3.608s (+35.3% 🔺) 4.968s (+21.2% 🔺) 1.360s 7 1.40x

🔍 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.309s (-8.1% 🟢) 2.008s (-16.1% 🟢) 0.700s 15 1.00x
🐘 Postgres Nitro 1.332s (-2.4%) 2.074s (-17.3% 🟢) 0.742s 15 1.02x
🐘 Postgres Next.js (Turbopack) 1.452s (-11.3% 🟢) 2.075s (-13.3% 🟢) 0.623s 15 1.11x
💻 Local Express 1.987s (+22.2% 🔺) 2.592s (+29.2% 🔺) 0.604s 12 1.52x
💻 Local Nitro 2.051s (+32.2% 🔺) 2.507s (+24.7% 🔺) 0.456s 12 1.57x
💻 Local Next.js (Turbopack) 2.359s (+23.5% 🔺) 3.008s (+31.2% 🔺) 0.649s 10 1.80x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 3.443s (-13.1% 🟢) 5.587s (-1.5%) 2.143s 6 1.00x
▲ Vercel Nitro 3.655s (-9.7% 🟢) 5.233s (-8.2% 🟢) 1.577s 6 1.06x
▲ Vercel Next.js (Turbopack) 4.245s (-5.5% 🟢) 5.891s (-9.1% 🟢) 1.646s 6 1.23x

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

Promise.all with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.468s (-16.7% 🟢) 3.886s (~) 2.417s 8 1.00x
🐘 Postgres Nitro 1.470s (-7.8% 🟢) 3.885s (-3.1%) 2.415s 8 1.00x
🐘 Postgres Next.js (Turbopack) 2.642s (-16.3% 🟢) 3.456s (-19.7% 🟢) 0.814s 9 1.80x
💻 Local Nitro 4.340s (+26.8% 🔺) 5.013s (+25.0% 🔺) 0.673s 6 2.96x
💻 Local Express 5.253s (+20.2% 🔺) 5.679s (+16.7% 🔺) 0.425s 6 3.58x
💻 Local Next.js (Turbopack) 6.408s (+11.0% 🔺) 7.019s (+12.9% 🔺) 0.611s 5 4.36x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 4.491s (-26.7% 🟢) 6.303s (-24.4% 🟢) 1.812s 5 1.00x
▲ Vercel Next.js (Turbopack) 4.524s (-19.6% 🟢) 6.497s (-15.8% 🟢) 1.973s 5 1.01x
▲ Vercel Express 4.646s (-15.6% 🟢) 7.023s (-11.4% 🟢) 2.378s 5 1.03x

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

Promise.race with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.202s (-1.3%) 2.007s (~) 0.805s 15 1.00x
🐘 Postgres Nitro 1.214s (~) 2.008s (~) 0.794s 15 1.01x
💻 Local Express 1.228s (-22.1% 🟢) 2.006s (~) 0.778s 15 1.02x
💻 Local Nitro 1.268s (+5.6% 🔺) 2.006s (~) 0.738s 15 1.06x
🐘 Postgres Next.js (Turbopack) 1.271s (-0.8%) 2.008s (~) 0.737s 15 1.06x
💻 Local Next.js (Turbopack) 1.449s (+3.6%) 2.006s (~) 0.557s 15 1.21x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.668s (-25.2% 🟢) 4.041s (-23.6% 🟢) 1.373s 8 1.00x
▲ Vercel Next.js (Turbopack) 2.876s (-22.7% 🟢) 4.782s (-8.6% 🟢) 1.907s 7 1.08x
▲ Vercel Express 3.339s (+43.7% 🔺) 5.449s (+43.2% 🔺) 2.110s 6 1.25x

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

Promise.race with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.304s (-9.6% 🟢) 2.075s (-13.3% 🟢) 0.771s 15 1.00x
🐘 Postgres Express 1.310s (-4.1%) 2.007s (-13.3% 🟢) 0.697s 15 1.00x
🐘 Postgres Next.js (Turbopack) 1.436s (-6.9% 🟢) 2.076s (-6.6% 🟢) 0.640s 15 1.10x
💻 Local Nitro 1.935s (+14.3% 🔺) 2.293s (+14.2% 🔺) 0.358s 14 1.48x
💻 Local Express 2.003s (+5.5% 🔺) 2.393s (+11.3% 🔺) 0.390s 13 1.54x
💻 Local Next.js (Turbopack) 2.369s (+9.3% 🔺) 3.009s (~) 0.639s 10 1.82x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 2.899s (+6.3% 🔺) 4.683s (+2.5%) 1.785s 7 1.00x
▲ Vercel Nitro 3.978s (+42.1% 🔺) 5.734s (+37.1% 🔺) 1.756s 6 1.37x
▲ Vercel Next.js (Turbopack) 4.216s (-5.6% 🟢) 5.944s (-3.4%) 1.728s 6 1.45x

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

Promise.race with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.452s (-19.3% 🟢) 4.014s (~) 2.561s 8 1.00x
🐘 Postgres Nitro 1.589s (-12.0% 🟢) 3.678s (-14.4% 🟢) 2.090s 9 1.09x
🐘 Postgres Next.js (Turbopack) 2.136s (-47.5% 🟢) 3.454s (-22.3% 🟢) 1.318s 9 1.47x
💻 Local Express 5.580s (+21.6% 🔺) 6.014s (+20.0% 🔺) 0.434s 5 3.84x
💻 Local Nitro 5.616s (+25.0% 🔺) 6.017s (+20.1% 🔺) 0.401s 5 3.87x
💻 Local Next.js (Turbopack) 6.852s (+18.0% 🔺) 7.416s (+15.6% 🔺) 0.563s 5 4.72x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 3.610s (-57.2% 🟢) 5.562s (-45.7% 🟢) 1.952s 6 1.00x
▲ Vercel Nitro 4.046s (-70.8% 🟢) 5.724s (-63.4% 🟢) 1.678s 6 1.12x
▲ Vercel Next.js (Turbopack) 4.124s (+9.0% 🔺) 6.272s (+19.9% 🔺) 2.147s 5 1.14x

🔍 Observability: Express | 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.605s (+6.0% 🔺) 1.041s (+3.5%) 0.436s 58 1.00x
💻 Local Express 0.616s (-1.5%) 1.005s (-1.7%) 0.389s 60 1.02x
💻 Local Nitro 0.629s (+25.8% 🔺) 1.039s (+3.1%) 0.410s 58 1.04x
🐘 Postgres Nitro 0.692s (+16.5% 🔺) 1.078s (+3.6%) 0.386s 56 1.14x
🐘 Postgres Next.js (Turbopack) 0.858s (+2.7%) 1.041s (+1.7%) 0.183s 58 1.42x
💻 Local Next.js (Turbopack) 0.860s (-3.1%) 1.005s (-3.3%) 0.144s 60 1.42x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 3.774s (-42.9% 🟢) 5.457s (-34.5% 🟢) 1.683s 11 1.00x
▲ Vercel Next.js (Turbopack) 3.815s (-39.5% 🟢) 5.390s (-33.8% 🟢) 1.575s 12 1.01x
▲ Vercel Express 4.488s (-1.3%) 6.377s (-1.6%) 1.889s 10 1.19x

🔍 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 🥇 Express 1.375s (+1.4%) 2.052s (+1.2%) 0.678s 44 1.00x
🐘 Postgres Nitro 1.466s (+5.6% 🔺) 2.030s (~) 0.564s 45 1.07x
💻 Local Nitro 1.485s (+25.1% 🔺) 2.006s (~) 0.521s 45 1.08x
💻 Local Express 1.530s (+2.8%) 2.007s (~) 0.476s 45 1.11x
🐘 Postgres Next.js (Turbopack) 1.989s (+2.5%) 2.308s (+11.2% 🔺) 0.319s 40 1.45x
💻 Local Next.js (Turbopack) 2.090s (-0.6%) 2.944s (-2.1%) 0.854s 31 1.52x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 10.297s (-41.1% 🟢) 12.369s (-35.5% 🟢) 2.072s 8 1.00x
▲ Vercel Nitro 10.774s (-8.8% 🟢) 12.396s (-12.5% 🟢) 1.621s 8 1.05x
▲ Vercel Next.js (Turbopack) 11.321s (-16.7% 🟢) 13.637s (-11.0% 🟢) 2.316s 7 1.10x

🔍 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.654s (-2.1%) 3.058s (-1.7%) 0.404s 40 1.00x
🐘 Postgres Nitro 2.903s (+3.7%) 3.280s (+3.6%) 0.376s 37 1.09x
💻 Local Express 3.275s (+2.4%) 4.010s (~) 0.735s 30 1.23x
💻 Local Nitro 3.362s (+23.1% 🔺) 4.010s (+24.4% 🔺) 0.648s 30 1.27x
🐘 Postgres Next.js (Turbopack) 3.983s (+2.9%) 4.253s (+4.3%) 0.270s 29 1.50x
💻 Local Next.js (Turbopack) 4.363s (~) 5.010s (~) 0.647s 24 1.64x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 18.286s (-29.9% 🟢) 20.532s (-26.7% 🟢) 2.246s 6 1.00x
▲ Vercel Nitro 18.713s (-28.9% 🟢) 20.382s (-27.5% 🟢) 1.669s 6 1.02x
▲ Vercel Next.js (Turbopack) 20.206s (-19.2% 🟢) 22.420s (-16.3% 🟢) 2.214s 6 1.11x

🔍 Observability: Express | 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 🥇 Express 0.232s (~) 1.006s (~) 0.773s 60 1.00x
🐘 Postgres Nitro 0.237s (+0.7%) 1.006s (~) 0.769s 60 1.02x
🐘 Postgres Next.js (Turbopack) 0.297s (+1.1%) 1.023s (+1.7%) 0.726s 59 1.28x
💻 Local Express 0.400s (-9.2% 🟢) 1.005s (~) 0.605s 60 1.72x
💻 Local Nitro 0.417s (+11.8% 🔺) 1.004s (~) 0.588s 60 1.79x
💻 Local Next.js (Turbopack) 0.633s (+8.8% 🔺) 1.004s (~) 0.371s 60 2.73x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 1.787s (+15.3% 🔺) 3.598s (+25.4% 🔺) 1.811s 18 1.00x
▲ Vercel Nitro 1.933s (+49.7% 🔺) 3.615s (+25.8% 🔺) 1.682s 17 1.08x
▲ Vercel Next.js (Turbopack) 2.387s (+27.6% 🔺) 4.344s (+11.8% 🔺) 1.956s 14 1.34x

🔍 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.331s (-6.9% 🟢) 1.006s (-3.3%) 0.675s 90 1.00x
🐘 Postgres Nitro 0.356s (+4.1%) 1.006s (-1.1%) 0.650s 90 1.08x
🐘 Postgres Next.js (Turbopack) 0.476s (-11.7% 🟢) 1.103s (-1.3%) 0.628s 83 1.44x
💻 Local Nitro 2.182s (+43.2% 🔺) 2.737s (+27.2% 🔺) 0.555s 33 6.59x
💻 Local Express 2.222s (+6.3% 🔺) 2.738s (+3.1%) 0.517s 33 6.71x
💻 Local Next.js (Turbopack) 2.493s (+7.0% 🔺) 3.344s (+8.7% 🔺) 0.851s 27 7.54x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 2.638s (-9.8% 🟢) 4.553s (-5.1% 🟢) 1.915s 20 1.00x
▲ Vercel Nitro 2.705s (-1.5%) 4.386s (-8.3% 🟢) 1.681s 21 1.03x
▲ Vercel Next.js (Turbopack) 3.092s (+1.6%) 4.802s (~) 1.710s 19 1.17x

🔍 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.526s (-7.4% 🟢) 1.059s (-13.0% 🟢) 0.532s 114 1.00x
🐘 Postgres Nitro 0.570s (~) 1.078s (-12.5% 🟢) 0.508s 112 1.08x
🐘 Postgres Next.js (Turbopack) 1.918s (-26.7% 🟢) 2.763s (-20.4% 🟢) 0.845s 44 3.64x
💻 Local Nitro 9.521s (+50.8% 🔺) 10.445s (+54.9% 🔺) 0.924s 12 18.09x
💻 Local Express 10.198s (+22.8% 🔺) 11.029s (+24.2% 🔺) 0.831s 12 19.38x
💻 Local Next.js (Turbopack) 10.284s (-4.4%) 11.663s (+0.8%) 1.379s 11 19.54x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 3.245s (-38.5% 🟢) 5.031s (-28.3% 🟢) 1.786s 24 1.00x
▲ Vercel Express 3.483s (-36.2% 🟢) 5.687s (-19.8% 🟢) 2.204s 22 1.07x
▲ Vercel Next.js (Turbopack) 4.699s (-2.9%) 6.708s (+2.7%) 2.008s 18 1.45x

🔍 Observability: Nitro | Express | 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
🐘 Postgres 🥇 Express 1.165s (-1.1%) 2.000s (~) 0.001s (+9.1% 🔺) 2.009s (~) 0.844s 10 1.00x
🐘 Postgres Nitro 1.181s (+1.4%) 1.995s (~) 0.001s (+30.0% 🔺) 2.011s (~) 0.831s 10 1.01x
💻 Local Nitro 1.181s (+5.7% 🔺) 2.005s (~) 0.010s (-62.9% 🟢) 2.017s (-1.0%) 0.836s 10 1.01x
💻 Local Express 1.185s (+3.0%) 2.005s (~) 0.012s (+19.8% 🔺) 2.019s (~) 0.835s 10 1.02x
💻 Local Next.js (Turbopack) 1.216s (~) 2.003s (~) 0.013s (+2.4%) 2.020s (~) 0.803s 10 1.04x
🐘 Postgres Next.js (Turbopack) 1.232s (~) 2.002s (~) 0.001s (-15.4% 🟢) 2.011s (~) 0.779s 10 1.06x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 2.206s (-8.2% 🟢) 3.426s (-6.9% 🟢) 0.602s (-18.8% 🟢) 4.513s (-7.4% 🟢) 2.307s 10 1.00x
▲ Vercel Next.js (Turbopack) 2.304s (-8.6% 🟢) 3.523s (-7.8% 🟢) 0.772s (-4.3%) 4.790s (-5.8% 🟢) 2.486s 10 1.04x
▲ Vercel Nitro 2.331s (-11.7% 🟢) 3.158s (-17.0% 🟢) 1.253s (+57.9% 🔺) 4.805s (-4.9%) 2.474s 10 1.06x

🔍 Observability: Express | 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 🥇 Nitro 1.572s (+9.3% 🔺) 2.011s (~) 0.012s (-32.0% 🟢) 2.025s (~) 0.453s 30 1.00x
🐘 Postgres Express 1.577s (~) 2.004s (~) 0.005s (+5.7% 🔺) 2.026s (~) 0.449s 30 1.00x
💻 Local Express 1.583s (+1.0%) 2.010s (~) 0.014s (+4.9%) 2.025s (~) 0.442s 30 1.01x
🐘 Postgres Nitro 1.602s (+1.5%) 2.008s (~) 0.005s (-5.6% 🟢) 2.027s (~) 0.425s 30 1.02x
💻 Local Next.js (Turbopack) 1.740s (-0.5%) 2.010s (~) 0.013s (+1.3%) 2.025s (~) 0.285s 30 1.11x
🐘 Postgres Next.js (Turbopack) 1.892s (+5.9% 🔺) 2.011s (~) 0.005s (+5.8% 🔺) 2.029s (~) 0.137s 30 1.20x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 6.842s (-19.5% 🟢) 8.374s (-14.7% 🟢) 0.220s (-16.3% 🟢) 9.079s (-14.1% 🟢) 2.236s 7 1.00x
▲ Vercel Nitro 6.905s (-3.6%) 7.768s (-10.3% 🟢) 0.455s (+91.9% 🔺) 8.766s (-6.9% 🟢) 1.861s 7 1.01x
▲ Vercel Express 8.068s (+26.4% 🔺) 9.373s (+20.1% 🔺) 0.307s (+71.5% 🔺) 10.412s (+23.1% 🔺) 2.345s 6 1.18x

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

10 parallel streams (1MB each)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.789s (+3.3%) 1.101s (+7.5% 🔺) 0.000s (-100.0% 🟢) 1.118s (+6.0% 🔺) 0.329s 54 1.00x
🐘 Postgres Nitro 0.795s (+2.9%) 1.044s (~) 0.000s (-3.5%) 1.061s (-3.0%) 0.265s 57 1.01x
🐘 Postgres Next.js (Turbopack) 0.986s (-3.3%) 1.397s (-4.6%) 0.000s (-100.0% 🟢) 1.407s (-4.4%) 0.421s 43 1.25x
💻 Local Express 1.533s (+12.5% 🔺) 2.014s (~) 0.000s (+16.7% 🔺) 2.016s (~) 0.483s 30 1.94x
💻 Local Nitro 1.568s (+45.8% 🔺) 2.014s (+9.6% 🔺) 0.000s (-56.8% 🟢) 2.016s (+9.5% 🔺) 0.448s 30 1.99x
💻 Local Next.js (Turbopack) 1.916s (+26.7% 🔺) 2.193s (+8.9% 🔺) 0.000s (+7.1% 🔺) 2.196s (+8.9% 🔺) 0.280s 28 2.43x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.961s (-21.2% 🟢) 4.002s (-25.7% 🟢) 0.000s (-21.4% 🟢) 4.479s (-23.6% 🟢) 1.518s 14 1.00x
▲ Vercel Express 3.140s (+3.6%) 4.516s (-1.1%) 0.000s (NaN%) 5.069s (+0.6%) 1.929s 12 1.06x
▲ Vercel Next.js (Turbopack) 3.540s (+2.7%) 5.016s (+5.2% 🔺) 0.000s (-100.0% 🟢) 5.553s (+5.5% 🔺) 2.013s 11 1.20x

🔍 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.691s (+4.7%) 2.302s (+11.5% 🔺) 0.000s (NaN%) 2.316s (+10.9% 🔺) 0.625s 26 1.00x
🐘 Postgres Nitro 1.779s (+14.7% 🔺) 2.381s (+11.6% 🔺) 0.000s (+11.5% 🔺) 2.394s (+11.5% 🔺) 0.615s 26 1.05x
🐘 Postgres Next.js (Turbopack) 2.251s (+6.6% 🔺) 2.651s (+2.5%) 0.000s (-100.0% 🟢) 2.664s (+2.6%) 0.413s 23 1.33x
💻 Local Nitro 4.321s (+95.8% 🔺) 4.717s (+72.0% 🔺) 0.001s (+12.8% 🔺) 4.729s (+71.7% 🔺) 0.409s 13 2.56x
💻 Local Express 4.772s (+62.9% 🔺) 5.362s (+46.1% 🔺) 0.001s (+98.3% 🔺) 5.367s (+46.1% 🔺) 0.595s 12 2.82x
💻 Local Next.js (Turbopack) 5.632s (+94.2% 🔺) 6.226s (+85.4% 🔺) 0.000s (-60.0% 🟢) 6.235s (+85.4% 🔺) 0.602s 10 3.33x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 4.310s (-24.3% 🟢) 6.054s (-16.2% 🟢) 0.000s (+Infinity% 🔺) 6.546s (-14.9% 🟢) 2.236s 10 1.00x
▲ Vercel Next.js (Turbopack) 4.772s (-34.2% 🟢) 6.201s (-26.1% 🟢) 0.000s (-100.0% 🟢) 6.679s (-24.5% 🟢) 1.907s 10 1.11x
▲ Vercel Nitro 4.835s (-7.9% 🟢) 5.805s (-10.1% 🟢) 0.000s (+Infinity% 🔺) 6.308s (-8.9% 🟢) 1.473s 10 1.12x

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

Summary

Fastest Framework by World

Winner determined by most benchmark wins

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

Winner determined by most benchmark wins

Framework 🥇 Fastest World Wins
Express 🐘 Postgres 17/21
Next.js (Turbopack) 🐘 Postgres 14/21
Nitro 🐘 Postgres 13/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

@pranaygp

Copy link
Copy Markdown
Contributor Author

CI failure triage — pre-existing Vercel-prod e2e flake (not a regression)

The two red checks (E2E Vercel Prod Tests (example), E2E Vercel Prod Tests (fastify), which roll up into E2E Required Check) are the shared Vercel-prod timing flake, not caused by this PR. Evidence:

Scope is wrong for a hydration regression. This PR only memoizes primitive step-result hydration. A determinism/stale-value bug there would surface across all worlds — yet every local suite is 100% green:

Suite Passed Failed
▲ Vercel Production 1440 2
💻 Local Development 1909 0
📦 Local Production 1909 0
🐘 Local Postgres 1895 0
🪟 Windows 152 0

Only 2 failures, only on Vercel Production.

The two failing tests are unrelated to result hydration, and are abort/hook timing races:

  1. exampleAbortController > abortFromStepWorkflow: step abort cancels an in-flight sibling step. The run completed successfully; the assertion failed only because the abort lost a race. From the run diagnostics (wrun_01KV9MZ1Y00N90XFCKFTSPX5N1):

    +2.2s  step_completed (longStep)         <- sibling finished on its own
    +3.0s  hook_received                      <- abort signal arrived AFTER
    

    The sibling longStep self-completed (2.2s) before the abort hook arrived (3.0s) under Vercel-prod queue/network latency, so there was no in-flight step left to cancel. Step results hydrated fine; this is purely environmental latency.

  2. fastifystartFromWorkflow - calling start() directly inside a workflow function with hook communication. This exact test passed (4128ms) in the example job of this same commit (a56f5c90b) — a textbook cross-run flake.

The same test is red on plain main, without this change. On main run 27704378960 (commit 2acf13cc7):

  • E2E Vercel Prod Tests (tanstack-start)abortFromStepWorkflow: step abort cancels an in-flight sibling step (the identical test that failed here on example)
  • E2E Vercel Prod Tests (nextjs-turbopack)distributedAbortController - reconnect to existing controller

And on main run 27657696161 (cb181392b, the commit this branch is based on): E2E Vercel Prod Tests (fastify)hookWithSleepFinalStepWorkflow. The set of red workbenches rotates run-to-run — the signature of environment flakiness, not a code regression.

Local verification of this branch (rebuilt @workflow/core first): cd packages/core && pnpm test1249 passed / 56 files, 0 failures, including the determinism/ordering replay tests in workflow.test.ts and the new step-hydration-cache.test.ts (10) + step-hydration-memoization.test.ts (3). The memoization tests assert byte-identical delivery ordering on cache hits and that objects re-hydrate fresh each replay.

Re-running the e2e jobs should clear them. No code change is warranted.

@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 O(N²)→O(N) hydration memoization, done with the right safety bias

This is the most safety-sensitive of the four (it's the only one that caches a value across replays), and the design lands on the conservative side of every judgment call, which is exactly right for replay determinism.

The primitives-only decision is the crux and it's correct. hydrateStepReturnValue (devalue.parse) returns a fresh object graph each call, and each replay runs in a fresh VM, so today the workflow gets a brand-new value every replay. Caching and returning the same object reference would let const r = await step(); r.count++ observe a prior replay's mutation — silent divergence. The alternatives are both worse: structuredClone is lossy for reviver-produced specials (stream handles, step-fn proxies, Request/Response, AbortController/Signal) and still O(size). Restricting the cache to primitives (immutable, compared by value) makes "share the reference" provably indistinguishable from re-parsing, and non-primitives fall through to a full re-hydrate every replay — preserving current behavior exactly. Trading the object-case optimization for airtight determinism is the right call.

What I verified in the integration:

  • Surgical wrap: only the await hydrateStepReturnValue(...) call is replaced; pendingDeliveries++/--, the ctx.promiseQueue.then(...) slot, and resolve(...) are byte-for-byte unchanged. The lookup helper always returns a Promise and awaits even on the miss path, so a cache hit occupies the identical position in the ordered delivery chain a re-hydrate would have — preserving the pendingDeliveries-gated suspension/barrier/Promise.race determinism.
  • Lifetime: cache is created once per invocation in runtime.ts (outside the per-iteration context), threaded through runWorkflow, never shared across runs. The optional param/context field degrades to re-hydrating-every-replay for harnesses that omit it.
  • Keying by step_completed eventId (stable, world-assigned, same immutable bytes every replay) is sound, and has() rather than get() !== undefined correctly treats a memoized undefined result as a hit.
  • Errors never cached — a rejected hydrate re-attempts next replay, no parked rejected promise.

Built @workflow/core; full suite green (1249) including the new step-hydration-cache.test.ts / step-hydration-memoization.test.ts, async-deserialization-ordering.test.ts, and workflow.test.ts.

One forward-looking note (non-blocking): the object case is left on the table by design. If it's ever revisited, the safe path is deep-freeze + share frozen graphs — but only after handling the reviver-produced specials, so I agree with deferring it. LGTM.

@VaguelySerious VaguelySerious 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.

AI review: no blocking issues

// Only memoize values that are safe to return by reference across replays.
// Non-primitives fall through and are re-hydrated fresh on every replay.
if (isMemoizablePrimitive(value)) {
cache.set(eventId, value);

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.

AI Review: Note

The per-run stepHydrationCache is never size-bounded or evicted: it grows one entry per primitive-returning completed step and lives for the entire invocation. The cost worth calling out (the PR doesn't) is the new residency — the decrypted/devalue-parsed plaintext of each cached primitive is now held for the whole invocation, on top of the serialized bytes already retained in cachedEvents. For a long sequential workflow whose steps return large strings, that roughly doubles peak retained memory for those results.

The dominant residency (the full event log in cachedEvents) already exists, so this isn't blocking, but a byte-size threshold would be a cheap safeguard — large primitives are exactly the cheap-to-re-hydrate case relative to their footprint, so letting them fall through to the existing re-hydrate path costs little and bounds the worst case. Ideally with a test asserting the bound.

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 call — capped it, with a test. Pushed in a60baad.

What changed (step-hydration-cache.ts):

  • Added MAX_MEMOIZED_PRIMITIVE_LENGTH = 4096 and extended isMemoizablePrimitive so a string/bigint longer than 4 KiB is treated as non-memoizable. Those are the only primitive types that can carry a large payload — number/boolean/null/undefined/symbol are inherently small, so they're never length-checked. Oversized values now fall through to the existing per-replay re-hydrate path, exactly as you suggested: large primitives are cheap to re-hydrate relative to their footprint, so this caps the doubled-residency worst case at negligible cost.
  • Documented the memory characteristic on the cache module: per-invocation lifetime (fresh Map per run in runtime.ts, GC'd when the invocation returns), bounded by the number of primitive-returning completed steps, primitives-only, now byte-bounded.

Tests (step-hydration-cache.test.ts, +4): isMemoizablePrimitive true at the bound / false beyond it (string and bigint), and an end-to-end assertion that an oversized string re-hydrates on every replay and cache.size === 0 (the bound assertion you asked for); plus an at-bound string is a cache hit.

The cap only ever reduces what gets cached, so determinism is untouched — oversized values just take the already-correct re-hydrate path. Full core suite green (1253, incl. the ordering/determinism + memoization suites); biome + tsc clean.

On consistency with #2471 (the sibling scriptCache): noting the distinction since they're bounded for different reasons. #2471's cache is process-wide and monotonic across the whole process — in dev/watch it pins every historical bundle string (hundreds of MB over a session), which is a genuine regression vs. the prior keep-only-latest behavior, hence the Blocking bound there. This cache is per-invocation and freed wholesale when the run returns, so it can never accumulate across runs; the only real cost is the doubled residency for large primitives during one run, which the size cap here now bounds. Different scope, different severity, but both bounded now.

Address the review note that the per-run step hydration cache was never
size-bounded: cached entries hold the decrypted/parsed plaintext of a
primitive step result for the whole invocation, on top of the serialized
bytes already retained in cachedEvents, so a long run returning large
strings could roughly double peak retained memory for those results.

Document the cache's memory characteristic (per-invocation, freed when the
invocation ends, bounded by primitive-returning step count) and cap the
only primitive types that can carry a large payload: string/bigint results
longer than MAX_MEMOIZED_PRIMITIVE_LENGTH (4 KiB) fall through to the
existing per-replay re-hydrate path instead of being memoized. Large
payloads are cheap to re-hydrate relative to their footprint, so this caps
the worst case at negligible cost. Other primitives are inherently small
and always memoized.

The cap only ever reduces what is cached, so deterministic replay is
unaffected: oversized values take the already-correct re-hydrate path.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…-hydration

* origin/main:
  perf(core): lazy inline step start (save one world round-trip per step) (#2478)
  perf(core): skip per-step events.list via inline event-log delta (#2475)
  Version Packages (beta) (#2491)
  [world-vercel] Honor hasMore flag from v4 list pagination endpoint (#2486)
  Version Packages (beta) (#2451)
  Fix Next workflow module specifier root (#2455)
  [world-vercel] Send remoteRefBehavior=lazy on v4 metadata-only event listings (#2415)
  [swc-plugin] Fix eager discovery for object property steps (#2484)
  fix(web-shared): align attributes panel styling (#2483)
  [web-shared] Auto-scroll trace viewer on J/K span navigation (#2366)
  fix(web): render restarted step segment as solid gray, not running stripes (#2480)
  fix(web-shared): use solid gray for queued trace segment (#2474)
  Add trace viewer span markers for hooks and attributes  (#2452)
  test: support Vercel protection bypass secret in e2e headers (#2458)
  fix(core): bump payload-compression cutoff to 5.0.0-beta.18 (#2470)

Co-Authored-By: Claude Opus 4.8 <[email protected]>

# Conflicts:
#	packages/core/src/runtime.ts
@pranaygp pranaygp enabled auto-merge (squash) June 18, 2026 06:24
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