Skip to content

[core] Memoize getWritable() to fix chunk reordering when called repeatedly - #2086

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/issue-2058
May 23, 2026
Merged

[core] Memoize getWritable() to fix chunk reordering when called repeatedly#2086
VaguelySerious merged 2 commits into
mainfrom
peter/issue-2058

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented May 22, 2026

Copy link
Copy Markdown
Member

Fixes#2058, where calling getWritable multiple times in the same function causes chunk re-ordering without any user-facing warnings. I'm not sure if this is the right approach or we should just throw an error or warn.

The issue

Each call to getWritable() constructed a fresh TransformStream + WorkflowServerWritableStream + background flushablePipe(...), all flushing to the same (runId, name). On Vercel the 50-100ms HTTP write latency turned the inter-pipe race into deterministic reordering — most visibly when callers acquired a fresh writer per chunk (e.g. an AI SDK text-delta loop). Locally world-local made it look fine because filesystem writes are effectively instant.

The fix

  • Share a single TransformStream + flushablePipe per (runId, namespace) within a step context so repeat calls to getWritable() no longer spawn racing pipes.
  • Add per-call guards on ctx.ops so the step still waits for every caller's writes to drain.

Repeat calls to `getWritable()` from the same step previously spawned
independent TransformStream + flushablePipe pairs that all flushed to
the same (runId, name) on the server. On Vercel the 50-100ms HTTP write
latency turned the inter-pipe race into deterministic reordering — most
visibly when callers acquired a fresh writer per chunk (e.g. an AI SDK
text-delta loop). Locally the world-local filesystem path made the race
invisible.
Cache the writable + pipe state per (runId, namespace) in the step
context so repeat calls share one serial sink. Each call still registers
a per-call guard in ctx.ops so the step waits for every caller's writes
to flush before completing, matching the prior multi-pipe semantics.
Closes#2058
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel

vercelBot commented May 22, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented May 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 40106be

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

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
workflowPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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-actionsBot commented May 22, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production122202191441
✅ 💻 Local Development161502191834
✅ 📦 Local Production161502191834
✅ 🐘 Local Postgres161502191834
✅ 🪟 Windows13100131
✅ 📋 Other7410176917
Total6939010527991

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro105026
✅ example105026
✅ express105026
✅ fastify105026
✅ hono105026
✅ nextjs-turbopack12902
✅ nextjs-webpack12902
✅ nitro105026
✅ nuxt105026
✅ sveltekit12407
✅ vite105026
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable106025
✅ express-stable106025
✅ fastify-stable106025
✅ hono-stable106025
✅ nextjs-turbopack-canary112019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13100
✅ nextjs-turbopack-stable-lazy-discovery-enabled13100
✅ nextjs-webpack-canary112019
✅ nextjs-webpack-stable-lazy-discovery-disabled13100
✅ nextjs-webpack-stable-lazy-discovery-enabled13100
✅ nitro-stable106025
✅ nuxt-stable106025
✅ sveltekit-stable12506
✅ vite-stable106025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable106025
✅ express-stable106025
✅ fastify-stable106025
✅ hono-stable106025
✅ nextjs-turbopack-canary112019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13100
✅ nextjs-turbopack-stable-lazy-discovery-enabled13100
✅ nextjs-webpack-canary112019
✅ nextjs-webpack-stable-lazy-discovery-disabled13100
✅ nextjs-webpack-stable-lazy-discovery-enabled13100
✅ nitro-stable106025
✅ nuxt-stable106025
✅ sveltekit-stable12506
✅ vite-stable106025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable106025
✅ express-stable106025
✅ fastify-stable106025
✅ hono-stable106025
✅ nextjs-turbopack-canary112019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13100
✅ nextjs-turbopack-stable-lazy-discovery-enabled13100
✅ nextjs-webpack-canary112019
✅ nextjs-webpack-stable-lazy-discovery-disabled13100
✅ nextjs-webpack-stable-lazy-discovery-enabled13100
✅ nitro-stable106025
✅ nuxt-stable106025
✅ sveltekit-stable12506
✅ vite-stable106025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack13100
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable106025
✅ e2e-local-dev-tanstack-start-106025
✅ e2e-local-postgres-nest-stable106025
✅ e2e-local-postgres-tanstack-start-106025
✅ e2e-local-prod-nest-stable106025
✅ e2e-local-prod-tanstack-start-106025
✅ e2e-vercel-prod-tanstack-start105026

📋 View full workflow run

@github-actions

github-actionsBot commented May 22, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

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

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro0.029s (-32.7% 🟢)1.005s (~)0.976s101.00x
💻 LocalExpress0.030s (-32.3% 🟢)1.006s (~)0.976s101.03x
💻 LocalNext.js (Turbopack)0.049s1.006s0.957s101.69x
🐘 PostgresNitro0.050s (-47.6% 🟢)1.012s (-3.0%)0.962s101.72x
🐘 PostgresExpress0.051s (-11.9% 🟢)1.012s (~)0.961s101.76x
🐘 PostgresNext.js (Turbopack)0.055s1.010s0.955s101.89x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.260s (+3.2%)2.424s (+3.9%)2.165s101.00x
▲ VercelNitro0.274s (-33.1% 🟢)2.158s (-14.0% 🟢)1.885s101.06x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.066s (-5.3% 🟢)2.006s (~)0.940s101.00x
💻 LocalNitro1.069s (-5.5% 🟢)2.006s (~)0.937s101.00x
🐘 PostgresExpress1.083s (-5.5% 🟢)2.009s (~)0.926s101.02x
🐘 PostgresNitro1.086s (-4.7%)2.009s (~)0.923s101.02x
💻 LocalNext.js (Turbopack)1.107s2.006s0.899s101.04x
🐘 PostgresNext.js (Turbopack)1.123s2.009s0.886s101.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)1.590s (-21.9% 🟢)3.378s (-11.8% 🟢)1.788s101.00x
▲ VercelNitro1.608s (-58.7% 🟢)3.228s (-45.4% 🟢)1.620s101.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro10.397s (-5.0% 🟢)11.022s (~)0.625s31.00x
💻 LocalExpress10.408s (-4.7%)11.023s (~)0.615s31.00x
🐘 PostgresExpress10.413s (-5.0% 🟢)11.013s (~)0.600s31.00x
🐘 PostgresNitro10.451s (-3.9%)11.021s (~)0.570s31.01x
💻 LocalNext.js (Turbopack)10.660s11.022s0.363s31.03x
🐘 PostgresNext.js (Turbopack)10.710s11.017s0.307s31.03x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro13.267s (-44.1% 🟢)14.695s (-41.5% 🟢)1.428s31.00x
▲ VercelNext.js (Turbopack)13.473s (-22.2% 🟢)14.947s (-22.9% 🟢)1.474s31.02x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro13.429s (-10.8% 🟢)14.027s (-12.5% 🟢)0.598s51.00x
💻 LocalExpress13.457s (-10.1% 🟢)14.028s (-6.7% 🟢)0.571s51.00x
🐘 PostgresNitro13.469s (-7.7% 🟢)14.019s (-6.7% 🟢)0.550s51.00x
🐘 PostgresExpress13.471s (-7.6% 🟢)14.018s (-6.7% 🟢)0.547s51.00x
💻 LocalNext.js (Turbopack)14.075s15.031s0.956s41.05x
🐘 PostgresNext.js (Turbopack)14.118s15.016s0.898s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro21.328s (-66.9% 🟢)22.835s (-65.7% 🟢)1.507s31.00x
▲ VercelNext.js (Turbopack)22.554s (-57.1% 🟢)24.075s (-55.9% 🟢)1.521s31.06x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro11.788s (-29.8% 🟢)12.022s (-29.4% 🟢)0.234s81.00x
🐘 PostgresExpress11.906s (-15.0% 🟢)12.145s (-16.8% 🟢)0.239s81.01x
💻 LocalExpress11.932s (-28.1% 🟢)12.150s (-28.7% 🟢)0.218s81.01x
🐘 PostgresNitro11.972s (-14.3% 🟢)12.268s (-14.3% 🟢)0.296s81.02x
💻 LocalNext.js (Turbopack)13.074s14.028s0.954s71.11x
🐘 PostgresNext.js (Turbopack)13.117s13.733s0.616s71.11x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro31.064s (-92.7% 🟢)32.780s (-92.3% 🟢)1.716s31.00x
▲ VercelNext.js (Turbopack)34.306s (-91.3% 🟢)36.360s (-90.8% 🟢)2.055s31.10x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.137s (-9.8% 🟢)2.008s (~)0.871s151.00x
💻 LocalNitro1.165s (-28.6% 🟢)2.006s (-3.3%)0.841s151.02x
💻 LocalExpress1.174s (-21.1% 🟢)2.006s (~)0.832s151.03x
🐘 PostgresNitro1.184s (-7.1% 🟢)2.007s (~)0.823s151.04x
🐘 PostgresNext.js (Turbopack)1.202s2.007s0.805s151.06x
💻 LocalNext.js (Turbopack)1.285s2.006s0.721s151.13x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.789s (-17.9% 🟢)4.385s (-11.1% 🟢)1.596s71.00x
▲ VercelNitro2.886s (+2.4%)4.249s (-1.7%)1.363s81.03x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.196s (-49.4% 🟢)2.007s (-33.3% 🟢)0.811s151.00x
🐘 PostgresNitro1.216s (-48.3% 🟢)2.007s (-33.3% 🟢)0.791s151.02x
🐘 PostgresNext.js (Turbopack)1.349s2.008s0.659s151.13x
💻 LocalNitro1.677s (-46.6% 🟢)2.006s (-48.4% 🟢)0.328s151.40x
💻 LocalNext.js (Turbopack)1.680s2.006s0.326s151.40x
💻 LocalExpress1.712s (-42.0% 🟢)2.006s (-41.9% 🟢)0.294s151.43x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.701s (-47.9% 🟢)5.327s (-40.2% 🟢)1.625s61.00x
▲ VercelNitro3.953s (-2.5%)5.162s (-12.8% 🟢)1.210s61.07x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.329s (-61.9% 🟢)2.008s (-49.9% 🟢)0.679s151.00x
🐘 PostgresNitro1.346s (-61.3% 🟢)2.006s (-49.9% 🟢)0.660s151.01x
🐘 PostgresNext.js (Turbopack)1.584s2.007s0.423s151.19x
💻 LocalNitro4.494s (-46.2% 🟢)5.011s (-44.5% 🟢)0.516s73.38x
💻 LocalNext.js (Turbopack)4.764s5.178s0.414s63.58x
💻 LocalExpress4.815s (-42.3% 🟢)5.179s (-42.6% 🟢)0.364s63.62x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)5.312s (-40.4% 🟢)7.208s (-34.2% 🟢)1.896s51.00x
▲ VercelNitro6.035s (+71.2% 🔺)7.638s (+38.0% 🔺)1.603s41.14x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.145s (-8.9% 🟢)2.008s (~)0.863s151.00x
🐘 PostgresExpress1.156s (-8.1% 🟢)2.008s (~)0.852s151.01x
🐘 PostgresNext.js (Turbopack)1.203s2.007s0.804s151.05x
💻 LocalNext.js (Turbopack)1.324s2.006s0.682s151.16x
💻 LocalNitro1.393s (-25.4% 🟢)2.006s (-14.3% 🟢)0.613s151.22x
💻 LocalExpress1.405s (-25.8% 🟢)2.006s (-15.1% 🟢)0.600s151.23x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.604s (+5.9% 🔺)4.035s (-3.2%)1.431s81.00x
▲ VercelNext.js (Turbopack)2.746s (-6.3% 🟢)4.374s (-5.8% 🟢)1.628s71.05x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.208s (-48.4% 🟢)2.009s (-33.2% 🟢)0.802s151.00x
🐘 PostgresExpress1.210s (-48.3% 🟢)2.009s (-33.3% 🟢)0.799s151.00x
🐘 PostgresNext.js (Turbopack)1.323s2.008s0.685s151.10x
💻 LocalExpress2.041s (-34.8% 🟢)2.469s (-34.4% 🟢)0.428s131.69x
💻 LocalNext.js (Turbopack)2.061s2.826s0.766s111.71x
💻 LocalNitro2.063s (-32.7% 🟢)2.593s (-33.3% 🟢)0.530s121.71x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.297s (+2.0%)4.707s (-7.3% 🟢)1.410s71.00x
▲ VercelNext.js (Turbopack)4.012s (+27.7% 🔺)5.468s (+20.9% 🔺)1.456s61.22x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.318s (-62.3% 🟢)2.008s (-49.9% 🟢)0.689s151.00x
🐘 PostgresNitro1.326s (-61.9% 🟢)2.009s (-49.9% 🟢)0.683s151.01x
🐘 PostgresNext.js (Turbopack)1.567s2.008s0.441s151.19x
💻 LocalExpress4.705s (-46.5% 🟢)5.348s (-42.3% 🟢)0.644s63.57x
💻 LocalNitro5.421s (-40.7% 🟢)6.013s (-40.0% 🟢)0.592s54.11x
💻 LocalNext.js (Turbopack)5.757s6.215s0.458s54.37x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.000s (-1.8%)6.567s (-3.7%)1.566s51.00x
▲ VercelNext.js (Turbopack)6.349s (-6.0% 🟢)8.030s (-6.0% 🟢)1.681s41.27x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.438s (-47.8% 🟢)1.007s (-1.6%)0.569s601.00x
💻 LocalNitro0.462s (-52.9% 🟢)1.004s (-8.2% 🟢)0.541s601.06x
🐘 PostgresNitro0.483s (-41.1% 🟢)1.024s (+1.8%)0.541s591.10x
💻 LocalExpress0.511s (-48.1% 🟢)1.039s (-3.5%)0.527s581.17x
🐘 PostgresNext.js (Turbopack)0.656s1.006s0.350s601.50x
💻 LocalNext.js (Turbopack)0.757s1.004s0.247s601.73x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.423s (-75.4% 🟢)6.932s (-71.1% 🟢)1.510s91.00x
▲ VercelNext.js (Turbopack)5.556s (-61.7% 🟢)7.267s (-54.8% 🟢)1.711s91.02x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.023s (-48.2% 🟢)1.549s (-31.4% 🟢)0.526s591.00x
🐘 PostgresNitro1.091s (-43.4% 🟢)1.903s (-9.4% 🟢)0.812s481.07x
💻 LocalNitro1.166s (-61.6% 🟢)2.005s (-46.6% 🟢)0.839s451.14x
💻 LocalExpress1.203s (-60.1% 🟢)2.006s (-44.1% 🟢)0.802s451.18x
🐘 PostgresNext.js (Turbopack)1.576s2.007s0.431s451.54x
💻 LocalNext.js (Turbopack)1.842s2.051s0.209s441.80x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro12.637s (-68.0% 🟢)14.394s (-65.1% 🟢)1.757s71.00x
▲ VercelNext.js (Turbopack)13.164s (-73.6% 🟢)15.106s (-70.8% 🟢)1.942s71.04x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.994s (-50.0% 🟢)2.335s (-46.6% 🟢)0.342s521.00x
🐘 PostgresNitro2.150s (-47.6% 🟢)2.799s (-39.2% 🟢)0.649s431.08x
💻 LocalNitro2.645s (-71.6% 🟢)3.007s (-70.0% 🟢)0.362s401.33x
💻 LocalExpress2.743s (-70.2% 🟢)3.033s (-69.7% 🟢)0.290s401.38x
🐘 PostgresNext.js (Turbopack)3.120s4.010s0.890s301.56x
💻 LocalNext.js (Turbopack)3.899s4.215s0.316s291.96x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro25.288s (-73.9% 🟢)27.188s (-72.4% 🟢)1.900s51.00x
▲ VercelNext.js (Turbopack)27.396s (-74.4% 🟢)29.427s (-73.0% 🟢)2.031s51.08x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.176s (-37.8% 🟢)1.006s (~)0.829s601.00x
🐘 PostgresExpress0.180s (-36.1% 🟢)1.006s (~)0.825s601.02x
🐘 PostgresNext.js (Turbopack)0.226s1.006s0.780s601.28x
💻 LocalExpress0.392s (-30.0% 🟢)1.004s (~)0.612s602.22x
💻 LocalNitro0.395s (-34.7% 🟢)1.004s (-1.7%)0.609s602.24x
💻 LocalNext.js (Turbopack)0.549s1.004s0.455s603.11x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.378s (+43.2% 🔺)3.815s (+13.8% 🔺)1.437s161.00x
▲ VercelNext.js (Turbopack)2.893s (+43.0% 🔺)4.315s (+13.7% 🔺)1.422s141.22x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.301s (-41.0% 🟢)1.006s (~)0.705s901.00x
🐘 PostgresNitro0.305s (-38.6% 🟢)1.006s (~)0.702s901.01x
🐘 PostgresNext.js (Turbopack)0.422s1.006s0.584s901.40x
💻 LocalNitro2.135s (-15.9% 🟢)2.715s (-9.8% 🟢)0.580s347.09x
💻 LocalExpress2.178s (-13.3% 🟢)2.796s (-7.1% 🟢)0.618s337.24x
💻 LocalNext.js (Turbopack)2.381s3.010s0.630s307.91x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.031s (+56.0% 🔺)6.621s (+37.3% 🔺)1.590s141.00x
▲ VercelNext.js (Turbopack)5.780s (+63.5% 🔺)7.417s (+42.8% 🔺)1.636s131.15x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.617s (-24.7% 🟢)1.006s (-1.1%)0.389s1201.00x
🐘 PostgresNitro0.661s (-16.4% 🟢)1.006s (~)0.345s1201.07x
🐘 PostgresNext.js (Turbopack)0.866s1.023s0.157s1181.40x
💻 LocalNitro9.855s (-11.9% 🟢)10.275s (-11.9% 🟢)0.420s1215.98x
💻 LocalExpress10.320s (-7.8% 🟢)10.781s (-9.7% 🟢)0.461s1216.73x
💻 LocalNext.js (Turbopack)11.376s11.939s0.563s1118.44x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)16.735s (+62.0% 🔺)18.540s (+50.9% 🔺)1.805s71.00x
▲ VercelNitro24.069s (+211.7% 🔺)25.599s (+172.3% 🔺)1.529s51.44x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro1.130s (+428.7% 🔺)2.005s (+99.6% 🔺)0.010s (-18.4% 🟢)2.017s (+98.0% 🔺)0.887s101.00x
💻 LocalExpress1.142s (+473.5% 🔺)2.005s (+99.6% 🔺)0.011s (-13.2% 🟢)2.018s (+98.2% 🔺)0.876s101.01x
🐘 PostgresExpress1.145s (+458.5% 🔺)1.997s (+99.9% 🔺)0.001s (-31.3% 🟢)2.010s (+98.7% 🔺)0.864s101.01x
🐘 PostgresNitro1.159s (+465.3% 🔺)2.002s (+100.2% 🔺)0.002s (+13.3% 🔺)2.011s (+98.9% 🔺)0.852s101.03x
💻 LocalNext.js (Turbopack)1.180s2.004s0.011s2.018s0.839s101.04x
🐘 PostgresNext.js (Turbopack)1.196s2.002s0.002s2.010s0.814s101.06x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.306s (-66.4% 🟢)3.176s (-63.3% 🟢)2.309s (+265.5% 🔺)5.934s (-39.4% 🟢)3.628s101.00x
▲ VercelNitro2.375s (-38.0% 🟢)3.202s (-39.3% 🟢)14.034s (+1791.1% 🔺)17.640s (+172.1% 🔺)15.265s101.03x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.519s (+141.2% 🔺)2.003s (+99.0% 🔺)0.004s (+1.8%)2.026s (+98.0% 🔺)0.506s301.00x
🐘 PostgresNitro1.566s (+150.9% 🔺)2.008s (+99.5% 🔺)0.004s (-1.7%)2.025s (+98.1% 🔺)0.459s301.03x
🐘 PostgresNext.js (Turbopack)1.652s2.010s0.004s2.026s0.374s301.09x
💻 LocalNitro1.694s (+102.0% 🔺)2.010s (+98.6% 🔺)0.009s (-2.4%)2.200s (+97.2% 🔺)0.507s281.11x
💻 LocalExpress1.703s (+125.0% 🔺)2.010s (+95.4% 🔺)0.011s (+12.7% 🔺)2.202s (+111.8% 🔺)0.499s281.12x
💻 LocalNext.js (Turbopack)1.871s2.011s0.009s2.203s0.332s281.23x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)5.720s (-66.2% 🟢)6.903s (-62.2% 🟢)0.256s (+20.9% 🔺)7.611s (-59.8% 🟢)1.891s81.00x
▲ VercelNitro5.938s (-79.8% 🟢)7.093s (-77.0% 🟢)0.210s (+87.5% 🔺)7.760s (-75.6% 🟢)1.823s81.04x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.651s (-32.3% 🟢)1.027s (-19.6% 🟢)0.000s (-61.0% 🟢)1.040s (-20.4% 🟢)0.390s591.00x
🐘 PostgresNitro0.674s (-30.4% 🟢)1.070s (-14.2% 🟢)0.000s (-15.8% 🟢)1.077s (-14.3% 🟢)0.403s571.04x
🐘 PostgresNext.js (Turbopack)0.750s1.035s0.000s1.042s0.292s581.15x
💻 LocalNitro1.341s (+9.7% 🔺)2.015s (~)0.001s (+433.3% 🔺)2.018s (~)0.677s302.06x
💻 LocalExpress1.376s (+12.3% 🔺)2.015s (~)0.000s (~)2.018s (~)0.642s302.11x
💻 LocalNext.js (Turbopack)1.482s2.014s0.000s2.017s0.535s302.28x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.886s (-61.8% 🟢)4.959s (-56.9% 🟢)0.002s (+Infinity% 🔺)5.651s (-53.1% 🟢)1.766s111.00x
▲ VercelNitro4.900s (+60.6% 🔺)6.154s (+40.1% 🔺)0.000s (-100.0% 🟢)6.747s (+40.3% 🔺)1.847s91.26x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

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

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.289s (-27.2% 🟢)2.065s (-5.2% 🟢)0.000s (+Infinity% 🔺)2.079s (-5.4% 🟢)0.790s291.00x
🐘 PostgresNitro1.338s (-25.3% 🟢)2.068s (-3.4%)0.000s (+189.7% 🔺)2.079s (-4.4%)0.742s291.04x
🐘 PostgresNext.js (Turbopack)1.566s2.181s0.000s2.203s0.637s281.21x
💻 LocalNitro3.063s (-9.6% 🟢)3.677s (-8.8% 🟢)0.000s (-55.9% 🟢)3.680s (-8.8% 🟢)0.617s172.38x
💻 LocalExpress3.172s (-8.5% 🟢)3.904s (-3.2%)0.001s (-37.5% 🟢)3.908s (-3.2%)0.736s162.46x
💻 LocalNext.js (Turbopack)3.190s3.337s0.001s3.612s0.422s192.47x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.807s (+41.8% 🔺)7.135s (+32.8% 🔺)0.000s (+37.5% 🔺)7.605s (+31.3% 🔺)1.798s81.00x
▲ VercelNext.js (Turbopack)6.464s (+15.1% 🔺)7.633s (+9.3% 🔺)0.000s (-100.0% 🟢)8.268s (+9.6% 🔺)1.804s81.11x
▲ VercelExpress⚠️missing-----

🔍 Observability: Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNitro16/21
🐘 PostgresExpress16/21
▲ VercelNitro12/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres16/21
Next.js (Turbopack)🐘 Postgres15/21
Nitro🐘 Postgres13/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.

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

Should we memoize the getWritable() return value instead? Seems simpler to me.

@VaguelySerious

Copy link
Copy Markdown
MemberAuthor

@TooTallNate doing now

Drop the per-call guard mechanism and `pollSharedWritableLock` helper.
With memoization, the cached pipe's single `state.promise` is sufficient:
in the loop pattern `await writer.write(chunk)` already blocks until each
chunk is flushed (the sink awaits the scheduled flush before resolving),
so by the time the loop ends `pendingOps === 0` and the final
`releaseLock()` lets `pollWritableLock` resolve the state. One writable,
one pipe, one ops entry per `(runId, namespace)`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@TooTallNateTooTallNate 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 — clean fix, my earlier "memoize the return value" suggestion landed in 40106be78

The bug is real and the fix matches the issue exactly: caching serialize.writable per (runId, namespace) on the step context so repeat calls within the same step go through one serial sink instead of spawning racing pipes that fight for the same (runId, name) server stream.

What I verified

  • pnpm install --frozen-lockfile
  • pnpm turbo run build --filter @workflow/core
  • pnpm --filter @workflow/core test ✓ (1013/1013)
  • cd packages/core && pnpm exec vitest run src/step/writable-stream.test.ts ✓ (5/5 — 2 existing + 3 new)
  • CI: 65 E2E jobs pass, Unit Tests pass, Windows Tests pass, single Benchmark Vercel (express) flake unrelated to this change

Test coverage looks great

The three new tests pin the contract from three angles:

  1. returns the same writable for repeat calls with the same namespace — direct identity assertion (and distinct namespaces still get distinct writables)
  2. preserves chunk order across per-write getWritable() calls in a loop — exact repro of the issue's 'nov', 'o', ' e', '2', 'e', ' ok' scenario, decoded through the matching deserialize stream
  3. registers exactly one pipe per (runId, namespace), regardless of call count — asserts ops.length === 2 (one shared + one for the distinct namespace)

The chunk-order test is particularly good because it goes through the full serialize → record → deserialize round trip rather than asserting against mock internals.

Per-call ctx.ops guards — no longer needed

The simplification from 0d076e0c40106be78 correctly drops per-caller ops registration. Since all callers now share one state.promise, that one promise already covers every writer that holds the lock — when any writer has the lock the pipe is active, when none do the state resolves. The "wait for every caller's writes to drain" semantics fall out for free.

One subtle behavior change worth calling out

A user who does getWritable().close() and then calls getWritable() again will now get back the same closed writable instead of a fresh one. Pre-PR, the second call would have created a new writable they could continue writing to. Post-PR, that second call returns a closed handle.

I don't think anyone is actually depending on this — the issue describes the bug as users not realizing re-acquisition was unsafe, so "close then re-acquire to keep writing" is unlikely to be a real pattern. The AI SDK / per-chunk-loop case (which IS the motivating example) only does releaseLock(), not close(). So this should be fine in practice. Worth mentioning in case anyone hits it post-release.

Branch hygiene note (not a blocker)

The branch is forked from a point before #2091 landed, so the GitHub diff against current main shows misleading "deletions" of .changeset/changelog.mjs, .changeset/config.json's changelog generator reference, and 5 other unrelated changesets. Those are all artifacts of being stale — the PR's actual tree doesn't have those files at all (because they were added on main after the branch point), so a fresh sync with main would make the diff display correctly. Squash-merge will produce a clean single commit regardless, so this doesn't affect what lands. Just flagging because the github UI looks scary at first glance.

Approving.

@VaguelySerious
VaguelySerious merged commit 2050656 into mainMay 23, 2026
185 of 192 checks passed
@VaguelySerious
VaguelySerious deleted the peter/issue-2058 branch May 23, 2026 07:52
@github-actionsgithub-actionsBot mentioned this pull request May 28, 2026
Sign up for freeto 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.

getWritable() returns a new TransformStream per call — racing pipes reorder chunks when callers acquire per-write

2 participants

@VaguelySerious@TooTallNate
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all \x3Cpre>\x3Ccode> blocks (function() { function addCopyButtons() { document.querySelectorAll('pre code').forEach(function(codeBlock) { if (codeBlock.parentElement.hasAttribute('data-copy-added')) return; codeBlock.parentElement.setAttribute('data-copy-added', 'true'); var btn = document.createElement('button'); btn.textContent = 'Copy'; btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;'; btn.onmouseover = function() { this.style.opacity = '1'; }; btn.onmouseout = function() { this.style.opacity = '0.7'; }; btn.onclick = function() { navigator.clipboard.writeText(codeBlock.textContent).then(function() { btn.textContent = 'Copied!'; setTimeout(function() { btn.textContent = 'Copy'; }, 1500); }); }; codeBlock.parentElement.style.position = 'relative'; codeBlock.parentElement.appendChild(btn); }); } addCopyButtons(); // Re-run on dynamic content var observer = new MutationObserver(addCopyButtons); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + ' [core] Memoize `getWritable()` to fix chunk reordering when called repeatedly by VaguelySerious · Pull Request #2086 · vercel/workflow · GitHub
Skip to content

[core] Memoize getWritable() to fix chunk reordering when called repeatedly - #2086

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/issue-2058
May 23, 2026
Merged

[core] Memoize getWritable() to fix chunk reordering when called repeatedly#2086
VaguelySerious merged 2 commits into
mainfrom
peter/issue-2058

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented May 22, 2026

Copy link
Copy Markdown
Member

Fixes#2058, where calling getWritable multiple times in the same function causes chunk re-ordering without any user-facing warnings. I'm not sure if this is the right approach or we should just throw an error or warn.

The issue

Each call to getWritable() constructed a fresh TransformStream + WorkflowServerWritableStream + background flushablePipe(...), all flushing to the same (runId, name). On Vercel the 50-100ms HTTP write latency turned the inter-pipe race into deterministic reordering — most visibly when callers acquired a fresh writer per chunk (e.g. an AI SDK text-delta loop). Locally world-local made it look fine because filesystem writes are effectively instant.

The fix

  • Share a single TransformStream + flushablePipe per (runId, namespace) within a step context so repeat calls to getWritable() no longer spawn racing pipes.
  • Add per-call guards on ctx.ops so the step still waits for every caller's writes to drain.

Repeat calls to `getWritable()` from the same step previously spawned
independent TransformStream + flushablePipe pairs that all flushed to
the same (runId, name) on the server. On Vercel the 50-100ms HTTP write
latency turned the inter-pipe race into deterministic reordering — most
visibly when callers acquired a fresh writer per chunk (e.g. an AI SDK
text-delta loop). Locally the world-local filesystem path made the race
invisible.
Cache the writable + pipe state per (runId, namespace) in the step
context so repeat calls share one serial sink. Each call still registers
a per-call guard in ctx.ops so the step waits for every caller's writes
to flush before completing, matching the prior multi-pipe semantics.
Closes#2058
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel

vercelBot commented May 22, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented May 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 40106be

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

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
workflowPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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-actionsBot commented May 22, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production122202191441
✅ 💻 Local Development161502191834
✅ 📦 Local Production161502191834
✅ 🐘 Local Postgres161502191834
✅ 🪟 Windows13100131
✅ 📋 Other7410176917
Total6939010527991

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro105026
✅ example105026
✅ express105026
✅ fastify105026
✅ hono105026
✅ nextjs-turbopack12902
✅ nextjs-webpack12902
✅ nitro105026
✅ nuxt105026
✅ sveltekit12407
✅ vite105026
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable106025
✅ express-stable106025
✅ fastify-stable106025
✅ hono-stable106025
✅ nextjs-turbopack-canary112019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13100
✅ nextjs-turbopack-stable-lazy-discovery-enabled13100
✅ nextjs-webpack-canary112019
✅ nextjs-webpack-stable-lazy-discovery-disabled13100
✅ nextjs-webpack-stable-lazy-discovery-enabled13100
✅ nitro-stable106025
✅ nuxt-stable106025
✅ sveltekit-stable12506
✅ vite-stable106025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable106025
✅ express-stable106025
✅ fastify-stable106025
✅ hono-stable106025
✅ nextjs-turbopack-canary112019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13100
✅ nextjs-turbopack-stable-lazy-discovery-enabled13100
✅ nextjs-webpack-canary112019
✅ nextjs-webpack-stable-lazy-discovery-disabled13100
✅ nextjs-webpack-stable-lazy-discovery-enabled13100
✅ nitro-stable106025
✅ nuxt-stable106025
✅ sveltekit-stable12506
✅ vite-stable106025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable106025
✅ express-stable106025
✅ fastify-stable106025
✅ hono-stable106025
✅ nextjs-turbopack-canary112019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13100
✅ nextjs-turbopack-stable-lazy-discovery-enabled13100
✅ nextjs-webpack-canary112019
✅ nextjs-webpack-stable-lazy-discovery-disabled13100
✅ nextjs-webpack-stable-lazy-discovery-enabled13100
✅ nitro-stable106025
✅ nuxt-stable106025
✅ sveltekit-stable12506
✅ vite-stable106025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack13100
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable106025
✅ e2e-local-dev-tanstack-start-106025
✅ e2e-local-postgres-nest-stable106025
✅ e2e-local-postgres-tanstack-start-106025
✅ e2e-local-prod-nest-stable106025
✅ e2e-local-prod-tanstack-start-106025
✅ e2e-vercel-prod-tanstack-start105026

📋 View full workflow run

@github-actions

github-actionsBot commented May 22, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

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

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro0.029s (-32.7% 🟢)1.005s (~)0.976s101.00x
💻 LocalExpress0.030s (-32.3% 🟢)1.006s (~)0.976s101.03x
💻 LocalNext.js (Turbopack)0.049s1.006s0.957s101.69x
🐘 PostgresNitro0.050s (-47.6% 🟢)1.012s (-3.0%)0.962s101.72x
🐘 PostgresExpress0.051s (-11.9% 🟢)1.012s (~)0.961s101.76x
🐘 PostgresNext.js (Turbopack)0.055s1.010s0.955s101.89x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.260s (+3.2%)2.424s (+3.9%)2.165s101.00x
▲ VercelNitro0.274s (-33.1% 🟢)2.158s (-14.0% 🟢)1.885s101.06x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.066s (-5.3% 🟢)2.006s (~)0.940s101.00x
💻 LocalNitro1.069s (-5.5% 🟢)2.006s (~)0.937s101.00x
🐘 PostgresExpress1.083s (-5.5% 🟢)2.009s (~)0.926s101.02x
🐘 PostgresNitro1.086s (-4.7%)2.009s (~)0.923s101.02x
💻 LocalNext.js (Turbopack)1.107s2.006s0.899s101.04x
🐘 PostgresNext.js (Turbopack)1.123s2.009s0.886s101.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)1.590s (-21.9% 🟢)3.378s (-11.8% 🟢)1.788s101.00x
▲ VercelNitro1.608s (-58.7% 🟢)3.228s (-45.4% 🟢)1.620s101.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro10.397s (-5.0% 🟢)11.022s (~)0.625s31.00x
💻 LocalExpress10.408s (-4.7%)11.023s (~)0.615s31.00x
🐘 PostgresExpress10.413s (-5.0% 🟢)11.013s (~)0.600s31.00x
🐘 PostgresNitro10.451s (-3.9%)11.021s (~)0.570s31.01x
💻 LocalNext.js (Turbopack)10.660s11.022s0.363s31.03x
🐘 PostgresNext.js (Turbopack)10.710s11.017s0.307s31.03x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro13.267s (-44.1% 🟢)14.695s (-41.5% 🟢)1.428s31.00x
▲ VercelNext.js (Turbopack)13.473s (-22.2% 🟢)14.947s (-22.9% 🟢)1.474s31.02x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro13.429s (-10.8% 🟢)14.027s (-12.5% 🟢)0.598s51.00x
💻 LocalExpress13.457s (-10.1% 🟢)14.028s (-6.7% 🟢)0.571s51.00x
🐘 PostgresNitro13.469s (-7.7% 🟢)14.019s (-6.7% 🟢)0.550s51.00x
🐘 PostgresExpress13.471s (-7.6% 🟢)14.018s (-6.7% 🟢)0.547s51.00x
💻 LocalNext.js (Turbopack)14.075s15.031s0.956s41.05x
🐘 PostgresNext.js (Turbopack)14.118s15.016s0.898s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro21.328s (-66.9% 🟢)22.835s (-65.7% 🟢)1.507s31.00x
▲ VercelNext.js (Turbopack)22.554s (-57.1% 🟢)24.075s (-55.9% 🟢)1.521s31.06x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro11.788s (-29.8% 🟢)12.022s (-29.4% 🟢)0.234s81.00x
🐘 PostgresExpress11.906s (-15.0% 🟢)12.145s (-16.8% 🟢)0.239s81.01x
💻 LocalExpress11.932s (-28.1% 🟢)12.150s (-28.7% 🟢)0.218s81.01x
🐘 PostgresNitro11.972s (-14.3% 🟢)12.268s (-14.3% 🟢)0.296s81.02x
💻 LocalNext.js (Turbopack)13.074s14.028s0.954s71.11x
🐘 PostgresNext.js (Turbopack)13.117s13.733s0.616s71.11x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro31.064s (-92.7% 🟢)32.780s (-92.3% 🟢)1.716s31.00x
▲ VercelNext.js (Turbopack)34.306s (-91.3% 🟢)36.360s (-90.8% 🟢)2.055s31.10x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.137s (-9.8% 🟢)2.008s (~)0.871s151.00x
💻 LocalNitro1.165s (-28.6% 🟢)2.006s (-3.3%)0.841s151.02x
💻 LocalExpress1.174s (-21.1% 🟢)2.006s (~)0.832s151.03x
🐘 PostgresNitro1.184s (-7.1% 🟢)2.007s (~)0.823s151.04x
🐘 PostgresNext.js (Turbopack)1.202s2.007s0.805s151.06x
💻 LocalNext.js (Turbopack)1.285s2.006s0.721s151.13x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.789s (-17.9% 🟢)4.385s (-11.1% 🟢)1.596s71.00x
▲ VercelNitro2.886s (+2.4%)4.249s (-1.7%)1.363s81.03x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.196s (-49.4% 🟢)2.007s (-33.3% 🟢)0.811s151.00x
🐘 PostgresNitro1.216s (-48.3% 🟢)2.007s (-33.3% 🟢)0.791s151.02x
🐘 PostgresNext.js (Turbopack)1.349s2.008s0.659s151.13x
💻 LocalNitro1.677s (-46.6% 🟢)2.006s (-48.4% 🟢)0.328s151.40x
💻 LocalNext.js (Turbopack)1.680s2.006s0.326s151.40x
💻 LocalExpress1.712s (-42.0% 🟢)2.006s (-41.9% 🟢)0.294s151.43x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.701s (-47.9% 🟢)5.327s (-40.2% 🟢)1.625s61.00x
▲ VercelNitro3.953s (-2.5%)5.162s (-12.8% 🟢)1.210s61.07x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.329s (-61.9% 🟢)2.008s (-49.9% 🟢)0.679s151.00x
🐘 PostgresNitro1.346s (-61.3% 🟢)2.006s (-49.9% 🟢)0.660s151.01x
🐘 PostgresNext.js (Turbopack)1.584s2.007s0.423s151.19x
💻 LocalNitro4.494s (-46.2% 🟢)5.011s (-44.5% 🟢)0.516s73.38x
💻 LocalNext.js (Turbopack)4.764s5.178s0.414s63.58x
💻 LocalExpress4.815s (-42.3% 🟢)5.179s (-42.6% 🟢)0.364s63.62x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)5.312s (-40.4% 🟢)7.208s (-34.2% 🟢)1.896s51.00x
▲ VercelNitro6.035s (+71.2% 🔺)7.638s (+38.0% 🔺)1.603s41.14x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.145s (-8.9% 🟢)2.008s (~)0.863s151.00x
🐘 PostgresExpress1.156s (-8.1% 🟢)2.008s (~)0.852s151.01x
🐘 PostgresNext.js (Turbopack)1.203s2.007s0.804s151.05x
💻 LocalNext.js (Turbopack)1.324s2.006s0.682s151.16x
💻 LocalNitro1.393s (-25.4% 🟢)2.006s (-14.3% 🟢)0.613s151.22x
💻 LocalExpress1.405s (-25.8% 🟢)2.006s (-15.1% 🟢)0.600s151.23x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.604s (+5.9% 🔺)4.035s (-3.2%)1.431s81.00x
▲ VercelNext.js (Turbopack)2.746s (-6.3% 🟢)4.374s (-5.8% 🟢)1.628s71.05x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.208s (-48.4% 🟢)2.009s (-33.2% 🟢)0.802s151.00x
🐘 PostgresExpress1.210s (-48.3% 🟢)2.009s (-33.3% 🟢)0.799s151.00x
🐘 PostgresNext.js (Turbopack)1.323s2.008s0.685s151.10x
💻 LocalExpress2.041s (-34.8% 🟢)2.469s (-34.4% 🟢)0.428s131.69x
💻 LocalNext.js (Turbopack)2.061s2.826s0.766s111.71x
💻 LocalNitro2.063s (-32.7% 🟢)2.593s (-33.3% 🟢)0.530s121.71x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.297s (+2.0%)4.707s (-7.3% 🟢)1.410s71.00x
▲ VercelNext.js (Turbopack)4.012s (+27.7% 🔺)5.468s (+20.9% 🔺)1.456s61.22x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.318s (-62.3% 🟢)2.008s (-49.9% 🟢)0.689s151.00x
🐘 PostgresNitro1.326s (-61.9% 🟢)2.009s (-49.9% 🟢)0.683s151.01x
🐘 PostgresNext.js (Turbopack)1.567s2.008s0.441s151.19x
💻 LocalExpress4.705s (-46.5% 🟢)5.348s (-42.3% 🟢)0.644s63.57x
💻 LocalNitro5.421s (-40.7% 🟢)6.013s (-40.0% 🟢)0.592s54.11x
💻 LocalNext.js (Turbopack)5.757s6.215s0.458s54.37x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.000s (-1.8%)6.567s (-3.7%)1.566s51.00x
▲ VercelNext.js (Turbopack)6.349s (-6.0% 🟢)8.030s (-6.0% 🟢)1.681s41.27x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.438s (-47.8% 🟢)1.007s (-1.6%)0.569s601.00x
💻 LocalNitro0.462s (-52.9% 🟢)1.004s (-8.2% 🟢)0.541s601.06x
🐘 PostgresNitro0.483s (-41.1% 🟢)1.024s (+1.8%)0.541s591.10x
💻 LocalExpress0.511s (-48.1% 🟢)1.039s (-3.5%)0.527s581.17x
🐘 PostgresNext.js (Turbopack)0.656s1.006s0.350s601.50x
💻 LocalNext.js (Turbopack)0.757s1.004s0.247s601.73x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.423s (-75.4% 🟢)6.932s (-71.1% 🟢)1.510s91.00x
▲ VercelNext.js (Turbopack)5.556s (-61.7% 🟢)7.267s (-54.8% 🟢)1.711s91.02x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.023s (-48.2% 🟢)1.549s (-31.4% 🟢)0.526s591.00x
🐘 PostgresNitro1.091s (-43.4% 🟢)1.903s (-9.4% 🟢)0.812s481.07x
💻 LocalNitro1.166s (-61.6% 🟢)2.005s (-46.6% 🟢)0.839s451.14x
💻 LocalExpress1.203s (-60.1% 🟢)2.006s (-44.1% 🟢)0.802s451.18x
🐘 PostgresNext.js (Turbopack)1.576s2.007s0.431s451.54x
💻 LocalNext.js (Turbopack)1.842s2.051s0.209s441.80x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro12.637s (-68.0% 🟢)14.394s (-65.1% 🟢)1.757s71.00x
▲ VercelNext.js (Turbopack)13.164s (-73.6% 🟢)15.106s (-70.8% 🟢)1.942s71.04x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.994s (-50.0% 🟢)2.335s (-46.6% 🟢)0.342s521.00x
🐘 PostgresNitro2.150s (-47.6% 🟢)2.799s (-39.2% 🟢)0.649s431.08x
💻 LocalNitro2.645s (-71.6% 🟢)3.007s (-70.0% 🟢)0.362s401.33x
💻 LocalExpress2.743s (-70.2% 🟢)3.033s (-69.7% 🟢)0.290s401.38x
🐘 PostgresNext.js (Turbopack)3.120s4.010s0.890s301.56x
💻 LocalNext.js (Turbopack)3.899s4.215s0.316s291.96x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro25.288s (-73.9% 🟢)27.188s (-72.4% 🟢)1.900s51.00x
▲ VercelNext.js (Turbopack)27.396s (-74.4% 🟢)29.427s (-73.0% 🟢)2.031s51.08x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.176s (-37.8% 🟢)1.006s (~)0.829s601.00x
🐘 PostgresExpress0.180s (-36.1% 🟢)1.006s (~)0.825s601.02x
🐘 PostgresNext.js (Turbopack)0.226s1.006s0.780s601.28x
💻 LocalExpress0.392s (-30.0% 🟢)1.004s (~)0.612s602.22x
💻 LocalNitro0.395s (-34.7% 🟢)1.004s (-1.7%)0.609s602.24x
💻 LocalNext.js (Turbopack)0.549s1.004s0.455s603.11x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.378s (+43.2% 🔺)3.815s (+13.8% 🔺)1.437s161.00x
▲ VercelNext.js (Turbopack)2.893s (+43.0% 🔺)4.315s (+13.7% 🔺)1.422s141.22x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.301s (-41.0% 🟢)1.006s (~)0.705s901.00x
🐘 PostgresNitro0.305s (-38.6% 🟢)1.006s (~)0.702s901.01x
🐘 PostgresNext.js (Turbopack)0.422s1.006s0.584s901.40x
💻 LocalNitro2.135s (-15.9% 🟢)2.715s (-9.8% 🟢)0.580s347.09x
💻 LocalExpress2.178s (-13.3% 🟢)2.796s (-7.1% 🟢)0.618s337.24x
💻 LocalNext.js (Turbopack)2.381s3.010s0.630s307.91x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.031s (+56.0% 🔺)6.621s (+37.3% 🔺)1.590s141.00x
▲ VercelNext.js (Turbopack)5.780s (+63.5% 🔺)7.417s (+42.8% 🔺)1.636s131.15x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.617s (-24.7% 🟢)1.006s (-1.1%)0.389s1201.00x
🐘 PostgresNitro0.661s (-16.4% 🟢)1.006s (~)0.345s1201.07x
🐘 PostgresNext.js (Turbopack)0.866s1.023s0.157s1181.40x
💻 LocalNitro9.855s (-11.9% 🟢)10.275s (-11.9% 🟢)0.420s1215.98x
💻 LocalExpress10.320s (-7.8% 🟢)10.781s (-9.7% 🟢)0.461s1216.73x
💻 LocalNext.js (Turbopack)11.376s11.939s0.563s1118.44x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)16.735s (+62.0% 🔺)18.540s (+50.9% 🔺)1.805s71.00x
▲ VercelNitro24.069s (+211.7% 🔺)25.599s (+172.3% 🔺)1.529s51.44x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro1.130s (+428.7% 🔺)2.005s (+99.6% 🔺)0.010s (-18.4% 🟢)2.017s (+98.0% 🔺)0.887s101.00x
💻 LocalExpress1.142s (+473.5% 🔺)2.005s (+99.6% 🔺)0.011s (-13.2% 🟢)2.018s (+98.2% 🔺)0.876s101.01x
🐘 PostgresExpress1.145s (+458.5% 🔺)1.997s (+99.9% 🔺)0.001s (-31.3% 🟢)2.010s (+98.7% 🔺)0.864s101.01x
🐘 PostgresNitro1.159s (+465.3% 🔺)2.002s (+100.2% 🔺)0.002s (+13.3% 🔺)2.011s (+98.9% 🔺)0.852s101.03x
💻 LocalNext.js (Turbopack)1.180s2.004s0.011s2.018s0.839s101.04x
🐘 PostgresNext.js (Turbopack)1.196s2.002s0.002s2.010s0.814s101.06x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.306s (-66.4% 🟢)3.176s (-63.3% 🟢)2.309s (+265.5% 🔺)5.934s (-39.4% 🟢)3.628s101.00x
▲ VercelNitro2.375s (-38.0% 🟢)3.202s (-39.3% 🟢)14.034s (+1791.1% 🔺)17.640s (+172.1% 🔺)15.265s101.03x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.519s (+141.2% 🔺)2.003s (+99.0% 🔺)0.004s (+1.8%)2.026s (+98.0% 🔺)0.506s301.00x
🐘 PostgresNitro1.566s (+150.9% 🔺)2.008s (+99.5% 🔺)0.004s (-1.7%)2.025s (+98.1% 🔺)0.459s301.03x
🐘 PostgresNext.js (Turbopack)1.652s2.010s0.004s2.026s0.374s301.09x
💻 LocalNitro1.694s (+102.0% 🔺)2.010s (+98.6% 🔺)0.009s (-2.4%)2.200s (+97.2% 🔺)0.507s281.11x
💻 LocalExpress1.703s (+125.0% 🔺)2.010s (+95.4% 🔺)0.011s (+12.7% 🔺)2.202s (+111.8% 🔺)0.499s281.12x
💻 LocalNext.js (Turbopack)1.871s2.011s0.009s2.203s0.332s281.23x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)5.720s (-66.2% 🟢)6.903s (-62.2% 🟢)0.256s (+20.9% 🔺)7.611s (-59.8% 🟢)1.891s81.00x
▲ VercelNitro5.938s (-79.8% 🟢)7.093s (-77.0% 🟢)0.210s (+87.5% 🔺)7.760s (-75.6% 🟢)1.823s81.04x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.651s (-32.3% 🟢)1.027s (-19.6% 🟢)0.000s (-61.0% 🟢)1.040s (-20.4% 🟢)0.390s591.00x
🐘 PostgresNitro0.674s (-30.4% 🟢)1.070s (-14.2% 🟢)0.000s (-15.8% 🟢)1.077s (-14.3% 🟢)0.403s571.04x
🐘 PostgresNext.js (Turbopack)0.750s1.035s0.000s1.042s0.292s581.15x
💻 LocalNitro1.341s (+9.7% 🔺)2.015s (~)0.001s (+433.3% 🔺)2.018s (~)0.677s302.06x
💻 LocalExpress1.376s (+12.3% 🔺)2.015s (~)0.000s (~)2.018s (~)0.642s302.11x
💻 LocalNext.js (Turbopack)1.482s2.014s0.000s2.017s0.535s302.28x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.886s (-61.8% 🟢)4.959s (-56.9% 🟢)0.002s (+Infinity% 🔺)5.651s (-53.1% 🟢)1.766s111.00x
▲ VercelNitro4.900s (+60.6% 🔺)6.154s (+40.1% 🔺)0.000s (-100.0% 🟢)6.747s (+40.3% 🔺)1.847s91.26x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

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

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.289s (-27.2% 🟢)2.065s (-5.2% 🟢)0.000s (+Infinity% 🔺)2.079s (-5.4% 🟢)0.790s291.00x
🐘 PostgresNitro1.338s (-25.3% 🟢)2.068s (-3.4%)0.000s (+189.7% 🔺)2.079s (-4.4%)0.742s291.04x
🐘 PostgresNext.js (Turbopack)1.566s2.181s0.000s2.203s0.637s281.21x
💻 LocalNitro3.063s (-9.6% 🟢)3.677s (-8.8% 🟢)0.000s (-55.9% 🟢)3.680s (-8.8% 🟢)0.617s172.38x
💻 LocalExpress3.172s (-8.5% 🟢)3.904s (-3.2%)0.001s (-37.5% 🟢)3.908s (-3.2%)0.736s162.46x
💻 LocalNext.js (Turbopack)3.190s3.337s0.001s3.612s0.422s192.47x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.807s (+41.8% 🔺)7.135s (+32.8% 🔺)0.000s (+37.5% 🔺)7.605s (+31.3% 🔺)1.798s81.00x
▲ VercelNext.js (Turbopack)6.464s (+15.1% 🔺)7.633s (+9.3% 🔺)0.000s (-100.0% 🟢)8.268s (+9.6% 🔺)1.804s81.11x
▲ VercelExpress⚠️missing-----

🔍 Observability: Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNitro16/21
🐘 PostgresExpress16/21
▲ VercelNitro12/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres16/21
Next.js (Turbopack)🐘 Postgres15/21
Nitro🐘 Postgres13/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.

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

Should we memoize the getWritable() return value instead? Seems simpler to me.

@VaguelySerious

Copy link
Copy Markdown
MemberAuthor

@TooTallNate doing now

Drop the per-call guard mechanism and `pollSharedWritableLock` helper.
With memoization, the cached pipe's single `state.promise` is sufficient:
in the loop pattern `await writer.write(chunk)` already blocks until each
chunk is flushed (the sink awaits the scheduled flush before resolving),
so by the time the loop ends `pendingOps === 0` and the final
`releaseLock()` lets `pollWritableLock` resolve the state. One writable,
one pipe, one ops entry per `(runId, namespace)`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@TooTallNateTooTallNate 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 — clean fix, my earlier "memoize the return value" suggestion landed in 40106be78

The bug is real and the fix matches the issue exactly: caching serialize.writable per (runId, namespace) on the step context so repeat calls within the same step go through one serial sink instead of spawning racing pipes that fight for the same (runId, name) server stream.

What I verified

  • pnpm install --frozen-lockfile
  • pnpm turbo run build --filter @workflow/core
  • pnpm --filter @workflow/core test ✓ (1013/1013)
  • cd packages/core && pnpm exec vitest run src/step/writable-stream.test.ts ✓ (5/5 — 2 existing + 3 new)
  • CI: 65 E2E jobs pass, Unit Tests pass, Windows Tests pass, single Benchmark Vercel (express) flake unrelated to this change

Test coverage looks great

The three new tests pin the contract from three angles:

  1. returns the same writable for repeat calls with the same namespace — direct identity assertion (and distinct namespaces still get distinct writables)
  2. preserves chunk order across per-write getWritable() calls in a loop — exact repro of the issue's 'nov', 'o', ' e', '2', 'e', ' ok' scenario, decoded through the matching deserialize stream
  3. registers exactly one pipe per (runId, namespace), regardless of call count — asserts ops.length === 2 (one shared + one for the distinct namespace)

The chunk-order test is particularly good because it goes through the full serialize → record → deserialize round trip rather than asserting against mock internals.

Per-call ctx.ops guards — no longer needed

The simplification from 0d076e0c40106be78 correctly drops per-caller ops registration. Since all callers now share one state.promise, that one promise already covers every writer that holds the lock — when any writer has the lock the pipe is active, when none do the state resolves. The "wait for every caller's writes to drain" semantics fall out for free.

One subtle behavior change worth calling out

A user who does getWritable().close() and then calls getWritable() again will now get back the same closed writable instead of a fresh one. Pre-PR, the second call would have created a new writable they could continue writing to. Post-PR, that second call returns a closed handle.

I don't think anyone is actually depending on this — the issue describes the bug as users not realizing re-acquisition was unsafe, so "close then re-acquire to keep writing" is unlikely to be a real pattern. The AI SDK / per-chunk-loop case (which IS the motivating example) only does releaseLock(), not close(). So this should be fine in practice. Worth mentioning in case anyone hits it post-release.

Branch hygiene note (not a blocker)

The branch is forked from a point before #2091 landed, so the GitHub diff against current main shows misleading "deletions" of .changeset/changelog.mjs, .changeset/config.json's changelog generator reference, and 5 other unrelated changesets. Those are all artifacts of being stale — the PR's actual tree doesn't have those files at all (because they were added on main after the branch point), so a fresh sync with main would make the diff display correctly. Squash-merge will produce a clean single commit regardless, so this doesn't affect what lands. Just flagging because the github UI looks scary at first glance.

Approving.

@VaguelySerious
VaguelySerious merged commit 2050656 into mainMay 23, 2026
185 of 192 checks passed
@VaguelySerious
VaguelySerious deleted the peter/issue-2058 branch May 23, 2026 07:52
@github-actionsgithub-actionsBot mentioned this pull request May 28, 2026
Sign up for freeto 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.

getWritable() returns a new TransformStream per call — racing pipes reorder chunks when callers acquire per-write

2 participants

@VaguelySerious@TooTallNate
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [core] Memoize `getWritable()` to fix chunk reordering when called repeatedly by VaguelySerious · Pull Request #2086 · vercel/workflow · GitHub
Skip to content

[core] Memoize getWritable() to fix chunk reordering when called repeatedly - #2086

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/issue-2058
May 23, 2026
Merged

[core] Memoize getWritable() to fix chunk reordering when called repeatedly#2086
VaguelySerious merged 2 commits into
mainfrom
peter/issue-2058

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented May 22, 2026

Copy link
Copy Markdown
Member

Fixes#2058, where calling getWritable multiple times in the same function causes chunk re-ordering without any user-facing warnings. I'm not sure if this is the right approach or we should just throw an error or warn.

The issue

Each call to getWritable() constructed a fresh TransformStream + WorkflowServerWritableStream + background flushablePipe(...), all flushing to the same (runId, name). On Vercel the 50-100ms HTTP write latency turned the inter-pipe race into deterministic reordering — most visibly when callers acquired a fresh writer per chunk (e.g. an AI SDK text-delta loop). Locally world-local made it look fine because filesystem writes are effectively instant.

The fix

  • Share a single TransformStream + flushablePipe per (runId, namespace) within a step context so repeat calls to getWritable() no longer spawn racing pipes.
  • Add per-call guards on ctx.ops so the step still waits for every caller's writes to drain.

Repeat calls to `getWritable()` from the same step previously spawned
independent TransformStream + flushablePipe pairs that all flushed to
the same (runId, name) on the server. On Vercel the 50-100ms HTTP write
latency turned the inter-pipe race into deterministic reordering — most
visibly when callers acquired a fresh writer per chunk (e.g. an AI SDK
text-delta loop). Locally the world-local filesystem path made the race
invisible.
Cache the writable + pipe state per (runId, namespace) in the step
context so repeat calls share one serial sink. Each call still registers
a per-call guard in ctx.ops so the step waits for every caller's writes
to flush before completing, matching the prior multi-pipe semantics.
Closes#2058
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel

vercelBot commented May 22, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented May 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 40106be

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

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
workflowPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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-actionsBot commented May 22, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production122202191441
✅ 💻 Local Development161502191834
✅ 📦 Local Production161502191834
✅ 🐘 Local Postgres161502191834
✅ 🪟 Windows13100131
✅ 📋 Other7410176917
Total6939010527991

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro105026
✅ example105026
✅ express105026
✅ fastify105026
✅ hono105026
✅ nextjs-turbopack12902
✅ nextjs-webpack12902
✅ nitro105026
✅ nuxt105026
✅ sveltekit12407
✅ vite105026
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable106025
✅ express-stable106025
✅ fastify-stable106025
✅ hono-stable106025
✅ nextjs-turbopack-canary112019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13100
✅ nextjs-turbopack-stable-lazy-discovery-enabled13100
✅ nextjs-webpack-canary112019
✅ nextjs-webpack-stable-lazy-discovery-disabled13100
✅ nextjs-webpack-stable-lazy-discovery-enabled13100
✅ nitro-stable106025
✅ nuxt-stable106025
✅ sveltekit-stable12506
✅ vite-stable106025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable106025
✅ express-stable106025
✅ fastify-stable106025
✅ hono-stable106025
✅ nextjs-turbopack-canary112019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13100
✅ nextjs-turbopack-stable-lazy-discovery-enabled13100
✅ nextjs-webpack-canary112019
✅ nextjs-webpack-stable-lazy-discovery-disabled13100
✅ nextjs-webpack-stable-lazy-discovery-enabled13100
✅ nitro-stable106025
✅ nuxt-stable106025
✅ sveltekit-stable12506
✅ vite-stable106025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable106025
✅ express-stable106025
✅ fastify-stable106025
✅ hono-stable106025
✅ nextjs-turbopack-canary112019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13100
✅ nextjs-turbopack-stable-lazy-discovery-enabled13100
✅ nextjs-webpack-canary112019
✅ nextjs-webpack-stable-lazy-discovery-disabled13100
✅ nextjs-webpack-stable-lazy-discovery-enabled13100
✅ nitro-stable106025
✅ nuxt-stable106025
✅ sveltekit-stable12506
✅ vite-stable106025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack13100
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable106025
✅ e2e-local-dev-tanstack-start-106025
✅ e2e-local-postgres-nest-stable106025
✅ e2e-local-postgres-tanstack-start-106025
✅ e2e-local-prod-nest-stable106025
✅ e2e-local-prod-tanstack-start-106025
✅ e2e-vercel-prod-tanstack-start105026

📋 View full workflow run

@github-actions

github-actionsBot commented May 22, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

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

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro0.029s (-32.7% 🟢)1.005s (~)0.976s101.00x
💻 LocalExpress0.030s (-32.3% 🟢)1.006s (~)0.976s101.03x
💻 LocalNext.js (Turbopack)0.049s1.006s0.957s101.69x
🐘 PostgresNitro0.050s (-47.6% 🟢)1.012s (-3.0%)0.962s101.72x
🐘 PostgresExpress0.051s (-11.9% 🟢)1.012s (~)0.961s101.76x
🐘 PostgresNext.js (Turbopack)0.055s1.010s0.955s101.89x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.260s (+3.2%)2.424s (+3.9%)2.165s101.00x
▲ VercelNitro0.274s (-33.1% 🟢)2.158s (-14.0% 🟢)1.885s101.06x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.066s (-5.3% 🟢)2.006s (~)0.940s101.00x
💻 LocalNitro1.069s (-5.5% 🟢)2.006s (~)0.937s101.00x
🐘 PostgresExpress1.083s (-5.5% 🟢)2.009s (~)0.926s101.02x
🐘 PostgresNitro1.086s (-4.7%)2.009s (~)0.923s101.02x
💻 LocalNext.js (Turbopack)1.107s2.006s0.899s101.04x
🐘 PostgresNext.js (Turbopack)1.123s2.009s0.886s101.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)1.590s (-21.9% 🟢)3.378s (-11.8% 🟢)1.788s101.00x
▲ VercelNitro1.608s (-58.7% 🟢)3.228s (-45.4% 🟢)1.620s101.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro10.397s (-5.0% 🟢)11.022s (~)0.625s31.00x
💻 LocalExpress10.408s (-4.7%)11.023s (~)0.615s31.00x
🐘 PostgresExpress10.413s (-5.0% 🟢)11.013s (~)0.600s31.00x
🐘 PostgresNitro10.451s (-3.9%)11.021s (~)0.570s31.01x
💻 LocalNext.js (Turbopack)10.660s11.022s0.363s31.03x
🐘 PostgresNext.js (Turbopack)10.710s11.017s0.307s31.03x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro13.267s (-44.1% 🟢)14.695s (-41.5% 🟢)1.428s31.00x
▲ VercelNext.js (Turbopack)13.473s (-22.2% 🟢)14.947s (-22.9% 🟢)1.474s31.02x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro13.429s (-10.8% 🟢)14.027s (-12.5% 🟢)0.598s51.00x
💻 LocalExpress13.457s (-10.1% 🟢)14.028s (-6.7% 🟢)0.571s51.00x
🐘 PostgresNitro13.469s (-7.7% 🟢)14.019s (-6.7% 🟢)0.550s51.00x
🐘 PostgresExpress13.471s (-7.6% 🟢)14.018s (-6.7% 🟢)0.547s51.00x
💻 LocalNext.js (Turbopack)14.075s15.031s0.956s41.05x
🐘 PostgresNext.js (Turbopack)14.118s15.016s0.898s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro21.328s (-66.9% 🟢)22.835s (-65.7% 🟢)1.507s31.00x
▲ VercelNext.js (Turbopack)22.554s (-57.1% 🟢)24.075s (-55.9% 🟢)1.521s31.06x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro11.788s (-29.8% 🟢)12.022s (-29.4% 🟢)0.234s81.00x
🐘 PostgresExpress11.906s (-15.0% 🟢)12.145s (-16.8% 🟢)0.239s81.01x
💻 LocalExpress11.932s (-28.1% 🟢)12.150s (-28.7% 🟢)0.218s81.01x
🐘 PostgresNitro11.972s (-14.3% 🟢)12.268s (-14.3% 🟢)0.296s81.02x
💻 LocalNext.js (Turbopack)13.074s14.028s0.954s71.11x
🐘 PostgresNext.js (Turbopack)13.117s13.733s0.616s71.11x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro31.064s (-92.7% 🟢)32.780s (-92.3% 🟢)1.716s31.00x
▲ VercelNext.js (Turbopack)34.306s (-91.3% 🟢)36.360s (-90.8% 🟢)2.055s31.10x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.137s (-9.8% 🟢)2.008s (~)0.871s151.00x
💻 LocalNitro1.165s (-28.6% 🟢)2.006s (-3.3%)0.841s151.02x
💻 LocalExpress1.174s (-21.1% 🟢)2.006s (~)0.832s151.03x
🐘 PostgresNitro1.184s (-7.1% 🟢)2.007s (~)0.823s151.04x
🐘 PostgresNext.js (Turbopack)1.202s2.007s0.805s151.06x
💻 LocalNext.js (Turbopack)1.285s2.006s0.721s151.13x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.789s (-17.9% 🟢)4.385s (-11.1% 🟢)1.596s71.00x
▲ VercelNitro2.886s (+2.4%)4.249s (-1.7%)1.363s81.03x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.196s (-49.4% 🟢)2.007s (-33.3% 🟢)0.811s151.00x
🐘 PostgresNitro1.216s (-48.3% 🟢)2.007s (-33.3% 🟢)0.791s151.02x
🐘 PostgresNext.js (Turbopack)1.349s2.008s0.659s151.13x
💻 LocalNitro1.677s (-46.6% 🟢)2.006s (-48.4% 🟢)0.328s151.40x
💻 LocalNext.js (Turbopack)1.680s2.006s0.326s151.40x
💻 LocalExpress1.712s (-42.0% 🟢)2.006s (-41.9% 🟢)0.294s151.43x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.701s (-47.9% 🟢)5.327s (-40.2% 🟢)1.625s61.00x
▲ VercelNitro3.953s (-2.5%)5.162s (-12.8% 🟢)1.210s61.07x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.329s (-61.9% 🟢)2.008s (-49.9% 🟢)0.679s151.00x
🐘 PostgresNitro1.346s (-61.3% 🟢)2.006s (-49.9% 🟢)0.660s151.01x
🐘 PostgresNext.js (Turbopack)1.584s2.007s0.423s151.19x
💻 LocalNitro4.494s (-46.2% 🟢)5.011s (-44.5% 🟢)0.516s73.38x
💻 LocalNext.js (Turbopack)4.764s5.178s0.414s63.58x
💻 LocalExpress4.815s (-42.3% 🟢)5.179s (-42.6% 🟢)0.364s63.62x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)5.312s (-40.4% 🟢)7.208s (-34.2% 🟢)1.896s51.00x
▲ VercelNitro6.035s (+71.2% 🔺)7.638s (+38.0% 🔺)1.603s41.14x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.145s (-8.9% 🟢)2.008s (~)0.863s151.00x
🐘 PostgresExpress1.156s (-8.1% 🟢)2.008s (~)0.852s151.01x
🐘 PostgresNext.js (Turbopack)1.203s2.007s0.804s151.05x
💻 LocalNext.js (Turbopack)1.324s2.006s0.682s151.16x
💻 LocalNitro1.393s (-25.4% 🟢)2.006s (-14.3% 🟢)0.613s151.22x
💻 LocalExpress1.405s (-25.8% 🟢)2.006s (-15.1% 🟢)0.600s151.23x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.604s (+5.9% 🔺)4.035s (-3.2%)1.431s81.00x
▲ VercelNext.js (Turbopack)2.746s (-6.3% 🟢)4.374s (-5.8% 🟢)1.628s71.05x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.208s (-48.4% 🟢)2.009s (-33.2% 🟢)0.802s151.00x
🐘 PostgresExpress1.210s (-48.3% 🟢)2.009s (-33.3% 🟢)0.799s151.00x
🐘 PostgresNext.js (Turbopack)1.323s2.008s0.685s151.10x
💻 LocalExpress2.041s (-34.8% 🟢)2.469s (-34.4% 🟢)0.428s131.69x
💻 LocalNext.js (Turbopack)2.061s2.826s0.766s111.71x
💻 LocalNitro2.063s (-32.7% 🟢)2.593s (-33.3% 🟢)0.530s121.71x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.297s (+2.0%)4.707s (-7.3% 🟢)1.410s71.00x
▲ VercelNext.js (Turbopack)4.012s (+27.7% 🔺)5.468s (+20.9% 🔺)1.456s61.22x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.318s (-62.3% 🟢)2.008s (-49.9% 🟢)0.689s151.00x
🐘 PostgresNitro1.326s (-61.9% 🟢)2.009s (-49.9% 🟢)0.683s151.01x
🐘 PostgresNext.js (Turbopack)1.567s2.008s0.441s151.19x
💻 LocalExpress4.705s (-46.5% 🟢)5.348s (-42.3% 🟢)0.644s63.57x
💻 LocalNitro5.421s (-40.7% 🟢)6.013s (-40.0% 🟢)0.592s54.11x
💻 LocalNext.js (Turbopack)5.757s6.215s0.458s54.37x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.000s (-1.8%)6.567s (-3.7%)1.566s51.00x
▲ VercelNext.js (Turbopack)6.349s (-6.0% 🟢)8.030s (-6.0% 🟢)1.681s41.27x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.438s (-47.8% 🟢)1.007s (-1.6%)0.569s601.00x
💻 LocalNitro0.462s (-52.9% 🟢)1.004s (-8.2% 🟢)0.541s601.06x
🐘 PostgresNitro0.483s (-41.1% 🟢)1.024s (+1.8%)0.541s591.10x
💻 LocalExpress0.511s (-48.1% 🟢)1.039s (-3.5%)0.527s581.17x
🐘 PostgresNext.js (Turbopack)0.656s1.006s0.350s601.50x
💻 LocalNext.js (Turbopack)0.757s1.004s0.247s601.73x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.423s (-75.4% 🟢)6.932s (-71.1% 🟢)1.510s91.00x
▲ VercelNext.js (Turbopack)5.556s (-61.7% 🟢)7.267s (-54.8% 🟢)1.711s91.02x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.023s (-48.2% 🟢)1.549s (-31.4% 🟢)0.526s591.00x
🐘 PostgresNitro1.091s (-43.4% 🟢)1.903s (-9.4% 🟢)0.812s481.07x
💻 LocalNitro1.166s (-61.6% 🟢)2.005s (-46.6% 🟢)0.839s451.14x
💻 LocalExpress1.203s (-60.1% 🟢)2.006s (-44.1% 🟢)0.802s451.18x
🐘 PostgresNext.js (Turbopack)1.576s2.007s0.431s451.54x
💻 LocalNext.js (Turbopack)1.842s2.051s0.209s441.80x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro12.637s (-68.0% 🟢)14.394s (-65.1% 🟢)1.757s71.00x
▲ VercelNext.js (Turbopack)13.164s (-73.6% 🟢)15.106s (-70.8% 🟢)1.942s71.04x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.994s (-50.0% 🟢)2.335s (-46.6% 🟢)0.342s521.00x
🐘 PostgresNitro2.150s (-47.6% 🟢)2.799s (-39.2% 🟢)0.649s431.08x
💻 LocalNitro2.645s (-71.6% 🟢)3.007s (-70.0% 🟢)0.362s401.33x
💻 LocalExpress2.743s (-70.2% 🟢)3.033s (-69.7% 🟢)0.290s401.38x
🐘 PostgresNext.js (Turbopack)3.120s4.010s0.890s301.56x
💻 LocalNext.js (Turbopack)3.899s4.215s0.316s291.96x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro25.288s (-73.9% 🟢)27.188s (-72.4% 🟢)1.900s51.00x
▲ VercelNext.js (Turbopack)27.396s (-74.4% 🟢)29.427s (-73.0% 🟢)2.031s51.08x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.176s (-37.8% 🟢)1.006s (~)0.829s601.00x
🐘 PostgresExpress0.180s (-36.1% 🟢)1.006s (~)0.825s601.02x
🐘 PostgresNext.js (Turbopack)0.226s1.006s0.780s601.28x
💻 LocalExpress0.392s (-30.0% 🟢)1.004s (~)0.612s602.22x
💻 LocalNitro0.395s (-34.7% 🟢)1.004s (-1.7%)0.609s602.24x
💻 LocalNext.js (Turbopack)0.549s1.004s0.455s603.11x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.378s (+43.2% 🔺)3.815s (+13.8% 🔺)1.437s161.00x
▲ VercelNext.js (Turbopack)2.893s (+43.0% 🔺)4.315s (+13.7% 🔺)1.422s141.22x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.301s (-41.0% 🟢)1.006s (~)0.705s901.00x
🐘 PostgresNitro0.305s (-38.6% 🟢)1.006s (~)0.702s901.01x
🐘 PostgresNext.js (Turbopack)0.422s1.006s0.584s901.40x
💻 LocalNitro2.135s (-15.9% 🟢)2.715s (-9.8% 🟢)0.580s347.09x
💻 LocalExpress2.178s (-13.3% 🟢)2.796s (-7.1% 🟢)0.618s337.24x
💻 LocalNext.js (Turbopack)2.381s3.010s0.630s307.91x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.031s (+56.0% 🔺)6.621s (+37.3% 🔺)1.590s141.00x
▲ VercelNext.js (Turbopack)5.780s (+63.5% 🔺)7.417s (+42.8% 🔺)1.636s131.15x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.617s (-24.7% 🟢)1.006s (-1.1%)0.389s1201.00x
🐘 PostgresNitro0.661s (-16.4% 🟢)1.006s (~)0.345s1201.07x
🐘 PostgresNext.js (Turbopack)0.866s1.023s0.157s1181.40x
💻 LocalNitro9.855s (-11.9% 🟢)10.275s (-11.9% 🟢)0.420s1215.98x
💻 LocalExpress10.320s (-7.8% 🟢)10.781s (-9.7% 🟢)0.461s1216.73x
💻 LocalNext.js (Turbopack)11.376s11.939s0.563s1118.44x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)16.735s (+62.0% 🔺)18.540s (+50.9% 🔺)1.805s71.00x
▲ VercelNitro24.069s (+211.7% 🔺)25.599s (+172.3% 🔺)1.529s51.44x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro1.130s (+428.7% 🔺)2.005s (+99.6% 🔺)0.010s (-18.4% 🟢)2.017s (+98.0% 🔺)0.887s101.00x
💻 LocalExpress1.142s (+473.5% 🔺)2.005s (+99.6% 🔺)0.011s (-13.2% 🟢)2.018s (+98.2% 🔺)0.876s101.01x
🐘 PostgresExpress1.145s (+458.5% 🔺)1.997s (+99.9% 🔺)0.001s (-31.3% 🟢)2.010s (+98.7% 🔺)0.864s101.01x
🐘 PostgresNitro1.159s (+465.3% 🔺)2.002s (+100.2% 🔺)0.002s (+13.3% 🔺)2.011s (+98.9% 🔺)0.852s101.03x
💻 LocalNext.js (Turbopack)1.180s2.004s0.011s2.018s0.839s101.04x
🐘 PostgresNext.js (Turbopack)1.196s2.002s0.002s2.010s0.814s101.06x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.306s (-66.4% 🟢)3.176s (-63.3% 🟢)2.309s (+265.5% 🔺)5.934s (-39.4% 🟢)3.628s101.00x
▲ VercelNitro2.375s (-38.0% 🟢)3.202s (-39.3% 🟢)14.034s (+1791.1% 🔺)17.640s (+172.1% 🔺)15.265s101.03x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.519s (+141.2% 🔺)2.003s (+99.0% 🔺)0.004s (+1.8%)2.026s (+98.0% 🔺)0.506s301.00x
🐘 PostgresNitro1.566s (+150.9% 🔺)2.008s (+99.5% 🔺)0.004s (-1.7%)2.025s (+98.1% 🔺)0.459s301.03x
🐘 PostgresNext.js (Turbopack)1.652s2.010s0.004s2.026s0.374s301.09x
💻 LocalNitro1.694s (+102.0% 🔺)2.010s (+98.6% 🔺)0.009s (-2.4%)2.200s (+97.2% 🔺)0.507s281.11x
💻 LocalExpress1.703s (+125.0% 🔺)2.010s (+95.4% 🔺)0.011s (+12.7% 🔺)2.202s (+111.8% 🔺)0.499s281.12x
💻 LocalNext.js (Turbopack)1.871s2.011s0.009s2.203s0.332s281.23x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)5.720s (-66.2% 🟢)6.903s (-62.2% 🟢)0.256s (+20.9% 🔺)7.611s (-59.8% 🟢)1.891s81.00x
▲ VercelNitro5.938s (-79.8% 🟢)7.093s (-77.0% 🟢)0.210s (+87.5% 🔺)7.760s (-75.6% 🟢)1.823s81.04x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.651s (-32.3% 🟢)1.027s (-19.6% 🟢)0.000s (-61.0% 🟢)1.040s (-20.4% 🟢)0.390s591.00x
🐘 PostgresNitro0.674s (-30.4% 🟢)1.070s (-14.2% 🟢)0.000s (-15.8% 🟢)1.077s (-14.3% 🟢)0.403s571.04x
🐘 PostgresNext.js (Turbopack)0.750s1.035s0.000s1.042s0.292s581.15x
💻 LocalNitro1.341s (+9.7% 🔺)2.015s (~)0.001s (+433.3% 🔺)2.018s (~)0.677s302.06x
💻 LocalExpress1.376s (+12.3% 🔺)2.015s (~)0.000s (~)2.018s (~)0.642s302.11x
💻 LocalNext.js (Turbopack)1.482s2.014s0.000s2.017s0.535s302.28x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.886s (-61.8% 🟢)4.959s (-56.9% 🟢)0.002s (+Infinity% 🔺)5.651s (-53.1% 🟢)1.766s111.00x
▲ VercelNitro4.900s (+60.6% 🔺)6.154s (+40.1% 🔺)0.000s (-100.0% 🟢)6.747s (+40.3% 🔺)1.847s91.26x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

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

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.289s (-27.2% 🟢)2.065s (-5.2% 🟢)0.000s (+Infinity% 🔺)2.079s (-5.4% 🟢)0.790s291.00x
🐘 PostgresNitro1.338s (-25.3% 🟢)2.068s (-3.4%)0.000s (+189.7% 🔺)2.079s (-4.4%)0.742s291.04x
🐘 PostgresNext.js (Turbopack)1.566s2.181s0.000s2.203s0.637s281.21x
💻 LocalNitro3.063s (-9.6% 🟢)3.677s (-8.8% 🟢)0.000s (-55.9% 🟢)3.680s (-8.8% 🟢)0.617s172.38x
💻 LocalExpress3.172s (-8.5% 🟢)3.904s (-3.2%)0.001s (-37.5% 🟢)3.908s (-3.2%)0.736s162.46x
💻 LocalNext.js (Turbopack)3.190s3.337s0.001s3.612s0.422s192.47x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.807s (+41.8% 🔺)7.135s (+32.8% 🔺)0.000s (+37.5% 🔺)7.605s (+31.3% 🔺)1.798s81.00x
▲ VercelNext.js (Turbopack)6.464s (+15.1% 🔺)7.633s (+9.3% 🔺)0.000s (-100.0% 🟢)8.268s (+9.6% 🔺)1.804s81.11x
▲ VercelExpress⚠️missing-----

🔍 Observability: Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNitro16/21
🐘 PostgresExpress16/21
▲ VercelNitro12/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres16/21
Next.js (Turbopack)🐘 Postgres15/21
Nitro🐘 Postgres13/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.

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

Should we memoize the getWritable() return value instead? Seems simpler to me.

@VaguelySerious

Copy link
Copy Markdown
MemberAuthor

@TooTallNate doing now

Drop the per-call guard mechanism and `pollSharedWritableLock` helper.
With memoization, the cached pipe's single `state.promise` is sufficient:
in the loop pattern `await writer.write(chunk)` already blocks until each
chunk is flushed (the sink awaits the scheduled flush before resolving),
so by the time the loop ends `pendingOps === 0` and the final
`releaseLock()` lets `pollWritableLock` resolve the state. One writable,
one pipe, one ops entry per `(runId, namespace)`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@TooTallNateTooTallNate 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 — clean fix, my earlier "memoize the return value" suggestion landed in 40106be78

The bug is real and the fix matches the issue exactly: caching serialize.writable per (runId, namespace) on the step context so repeat calls within the same step go through one serial sink instead of spawning racing pipes that fight for the same (runId, name) server stream.

What I verified

  • pnpm install --frozen-lockfile
  • pnpm turbo run build --filter @workflow/core
  • pnpm --filter @workflow/core test ✓ (1013/1013)
  • cd packages/core && pnpm exec vitest run src/step/writable-stream.test.ts ✓ (5/5 — 2 existing + 3 new)
  • CI: 65 E2E jobs pass, Unit Tests pass, Windows Tests pass, single Benchmark Vercel (express) flake unrelated to this change

Test coverage looks great

The three new tests pin the contract from three angles:

  1. returns the same writable for repeat calls with the same namespace — direct identity assertion (and distinct namespaces still get distinct writables)
  2. preserves chunk order across per-write getWritable() calls in a loop — exact repro of the issue's 'nov', 'o', ' e', '2', 'e', ' ok' scenario, decoded through the matching deserialize stream
  3. registers exactly one pipe per (runId, namespace), regardless of call count — asserts ops.length === 2 (one shared + one for the distinct namespace)

The chunk-order test is particularly good because it goes through the full serialize → record → deserialize round trip rather than asserting against mock internals.

Per-call ctx.ops guards — no longer needed

The simplification from 0d076e0c40106be78 correctly drops per-caller ops registration. Since all callers now share one state.promise, that one promise already covers every writer that holds the lock — when any writer has the lock the pipe is active, when none do the state resolves. The "wait for every caller's writes to drain" semantics fall out for free.

One subtle behavior change worth calling out

A user who does getWritable().close() and then calls getWritable() again will now get back the same closed writable instead of a fresh one. Pre-PR, the second call would have created a new writable they could continue writing to. Post-PR, that second call returns a closed handle.

I don't think anyone is actually depending on this — the issue describes the bug as users not realizing re-acquisition was unsafe, so "close then re-acquire to keep writing" is unlikely to be a real pattern. The AI SDK / per-chunk-loop case (which IS the motivating example) only does releaseLock(), not close(). So this should be fine in practice. Worth mentioning in case anyone hits it post-release.

Branch hygiene note (not a blocker)

The branch is forked from a point before #2091 landed, so the GitHub diff against current main shows misleading "deletions" of .changeset/changelog.mjs, .changeset/config.json's changelog generator reference, and 5 other unrelated changesets. Those are all artifacts of being stale — the PR's actual tree doesn't have those files at all (because they were added on main after the branch point), so a fresh sync with main would make the diff display correctly. Squash-merge will produce a clean single commit regardless, so this doesn't affect what lands. Just flagging because the github UI looks scary at first glance.

Approving.

@VaguelySerious
VaguelySerious merged commit 2050656 into mainMay 23, 2026
185 of 192 checks passed
@VaguelySerious
VaguelySerious deleted the peter/issue-2058 branch May 23, 2026 07:52
@github-actionsgithub-actionsBot mentioned this pull request May 28, 2026
Sign up for freeto 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.

getWritable() returns a new TransformStream per call — racing pipes reorder chunks when callers acquire per-write

2 participants

@VaguelySerious@TooTallNate
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [core] Memoize `getWritable()` to fix chunk reordering when called repeatedly by VaguelySerious · Pull Request #2086 · vercel/workflow · GitHub
Skip to content

[core] Memoize getWritable() to fix chunk reordering when called repeatedly - #2086

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/issue-2058
May 23, 2026
Merged

[core] Memoize getWritable() to fix chunk reordering when called repeatedly#2086
VaguelySerious merged 2 commits into
mainfrom
peter/issue-2058

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented May 22, 2026

Copy link
Copy Markdown
Member

Fixes#2058, where calling getWritable multiple times in the same function causes chunk re-ordering without any user-facing warnings. I'm not sure if this is the right approach or we should just throw an error or warn.

The issue

Each call to getWritable() constructed a fresh TransformStream + WorkflowServerWritableStream + background flushablePipe(...), all flushing to the same (runId, name). On Vercel the 50-100ms HTTP write latency turned the inter-pipe race into deterministic reordering — most visibly when callers acquired a fresh writer per chunk (e.g. an AI SDK text-delta loop). Locally world-local made it look fine because filesystem writes are effectively instant.

The fix

  • Share a single TransformStream + flushablePipe per (runId, namespace) within a step context so repeat calls to getWritable() no longer spawn racing pipes.
  • Add per-call guards on ctx.ops so the step still waits for every caller's writes to drain.

Repeat calls to `getWritable()` from the same step previously spawned
independent TransformStream + flushablePipe pairs that all flushed to
the same (runId, name) on the server. On Vercel the 50-100ms HTTP write
latency turned the inter-pipe race into deterministic reordering — most
visibly when callers acquired a fresh writer per chunk (e.g. an AI SDK
text-delta loop). Locally the world-local filesystem path made the race
invisible.
Cache the writable + pipe state per (runId, namespace) in the step
context so repeat calls share one serial sink. Each call still registers
a per-call guard in ctx.ops so the step waits for every caller's writes
to flush before completing, matching the prior multi-pipe semantics.
Closes#2058
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel

vercelBot commented May 22, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented May 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 40106be

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

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
workflowPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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-actionsBot commented May 22, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production122202191441
✅ 💻 Local Development161502191834
✅ 📦 Local Production161502191834
✅ 🐘 Local Postgres161502191834
✅ 🪟 Windows13100131
✅ 📋 Other7410176917
Total6939010527991

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro105026
✅ example105026
✅ express105026
✅ fastify105026
✅ hono105026
✅ nextjs-turbopack12902
✅ nextjs-webpack12902
✅ nitro105026
✅ nuxt105026
✅ sveltekit12407
✅ vite105026
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable106025
✅ express-stable106025
✅ fastify-stable106025
✅ hono-stable106025
✅ nextjs-turbopack-canary112019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13100
✅ nextjs-turbopack-stable-lazy-discovery-enabled13100
✅ nextjs-webpack-canary112019
✅ nextjs-webpack-stable-lazy-discovery-disabled13100
✅ nextjs-webpack-stable-lazy-discovery-enabled13100
✅ nitro-stable106025
✅ nuxt-stable106025
✅ sveltekit-stable12506
✅ vite-stable106025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable106025
✅ express-stable106025
✅ fastify-stable106025
✅ hono-stable106025
✅ nextjs-turbopack-canary112019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13100
✅ nextjs-turbopack-stable-lazy-discovery-enabled13100
✅ nextjs-webpack-canary112019
✅ nextjs-webpack-stable-lazy-discovery-disabled13100
✅ nextjs-webpack-stable-lazy-discovery-enabled13100
✅ nitro-stable106025
✅ nuxt-stable106025
✅ sveltekit-stable12506
✅ vite-stable106025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable106025
✅ express-stable106025
✅ fastify-stable106025
✅ hono-stable106025
✅ nextjs-turbopack-canary112019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13100
✅ nextjs-turbopack-stable-lazy-discovery-enabled13100
✅ nextjs-webpack-canary112019
✅ nextjs-webpack-stable-lazy-discovery-disabled13100
✅ nextjs-webpack-stable-lazy-discovery-enabled13100
✅ nitro-stable106025
✅ nuxt-stable106025
✅ sveltekit-stable12506
✅ vite-stable106025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack13100
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable106025
✅ e2e-local-dev-tanstack-start-106025
✅ e2e-local-postgres-nest-stable106025
✅ e2e-local-postgres-tanstack-start-106025
✅ e2e-local-prod-nest-stable106025
✅ e2e-local-prod-tanstack-start-106025
✅ e2e-vercel-prod-tanstack-start105026

📋 View full workflow run

@github-actions

github-actionsBot commented May 22, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

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

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro0.029s (-32.7% 🟢)1.005s (~)0.976s101.00x
💻 LocalExpress0.030s (-32.3% 🟢)1.006s (~)0.976s101.03x
💻 LocalNext.js (Turbopack)0.049s1.006s0.957s101.69x
🐘 PostgresNitro0.050s (-47.6% 🟢)1.012s (-3.0%)0.962s101.72x
🐘 PostgresExpress0.051s (-11.9% 🟢)1.012s (~)0.961s101.76x
🐘 PostgresNext.js (Turbopack)0.055s1.010s0.955s101.89x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.260s (+3.2%)2.424s (+3.9%)2.165s101.00x
▲ VercelNitro0.274s (-33.1% 🟢)2.158s (-14.0% 🟢)1.885s101.06x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.066s (-5.3% 🟢)2.006s (~)0.940s101.00x
💻 LocalNitro1.069s (-5.5% 🟢)2.006s (~)0.937s101.00x
🐘 PostgresExpress1.083s (-5.5% 🟢)2.009s (~)0.926s101.02x
🐘 PostgresNitro1.086s (-4.7%)2.009s (~)0.923s101.02x
💻 LocalNext.js (Turbopack)1.107s2.006s0.899s101.04x
🐘 PostgresNext.js (Turbopack)1.123s2.009s0.886s101.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)1.590s (-21.9% 🟢)3.378s (-11.8% 🟢)1.788s101.00x
▲ VercelNitro1.608s (-58.7% 🟢)3.228s (-45.4% 🟢)1.620s101.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro10.397s (-5.0% 🟢)11.022s (~)0.625s31.00x
💻 LocalExpress10.408s (-4.7%)11.023s (~)0.615s31.00x
🐘 PostgresExpress10.413s (-5.0% 🟢)11.013s (~)0.600s31.00x
🐘 PostgresNitro10.451s (-3.9%)11.021s (~)0.570s31.01x
💻 LocalNext.js (Turbopack)10.660s11.022s0.363s31.03x
🐘 PostgresNext.js (Turbopack)10.710s11.017s0.307s31.03x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro13.267s (-44.1% 🟢)14.695s (-41.5% 🟢)1.428s31.00x
▲ VercelNext.js (Turbopack)13.473s (-22.2% 🟢)14.947s (-22.9% 🟢)1.474s31.02x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro13.429s (-10.8% 🟢)14.027s (-12.5% 🟢)0.598s51.00x
💻 LocalExpress13.457s (-10.1% 🟢)14.028s (-6.7% 🟢)0.571s51.00x
🐘 PostgresNitro13.469s (-7.7% 🟢)14.019s (-6.7% 🟢)0.550s51.00x
🐘 PostgresExpress13.471s (-7.6% 🟢)14.018s (-6.7% 🟢)0.547s51.00x
💻 LocalNext.js (Turbopack)14.075s15.031s0.956s41.05x
🐘 PostgresNext.js (Turbopack)14.118s15.016s0.898s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro21.328s (-66.9% 🟢)22.835s (-65.7% 🟢)1.507s31.00x
▲ VercelNext.js (Turbopack)22.554s (-57.1% 🟢)24.075s (-55.9% 🟢)1.521s31.06x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro11.788s (-29.8% 🟢)12.022s (-29.4% 🟢)0.234s81.00x
🐘 PostgresExpress11.906s (-15.0% 🟢)12.145s (-16.8% 🟢)0.239s81.01x
💻 LocalExpress11.932s (-28.1% 🟢)12.150s (-28.7% 🟢)0.218s81.01x
🐘 PostgresNitro11.972s (-14.3% 🟢)12.268s (-14.3% 🟢)0.296s81.02x
💻 LocalNext.js (Turbopack)13.074s14.028s0.954s71.11x
🐘 PostgresNext.js (Turbopack)13.117s13.733s0.616s71.11x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro31.064s (-92.7% 🟢)32.780s (-92.3% 🟢)1.716s31.00x
▲ VercelNext.js (Turbopack)34.306s (-91.3% 🟢)36.360s (-90.8% 🟢)2.055s31.10x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.137s (-9.8% 🟢)2.008s (~)0.871s151.00x
💻 LocalNitro1.165s (-28.6% 🟢)2.006s (-3.3%)0.841s151.02x
💻 LocalExpress1.174s (-21.1% 🟢)2.006s (~)0.832s151.03x
🐘 PostgresNitro1.184s (-7.1% 🟢)2.007s (~)0.823s151.04x
🐘 PostgresNext.js (Turbopack)1.202s2.007s0.805s151.06x
💻 LocalNext.js (Turbopack)1.285s2.006s0.721s151.13x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.789s (-17.9% 🟢)4.385s (-11.1% 🟢)1.596s71.00x
▲ VercelNitro2.886s (+2.4%)4.249s (-1.7%)1.363s81.03x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.196s (-49.4% 🟢)2.007s (-33.3% 🟢)0.811s151.00x
🐘 PostgresNitro1.216s (-48.3% 🟢)2.007s (-33.3% 🟢)0.791s151.02x
🐘 PostgresNext.js (Turbopack)1.349s2.008s0.659s151.13x
💻 LocalNitro1.677s (-46.6% 🟢)2.006s (-48.4% 🟢)0.328s151.40x
💻 LocalNext.js (Turbopack)1.680s2.006s0.326s151.40x
💻 LocalExpress1.712s (-42.0% 🟢)2.006s (-41.9% 🟢)0.294s151.43x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.701s (-47.9% 🟢)5.327s (-40.2% 🟢)1.625s61.00x
▲ VercelNitro3.953s (-2.5%)5.162s (-12.8% 🟢)1.210s61.07x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.329s (-61.9% 🟢)2.008s (-49.9% 🟢)0.679s151.00x
🐘 PostgresNitro1.346s (-61.3% 🟢)2.006s (-49.9% 🟢)0.660s151.01x
🐘 PostgresNext.js (Turbopack)1.584s2.007s0.423s151.19x
💻 LocalNitro4.494s (-46.2% 🟢)5.011s (-44.5% 🟢)0.516s73.38x
💻 LocalNext.js (Turbopack)4.764s5.178s0.414s63.58x
💻 LocalExpress4.815s (-42.3% 🟢)5.179s (-42.6% 🟢)0.364s63.62x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)5.312s (-40.4% 🟢)7.208s (-34.2% 🟢)1.896s51.00x
▲ VercelNitro6.035s (+71.2% 🔺)7.638s (+38.0% 🔺)1.603s41.14x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.145s (-8.9% 🟢)2.008s (~)0.863s151.00x
🐘 PostgresExpress1.156s (-8.1% 🟢)2.008s (~)0.852s151.01x
🐘 PostgresNext.js (Turbopack)1.203s2.007s0.804s151.05x
💻 LocalNext.js (Turbopack)1.324s2.006s0.682s151.16x
💻 LocalNitro1.393s (-25.4% 🟢)2.006s (-14.3% 🟢)0.613s151.22x
💻 LocalExpress1.405s (-25.8% 🟢)2.006s (-15.1% 🟢)0.600s151.23x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.604s (+5.9% 🔺)4.035s (-3.2%)1.431s81.00x
▲ VercelNext.js (Turbopack)2.746s (-6.3% 🟢)4.374s (-5.8% 🟢)1.628s71.05x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.208s (-48.4% 🟢)2.009s (-33.2% 🟢)0.802s151.00x
🐘 PostgresExpress1.210s (-48.3% 🟢)2.009s (-33.3% 🟢)0.799s151.00x
🐘 PostgresNext.js (Turbopack)1.323s2.008s0.685s151.10x
💻 LocalExpress2.041s (-34.8% 🟢)2.469s (-34.4% 🟢)0.428s131.69x
💻 LocalNext.js (Turbopack)2.061s2.826s0.766s111.71x
💻 LocalNitro2.063s (-32.7% 🟢)2.593s (-33.3% 🟢)0.530s121.71x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.297s (+2.0%)4.707s (-7.3% 🟢)1.410s71.00x
▲ VercelNext.js (Turbopack)4.012s (+27.7% 🔺)5.468s (+20.9% 🔺)1.456s61.22x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.318s (-62.3% 🟢)2.008s (-49.9% 🟢)0.689s151.00x
🐘 PostgresNitro1.326s (-61.9% 🟢)2.009s (-49.9% 🟢)0.683s151.01x
🐘 PostgresNext.js (Turbopack)1.567s2.008s0.441s151.19x
💻 LocalExpress4.705s (-46.5% 🟢)5.348s (-42.3% 🟢)0.644s63.57x
💻 LocalNitro5.421s (-40.7% 🟢)6.013s (-40.0% 🟢)0.592s54.11x
💻 LocalNext.js (Turbopack)5.757s6.215s0.458s54.37x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.000s (-1.8%)6.567s (-3.7%)1.566s51.00x
▲ VercelNext.js (Turbopack)6.349s (-6.0% 🟢)8.030s (-6.0% 🟢)1.681s41.27x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.438s (-47.8% 🟢)1.007s (-1.6%)0.569s601.00x
💻 LocalNitro0.462s (-52.9% 🟢)1.004s (-8.2% 🟢)0.541s601.06x
🐘 PostgresNitro0.483s (-41.1% 🟢)1.024s (+1.8%)0.541s591.10x
💻 LocalExpress0.511s (-48.1% 🟢)1.039s (-3.5%)0.527s581.17x
🐘 PostgresNext.js (Turbopack)0.656s1.006s0.350s601.50x
💻 LocalNext.js (Turbopack)0.757s1.004s0.247s601.73x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.423s (-75.4% 🟢)6.932s (-71.1% 🟢)1.510s91.00x
▲ VercelNext.js (Turbopack)5.556s (-61.7% 🟢)7.267s (-54.8% 🟢)1.711s91.02x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.023s (-48.2% 🟢)1.549s (-31.4% 🟢)0.526s591.00x
🐘 PostgresNitro1.091s (-43.4% 🟢)1.903s (-9.4% 🟢)0.812s481.07x
💻 LocalNitro1.166s (-61.6% 🟢)2.005s (-46.6% 🟢)0.839s451.14x
💻 LocalExpress1.203s (-60.1% 🟢)2.006s (-44.1% 🟢)0.802s451.18x
🐘 PostgresNext.js (Turbopack)1.576s2.007s0.431s451.54x
💻 LocalNext.js (Turbopack)1.842s2.051s0.209s441.80x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro12.637s (-68.0% 🟢)14.394s (-65.1% 🟢)1.757s71.00x
▲ VercelNext.js (Turbopack)13.164s (-73.6% 🟢)15.106s (-70.8% 🟢)1.942s71.04x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.994s (-50.0% 🟢)2.335s (-46.6% 🟢)0.342s521.00x
🐘 PostgresNitro2.150s (-47.6% 🟢)2.799s (-39.2% 🟢)0.649s431.08x
💻 LocalNitro2.645s (-71.6% 🟢)3.007s (-70.0% 🟢)0.362s401.33x
💻 LocalExpress2.743s (-70.2% 🟢)3.033s (-69.7% 🟢)0.290s401.38x
🐘 PostgresNext.js (Turbopack)3.120s4.010s0.890s301.56x
💻 LocalNext.js (Turbopack)3.899s4.215s0.316s291.96x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro25.288s (-73.9% 🟢)27.188s (-72.4% 🟢)1.900s51.00x
▲ VercelNext.js (Turbopack)27.396s (-74.4% 🟢)29.427s (-73.0% 🟢)2.031s51.08x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.176s (-37.8% 🟢)1.006s (~)0.829s601.00x
🐘 PostgresExpress0.180s (-36.1% 🟢)1.006s (~)0.825s601.02x
🐘 PostgresNext.js (Turbopack)0.226s1.006s0.780s601.28x
💻 LocalExpress0.392s (-30.0% 🟢)1.004s (~)0.612s602.22x
💻 LocalNitro0.395s (-34.7% 🟢)1.004s (-1.7%)0.609s602.24x
💻 LocalNext.js (Turbopack)0.549s1.004s0.455s603.11x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.378s (+43.2% 🔺)3.815s (+13.8% 🔺)1.437s161.00x
▲ VercelNext.js (Turbopack)2.893s (+43.0% 🔺)4.315s (+13.7% 🔺)1.422s141.22x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.301s (-41.0% 🟢)1.006s (~)0.705s901.00x
🐘 PostgresNitro0.305s (-38.6% 🟢)1.006s (~)0.702s901.01x
🐘 PostgresNext.js (Turbopack)0.422s1.006s0.584s901.40x
💻 LocalNitro2.135s (-15.9% 🟢)2.715s (-9.8% 🟢)0.580s347.09x
💻 LocalExpress2.178s (-13.3% 🟢)2.796s (-7.1% 🟢)0.618s337.24x
💻 LocalNext.js (Turbopack)2.381s3.010s0.630s307.91x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.031s (+56.0% 🔺)6.621s (+37.3% 🔺)1.590s141.00x
▲ VercelNext.js (Turbopack)5.780s (+63.5% 🔺)7.417s (+42.8% 🔺)1.636s131.15x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.617s (-24.7% 🟢)1.006s (-1.1%)0.389s1201.00x
🐘 PostgresNitro0.661s (-16.4% 🟢)1.006s (~)0.345s1201.07x
🐘 PostgresNext.js (Turbopack)0.866s1.023s0.157s1181.40x
💻 LocalNitro9.855s (-11.9% 🟢)10.275s (-11.9% 🟢)0.420s1215.98x
💻 LocalExpress10.320s (-7.8% 🟢)10.781s (-9.7% 🟢)0.461s1216.73x
💻 LocalNext.js (Turbopack)11.376s11.939s0.563s1118.44x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)16.735s (+62.0% 🔺)18.540s (+50.9% 🔺)1.805s71.00x
▲ VercelNitro24.069s (+211.7% 🔺)25.599s (+172.3% 🔺)1.529s51.44x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro1.130s (+428.7% 🔺)2.005s (+99.6% 🔺)0.010s (-18.4% 🟢)2.017s (+98.0% 🔺)0.887s101.00x
💻 LocalExpress1.142s (+473.5% 🔺)2.005s (+99.6% 🔺)0.011s (-13.2% 🟢)2.018s (+98.2% 🔺)0.876s101.01x
🐘 PostgresExpress1.145s (+458.5% 🔺)1.997s (+99.9% 🔺)0.001s (-31.3% 🟢)2.010s (+98.7% 🔺)0.864s101.01x
🐘 PostgresNitro1.159s (+465.3% 🔺)2.002s (+100.2% 🔺)0.002s (+13.3% 🔺)2.011s (+98.9% 🔺)0.852s101.03x
💻 LocalNext.js (Turbopack)1.180s2.004s0.011s2.018s0.839s101.04x
🐘 PostgresNext.js (Turbopack)1.196s2.002s0.002s2.010s0.814s101.06x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.306s (-66.4% 🟢)3.176s (-63.3% 🟢)2.309s (+265.5% 🔺)5.934s (-39.4% 🟢)3.628s101.00x
▲ VercelNitro2.375s (-38.0% 🟢)3.202s (-39.3% 🟢)14.034s (+1791.1% 🔺)17.640s (+172.1% 🔺)15.265s101.03x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.519s (+141.2% 🔺)2.003s (+99.0% 🔺)0.004s (+1.8%)2.026s (+98.0% 🔺)0.506s301.00x
🐘 PostgresNitro1.566s (+150.9% 🔺)2.008s (+99.5% 🔺)0.004s (-1.7%)2.025s (+98.1% 🔺)0.459s301.03x
🐘 PostgresNext.js (Turbopack)1.652s2.010s0.004s2.026s0.374s301.09x
💻 LocalNitro1.694s (+102.0% 🔺)2.010s (+98.6% 🔺)0.009s (-2.4%)2.200s (+97.2% 🔺)0.507s281.11x
💻 LocalExpress1.703s (+125.0% 🔺)2.010s (+95.4% 🔺)0.011s (+12.7% 🔺)2.202s (+111.8% 🔺)0.499s281.12x
💻 LocalNext.js (Turbopack)1.871s2.011s0.009s2.203s0.332s281.23x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)5.720s (-66.2% 🟢)6.903s (-62.2% 🟢)0.256s (+20.9% 🔺)7.611s (-59.8% 🟢)1.891s81.00x
▲ VercelNitro5.938s (-79.8% 🟢)7.093s (-77.0% 🟢)0.210s (+87.5% 🔺)7.760s (-75.6% 🟢)1.823s81.04x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.651s (-32.3% 🟢)1.027s (-19.6% 🟢)0.000s (-61.0% 🟢)1.040s (-20.4% 🟢)0.390s591.00x
🐘 PostgresNitro0.674s (-30.4% 🟢)1.070s (-14.2% 🟢)0.000s (-15.8% 🟢)1.077s (-14.3% 🟢)0.403s571.04x
🐘 PostgresNext.js (Turbopack)0.750s1.035s0.000s1.042s0.292s581.15x
💻 LocalNitro1.341s (+9.7% 🔺)2.015s (~)0.001s (+433.3% 🔺)2.018s (~)0.677s302.06x
💻 LocalExpress1.376s (+12.3% 🔺)2.015s (~)0.000s (~)2.018s (~)0.642s302.11x
💻 LocalNext.js (Turbopack)1.482s2.014s0.000s2.017s0.535s302.28x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.886s (-61.8% 🟢)4.959s (-56.9% 🟢)0.002s (+Infinity% 🔺)5.651s (-53.1% 🟢)1.766s111.00x
▲ VercelNitro4.900s (+60.6% 🔺)6.154s (+40.1% 🔺)0.000s (-100.0% 🟢)6.747s (+40.3% 🔺)1.847s91.26x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

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

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.289s (-27.2% 🟢)2.065s (-5.2% 🟢)0.000s (+Infinity% 🔺)2.079s (-5.4% 🟢)0.790s291.00x
🐘 PostgresNitro1.338s (-25.3% 🟢)2.068s (-3.4%)0.000s (+189.7% 🔺)2.079s (-4.4%)0.742s291.04x
🐘 PostgresNext.js (Turbopack)1.566s2.181s0.000s2.203s0.637s281.21x
💻 LocalNitro3.063s (-9.6% 🟢)3.677s (-8.8% 🟢)0.000s (-55.9% 🟢)3.680s (-8.8% 🟢)0.617s172.38x
💻 LocalExpress3.172s (-8.5% 🟢)3.904s (-3.2%)0.001s (-37.5% 🟢)3.908s (-3.2%)0.736s162.46x
💻 LocalNext.js (Turbopack)3.190s3.337s0.001s3.612s0.422s192.47x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.807s (+41.8% 🔺)7.135s (+32.8% 🔺)0.000s (+37.5% 🔺)7.605s (+31.3% 🔺)1.798s81.00x
▲ VercelNext.js (Turbopack)6.464s (+15.1% 🔺)7.633s (+9.3% 🔺)0.000s (-100.0% 🟢)8.268s (+9.6% 🔺)1.804s81.11x
▲ VercelExpress⚠️missing-----

🔍 Observability: Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNitro16/21
🐘 PostgresExpress16/21
▲ VercelNitro12/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres16/21
Next.js (Turbopack)🐘 Postgres15/21
Nitro🐘 Postgres13/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.

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

Should we memoize the getWritable() return value instead? Seems simpler to me.

@VaguelySerious

Copy link
Copy Markdown
MemberAuthor

@TooTallNate doing now

Drop the per-call guard mechanism and `pollSharedWritableLock` helper.
With memoization, the cached pipe's single `state.promise` is sufficient:
in the loop pattern `await writer.write(chunk)` already blocks until each
chunk is flushed (the sink awaits the scheduled flush before resolving),
so by the time the loop ends `pendingOps === 0` and the final
`releaseLock()` lets `pollWritableLock` resolve the state. One writable,
one pipe, one ops entry per `(runId, namespace)`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@TooTallNateTooTallNate 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 — clean fix, my earlier "memoize the return value" suggestion landed in 40106be78

The bug is real and the fix matches the issue exactly: caching serialize.writable per (runId, namespace) on the step context so repeat calls within the same step go through one serial sink instead of spawning racing pipes that fight for the same (runId, name) server stream.

What I verified

  • pnpm install --frozen-lockfile
  • pnpm turbo run build --filter @workflow/core
  • pnpm --filter @workflow/core test ✓ (1013/1013)
  • cd packages/core && pnpm exec vitest run src/step/writable-stream.test.ts ✓ (5/5 — 2 existing + 3 new)
  • CI: 65 E2E jobs pass, Unit Tests pass, Windows Tests pass, single Benchmark Vercel (express) flake unrelated to this change

Test coverage looks great

The three new tests pin the contract from three angles:

  1. returns the same writable for repeat calls with the same namespace — direct identity assertion (and distinct namespaces still get distinct writables)
  2. preserves chunk order across per-write getWritable() calls in a loop — exact repro of the issue's 'nov', 'o', ' e', '2', 'e', ' ok' scenario, decoded through the matching deserialize stream
  3. registers exactly one pipe per (runId, namespace), regardless of call count — asserts ops.length === 2 (one shared + one for the distinct namespace)

The chunk-order test is particularly good because it goes through the full serialize → record → deserialize round trip rather than asserting against mock internals.

Per-call ctx.ops guards — no longer needed

The simplification from 0d076e0c40106be78 correctly drops per-caller ops registration. Since all callers now share one state.promise, that one promise already covers every writer that holds the lock — when any writer has the lock the pipe is active, when none do the state resolves. The "wait for every caller's writes to drain" semantics fall out for free.

One subtle behavior change worth calling out

A user who does getWritable().close() and then calls getWritable() again will now get back the same closed writable instead of a fresh one. Pre-PR, the second call would have created a new writable they could continue writing to. Post-PR, that second call returns a closed handle.

I don't think anyone is actually depending on this — the issue describes the bug as users not realizing re-acquisition was unsafe, so "close then re-acquire to keep writing" is unlikely to be a real pattern. The AI SDK / per-chunk-loop case (which IS the motivating example) only does releaseLock(), not close(). So this should be fine in practice. Worth mentioning in case anyone hits it post-release.

Branch hygiene note (not a blocker)

The branch is forked from a point before #2091 landed, so the GitHub diff against current main shows misleading "deletions" of .changeset/changelog.mjs, .changeset/config.json's changelog generator reference, and 5 other unrelated changesets. Those are all artifacts of being stale — the PR's actual tree doesn't have those files at all (because they were added on main after the branch point), so a fresh sync with main would make the diff display correctly. Squash-merge will produce a clean single commit regardless, so this doesn't affect what lands. Just flagging because the github UI looks scary at first glance.

Approving.

@VaguelySerious
VaguelySerious merged commit 2050656 into mainMay 23, 2026
185 of 192 checks passed
@VaguelySerious
VaguelySerious deleted the peter/issue-2058 branch May 23, 2026 07:52
@github-actionsgithub-actionsBot mentioned this pull request May 28, 2026
Sign up for freeto 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.

getWritable() returns a new TransformStream per call — racing pipes reorder chunks when callers acquire per-write

2 participants

@VaguelySerious@TooTallNate
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' [core] Memoize `getWritable()` to fix chunk reordering when called repeatedly by VaguelySerious · Pull Request #2086 · vercel/workflow · GitHub
Skip to content

[core] Memoize getWritable() to fix chunk reordering when called repeatedly - #2086

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/issue-2058
May 23, 2026
Merged

[core] Memoize getWritable() to fix chunk reordering when called repeatedly#2086
VaguelySerious merged 2 commits into
mainfrom
peter/issue-2058

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented May 22, 2026

Copy link
Copy Markdown
Member

Fixes#2058, where calling getWritable multiple times in the same function causes chunk re-ordering without any user-facing warnings. I'm not sure if this is the right approach or we should just throw an error or warn.

The issue

Each call to getWritable() constructed a fresh TransformStream + WorkflowServerWritableStream + background flushablePipe(...), all flushing to the same (runId, name). On Vercel the 50-100ms HTTP write latency turned the inter-pipe race into deterministic reordering — most visibly when callers acquired a fresh writer per chunk (e.g. an AI SDK text-delta loop). Locally world-local made it look fine because filesystem writes are effectively instant.

The fix

  • Share a single TransformStream + flushablePipe per (runId, namespace) within a step context so repeat calls to getWritable() no longer spawn racing pipes.
  • Add per-call guards on ctx.ops so the step still waits for every caller's writes to drain.

Repeat calls to `getWritable()` from the same step previously spawned
independent TransformStream + flushablePipe pairs that all flushed to
the same (runId, name) on the server. On Vercel the 50-100ms HTTP write
latency turned the inter-pipe race into deterministic reordering — most
visibly when callers acquired a fresh writer per chunk (e.g. an AI SDK
text-delta loop). Locally the world-local filesystem path made the race
invisible.
Cache the writable + pipe state per (runId, namespace) in the step
context so repeat calls share one serial sink. Each call still registers
a per-call guard in ctx.ops so the step waits for every caller's writes
to flush before completing, matching the prior multi-pipe semantics.
Closes#2058
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel

vercelBot commented May 22, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented May 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 40106be

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

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
workflowPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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-actionsBot commented May 22, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production122202191441
✅ 💻 Local Development161502191834
✅ 📦 Local Production161502191834
✅ 🐘 Local Postgres161502191834
✅ 🪟 Windows13100131
✅ 📋 Other7410176917
Total6939010527991

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro105026
✅ example105026
✅ express105026
✅ fastify105026
✅ hono105026
✅ nextjs-turbopack12902
✅ nextjs-webpack12902
✅ nitro105026
✅ nuxt105026
✅ sveltekit12407
✅ vite105026
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable106025
✅ express-stable106025
✅ fastify-stable106025
✅ hono-stable106025
✅ nextjs-turbopack-canary112019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13100
✅ nextjs-turbopack-stable-lazy-discovery-enabled13100
✅ nextjs-webpack-canary112019
✅ nextjs-webpack-stable-lazy-discovery-disabled13100
✅ nextjs-webpack-stable-lazy-discovery-enabled13100
✅ nitro-stable106025
✅ nuxt-stable106025
✅ sveltekit-stable12506
✅ vite-stable106025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable106025
✅ express-stable106025
✅ fastify-stable106025
✅ hono-stable106025
✅ nextjs-turbopack-canary112019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13100
✅ nextjs-turbopack-stable-lazy-discovery-enabled13100
✅ nextjs-webpack-canary112019
✅ nextjs-webpack-stable-lazy-discovery-disabled13100
✅ nextjs-webpack-stable-lazy-discovery-enabled13100
✅ nitro-stable106025
✅ nuxt-stable106025
✅ sveltekit-stable12506
✅ vite-stable106025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable106025
✅ express-stable106025
✅ fastify-stable106025
✅ hono-stable106025
✅ nextjs-turbopack-canary112019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13100
✅ nextjs-turbopack-stable-lazy-discovery-enabled13100
✅ nextjs-webpack-canary112019
✅ nextjs-webpack-stable-lazy-discovery-disabled13100
✅ nextjs-webpack-stable-lazy-discovery-enabled13100
✅ nitro-stable106025
✅ nuxt-stable106025
✅ sveltekit-stable12506
✅ vite-stable106025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack13100
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable106025
✅ e2e-local-dev-tanstack-start-106025
✅ e2e-local-postgres-nest-stable106025
✅ e2e-local-postgres-tanstack-start-106025
✅ e2e-local-prod-nest-stable106025
✅ e2e-local-prod-tanstack-start-106025
✅ e2e-vercel-prod-tanstack-start105026

📋 View full workflow run

@github-actions

github-actionsBot commented May 22, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

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

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro0.029s (-32.7% 🟢)1.005s (~)0.976s101.00x
💻 LocalExpress0.030s (-32.3% 🟢)1.006s (~)0.976s101.03x
💻 LocalNext.js (Turbopack)0.049s1.006s0.957s101.69x
🐘 PostgresNitro0.050s (-47.6% 🟢)1.012s (-3.0%)0.962s101.72x
🐘 PostgresExpress0.051s (-11.9% 🟢)1.012s (~)0.961s101.76x
🐘 PostgresNext.js (Turbopack)0.055s1.010s0.955s101.89x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.260s (+3.2%)2.424s (+3.9%)2.165s101.00x
▲ VercelNitro0.274s (-33.1% 🟢)2.158s (-14.0% 🟢)1.885s101.06x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.066s (-5.3% 🟢)2.006s (~)0.940s101.00x
💻 LocalNitro1.069s (-5.5% 🟢)2.006s (~)0.937s101.00x
🐘 PostgresExpress1.083s (-5.5% 🟢)2.009s (~)0.926s101.02x
🐘 PostgresNitro1.086s (-4.7%)2.009s (~)0.923s101.02x
💻 LocalNext.js (Turbopack)1.107s2.006s0.899s101.04x
🐘 PostgresNext.js (Turbopack)1.123s2.009s0.886s101.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)1.590s (-21.9% 🟢)3.378s (-11.8% 🟢)1.788s101.00x
▲ VercelNitro1.608s (-58.7% 🟢)3.228s (-45.4% 🟢)1.620s101.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro10.397s (-5.0% 🟢)11.022s (~)0.625s31.00x
💻 LocalExpress10.408s (-4.7%)11.023s (~)0.615s31.00x
🐘 PostgresExpress10.413s (-5.0% 🟢)11.013s (~)0.600s31.00x
🐘 PostgresNitro10.451s (-3.9%)11.021s (~)0.570s31.01x
💻 LocalNext.js (Turbopack)10.660s11.022s0.363s31.03x
🐘 PostgresNext.js (Turbopack)10.710s11.017s0.307s31.03x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro13.267s (-44.1% 🟢)14.695s (-41.5% 🟢)1.428s31.00x
▲ VercelNext.js (Turbopack)13.473s (-22.2% 🟢)14.947s (-22.9% 🟢)1.474s31.02x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro13.429s (-10.8% 🟢)14.027s (-12.5% 🟢)0.598s51.00x
💻 LocalExpress13.457s (-10.1% 🟢)14.028s (-6.7% 🟢)0.571s51.00x
🐘 PostgresNitro13.469s (-7.7% 🟢)14.019s (-6.7% 🟢)0.550s51.00x
🐘 PostgresExpress13.471s (-7.6% 🟢)14.018s (-6.7% 🟢)0.547s51.00x
💻 LocalNext.js (Turbopack)14.075s15.031s0.956s41.05x
🐘 PostgresNext.js (Turbopack)14.118s15.016s0.898s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro21.328s (-66.9% 🟢)22.835s (-65.7% 🟢)1.507s31.00x
▲ VercelNext.js (Turbopack)22.554s (-57.1% 🟢)24.075s (-55.9% 🟢)1.521s31.06x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro11.788s (-29.8% 🟢)12.022s (-29.4% 🟢)0.234s81.00x
🐘 PostgresExpress11.906s (-15.0% 🟢)12.145s (-16.8% 🟢)0.239s81.01x
💻 LocalExpress11.932s (-28.1% 🟢)12.150s (-28.7% 🟢)0.218s81.01x
🐘 PostgresNitro11.972s (-14.3% 🟢)12.268s (-14.3% 🟢)0.296s81.02x
💻 LocalNext.js (Turbopack)13.074s14.028s0.954s71.11x
🐘 PostgresNext.js (Turbopack)13.117s13.733s0.616s71.11x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro31.064s (-92.7% 🟢)32.780s (-92.3% 🟢)1.716s31.00x
▲ VercelNext.js (Turbopack)34.306s (-91.3% 🟢)36.360s (-90.8% 🟢)2.055s31.10x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.137s (-9.8% 🟢)2.008s (~)0.871s151.00x
💻 LocalNitro1.165s (-28.6% 🟢)2.006s (-3.3%)0.841s151.02x
💻 LocalExpress1.174s (-21.1% 🟢)2.006s (~)0.832s151.03x
🐘 PostgresNitro1.184s (-7.1% 🟢)2.007s (~)0.823s151.04x
🐘 PostgresNext.js (Turbopack)1.202s2.007s0.805s151.06x
💻 LocalNext.js (Turbopack)1.285s2.006s0.721s151.13x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.789s (-17.9% 🟢)4.385s (-11.1% 🟢)1.596s71.00x
▲ VercelNitro2.886s (+2.4%)4.249s (-1.7%)1.363s81.03x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.196s (-49.4% 🟢)2.007s (-33.3% 🟢)0.811s151.00x
🐘 PostgresNitro1.216s (-48.3% 🟢)2.007s (-33.3% 🟢)0.791s151.02x
🐘 PostgresNext.js (Turbopack)1.349s2.008s0.659s151.13x
💻 LocalNitro1.677s (-46.6% 🟢)2.006s (-48.4% 🟢)0.328s151.40x
💻 LocalNext.js (Turbopack)1.680s2.006s0.326s151.40x
💻 LocalExpress1.712s (-42.0% 🟢)2.006s (-41.9% 🟢)0.294s151.43x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.701s (-47.9% 🟢)5.327s (-40.2% 🟢)1.625s61.00x
▲ VercelNitro3.953s (-2.5%)5.162s (-12.8% 🟢)1.210s61.07x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.329s (-61.9% 🟢)2.008s (-49.9% 🟢)0.679s151.00x
🐘 PostgresNitro1.346s (-61.3% 🟢)2.006s (-49.9% 🟢)0.660s151.01x
🐘 PostgresNext.js (Turbopack)1.584s2.007s0.423s151.19x
💻 LocalNitro4.494s (-46.2% 🟢)5.011s (-44.5% 🟢)0.516s73.38x
💻 LocalNext.js (Turbopack)4.764s5.178s0.414s63.58x
💻 LocalExpress4.815s (-42.3% 🟢)5.179s (-42.6% 🟢)0.364s63.62x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)5.312s (-40.4% 🟢)7.208s (-34.2% 🟢)1.896s51.00x
▲ VercelNitro6.035s (+71.2% 🔺)7.638s (+38.0% 🔺)1.603s41.14x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.145s (-8.9% 🟢)2.008s (~)0.863s151.00x
🐘 PostgresExpress1.156s (-8.1% 🟢)2.008s (~)0.852s151.01x
🐘 PostgresNext.js (Turbopack)1.203s2.007s0.804s151.05x
💻 LocalNext.js (Turbopack)1.324s2.006s0.682s151.16x
💻 LocalNitro1.393s (-25.4% 🟢)2.006s (-14.3% 🟢)0.613s151.22x
💻 LocalExpress1.405s (-25.8% 🟢)2.006s (-15.1% 🟢)0.600s151.23x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.604s (+5.9% 🔺)4.035s (-3.2%)1.431s81.00x
▲ VercelNext.js (Turbopack)2.746s (-6.3% 🟢)4.374s (-5.8% 🟢)1.628s71.05x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.208s (-48.4% 🟢)2.009s (-33.2% 🟢)0.802s151.00x
🐘 PostgresExpress1.210s (-48.3% 🟢)2.009s (-33.3% 🟢)0.799s151.00x
🐘 PostgresNext.js (Turbopack)1.323s2.008s0.685s151.10x
💻 LocalExpress2.041s (-34.8% 🟢)2.469s (-34.4% 🟢)0.428s131.69x
💻 LocalNext.js (Turbopack)2.061s2.826s0.766s111.71x
💻 LocalNitro2.063s (-32.7% 🟢)2.593s (-33.3% 🟢)0.530s121.71x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.297s (+2.0%)4.707s (-7.3% 🟢)1.410s71.00x
▲ VercelNext.js (Turbopack)4.012s (+27.7% 🔺)5.468s (+20.9% 🔺)1.456s61.22x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.318s (-62.3% 🟢)2.008s (-49.9% 🟢)0.689s151.00x
🐘 PostgresNitro1.326s (-61.9% 🟢)2.009s (-49.9% 🟢)0.683s151.01x
🐘 PostgresNext.js (Turbopack)1.567s2.008s0.441s151.19x
💻 LocalExpress4.705s (-46.5% 🟢)5.348s (-42.3% 🟢)0.644s63.57x
💻 LocalNitro5.421s (-40.7% 🟢)6.013s (-40.0% 🟢)0.592s54.11x
💻 LocalNext.js (Turbopack)5.757s6.215s0.458s54.37x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.000s (-1.8%)6.567s (-3.7%)1.566s51.00x
▲ VercelNext.js (Turbopack)6.349s (-6.0% 🟢)8.030s (-6.0% 🟢)1.681s41.27x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.438s (-47.8% 🟢)1.007s (-1.6%)0.569s601.00x
💻 LocalNitro0.462s (-52.9% 🟢)1.004s (-8.2% 🟢)0.541s601.06x
🐘 PostgresNitro0.483s (-41.1% 🟢)1.024s (+1.8%)0.541s591.10x
💻 LocalExpress0.511s (-48.1% 🟢)1.039s (-3.5%)0.527s581.17x
🐘 PostgresNext.js (Turbopack)0.656s1.006s0.350s601.50x
💻 LocalNext.js (Turbopack)0.757s1.004s0.247s601.73x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.423s (-75.4% 🟢)6.932s (-71.1% 🟢)1.510s91.00x
▲ VercelNext.js (Turbopack)5.556s (-61.7% 🟢)7.267s (-54.8% 🟢)1.711s91.02x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.023s (-48.2% 🟢)1.549s (-31.4% 🟢)0.526s591.00x
🐘 PostgresNitro1.091s (-43.4% 🟢)1.903s (-9.4% 🟢)0.812s481.07x
💻 LocalNitro1.166s (-61.6% 🟢)2.005s (-46.6% 🟢)0.839s451.14x
💻 LocalExpress1.203s (-60.1% 🟢)2.006s (-44.1% 🟢)0.802s451.18x
🐘 PostgresNext.js (Turbopack)1.576s2.007s0.431s451.54x
💻 LocalNext.js (Turbopack)1.842s2.051s0.209s441.80x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro12.637s (-68.0% 🟢)14.394s (-65.1% 🟢)1.757s71.00x
▲ VercelNext.js (Turbopack)13.164s (-73.6% 🟢)15.106s (-70.8% 🟢)1.942s71.04x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.994s (-50.0% 🟢)2.335s (-46.6% 🟢)0.342s521.00x
🐘 PostgresNitro2.150s (-47.6% 🟢)2.799s (-39.2% 🟢)0.649s431.08x
💻 LocalNitro2.645s (-71.6% 🟢)3.007s (-70.0% 🟢)0.362s401.33x
💻 LocalExpress2.743s (-70.2% 🟢)3.033s (-69.7% 🟢)0.290s401.38x
🐘 PostgresNext.js (Turbopack)3.120s4.010s0.890s301.56x
💻 LocalNext.js (Turbopack)3.899s4.215s0.316s291.96x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro25.288s (-73.9% 🟢)27.188s (-72.4% 🟢)1.900s51.00x
▲ VercelNext.js (Turbopack)27.396s (-74.4% 🟢)29.427s (-73.0% 🟢)2.031s51.08x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.176s (-37.8% 🟢)1.006s (~)0.829s601.00x
🐘 PostgresExpress0.180s (-36.1% 🟢)1.006s (~)0.825s601.02x
🐘 PostgresNext.js (Turbopack)0.226s1.006s0.780s601.28x
💻 LocalExpress0.392s (-30.0% 🟢)1.004s (~)0.612s602.22x
💻 LocalNitro0.395s (-34.7% 🟢)1.004s (-1.7%)0.609s602.24x
💻 LocalNext.js (Turbopack)0.549s1.004s0.455s603.11x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.378s (+43.2% 🔺)3.815s (+13.8% 🔺)1.437s161.00x
▲ VercelNext.js (Turbopack)2.893s (+43.0% 🔺)4.315s (+13.7% 🔺)1.422s141.22x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.301s (-41.0% 🟢)1.006s (~)0.705s901.00x
🐘 PostgresNitro0.305s (-38.6% 🟢)1.006s (~)0.702s901.01x
🐘 PostgresNext.js (Turbopack)0.422s1.006s0.584s901.40x
💻 LocalNitro2.135s (-15.9% 🟢)2.715s (-9.8% 🟢)0.580s347.09x
💻 LocalExpress2.178s (-13.3% 🟢)2.796s (-7.1% 🟢)0.618s337.24x
💻 LocalNext.js (Turbopack)2.381s3.010s0.630s307.91x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.031s (+56.0% 🔺)6.621s (+37.3% 🔺)1.590s141.00x
▲ VercelNext.js (Turbopack)5.780s (+63.5% 🔺)7.417s (+42.8% 🔺)1.636s131.15x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.617s (-24.7% 🟢)1.006s (-1.1%)0.389s1201.00x
🐘 PostgresNitro0.661s (-16.4% 🟢)1.006s (~)0.345s1201.07x
🐘 PostgresNext.js (Turbopack)0.866s1.023s0.157s1181.40x
💻 LocalNitro9.855s (-11.9% 🟢)10.275s (-11.9% 🟢)0.420s1215.98x
💻 LocalExpress10.320s (-7.8% 🟢)10.781s (-9.7% 🟢)0.461s1216.73x
💻 LocalNext.js (Turbopack)11.376s11.939s0.563s1118.44x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)16.735s (+62.0% 🔺)18.540s (+50.9% 🔺)1.805s71.00x
▲ VercelNitro24.069s (+211.7% 🔺)25.599s (+172.3% 🔺)1.529s51.44x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro1.130s (+428.7% 🔺)2.005s (+99.6% 🔺)0.010s (-18.4% 🟢)2.017s (+98.0% 🔺)0.887s101.00x
💻 LocalExpress1.142s (+473.5% 🔺)2.005s (+99.6% 🔺)0.011s (-13.2% 🟢)2.018s (+98.2% 🔺)0.876s101.01x
🐘 PostgresExpress1.145s (+458.5% 🔺)1.997s (+99.9% 🔺)0.001s (-31.3% 🟢)2.010s (+98.7% 🔺)0.864s101.01x
🐘 PostgresNitro1.159s (+465.3% 🔺)2.002s (+100.2% 🔺)0.002s (+13.3% 🔺)2.011s (+98.9% 🔺)0.852s101.03x
💻 LocalNext.js (Turbopack)1.180s2.004s0.011s2.018s0.839s101.04x
🐘 PostgresNext.js (Turbopack)1.196s2.002s0.002s2.010s0.814s101.06x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.306s (-66.4% 🟢)3.176s (-63.3% 🟢)2.309s (+265.5% 🔺)5.934s (-39.4% 🟢)3.628s101.00x
▲ VercelNitro2.375s (-38.0% 🟢)3.202s (-39.3% 🟢)14.034s (+1791.1% 🔺)17.640s (+172.1% 🔺)15.265s101.03x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.519s (+141.2% 🔺)2.003s (+99.0% 🔺)0.004s (+1.8%)2.026s (+98.0% 🔺)0.506s301.00x
🐘 PostgresNitro1.566s (+150.9% 🔺)2.008s (+99.5% 🔺)0.004s (-1.7%)2.025s (+98.1% 🔺)0.459s301.03x
🐘 PostgresNext.js (Turbopack)1.652s2.010s0.004s2.026s0.374s301.09x
💻 LocalNitro1.694s (+102.0% 🔺)2.010s (+98.6% 🔺)0.009s (-2.4%)2.200s (+97.2% 🔺)0.507s281.11x
💻 LocalExpress1.703s (+125.0% 🔺)2.010s (+95.4% 🔺)0.011s (+12.7% 🔺)2.202s (+111.8% 🔺)0.499s281.12x
💻 LocalNext.js (Turbopack)1.871s2.011s0.009s2.203s0.332s281.23x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)5.720s (-66.2% 🟢)6.903s (-62.2% 🟢)0.256s (+20.9% 🔺)7.611s (-59.8% 🟢)1.891s81.00x
▲ VercelNitro5.938s (-79.8% 🟢)7.093s (-77.0% 🟢)0.210s (+87.5% 🔺)7.760s (-75.6% 🟢)1.823s81.04x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.651s (-32.3% 🟢)1.027s (-19.6% 🟢)0.000s (-61.0% 🟢)1.040s (-20.4% 🟢)0.390s591.00x
🐘 PostgresNitro0.674s (-30.4% 🟢)1.070s (-14.2% 🟢)0.000s (-15.8% 🟢)1.077s (-14.3% 🟢)0.403s571.04x
🐘 PostgresNext.js (Turbopack)0.750s1.035s0.000s1.042s0.292s581.15x
💻 LocalNitro1.341s (+9.7% 🔺)2.015s (~)0.001s (+433.3% 🔺)2.018s (~)0.677s302.06x
💻 LocalExpress1.376s (+12.3% 🔺)2.015s (~)0.000s (~)2.018s (~)0.642s302.11x
💻 LocalNext.js (Turbopack)1.482s2.014s0.000s2.017s0.535s302.28x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.886s (-61.8% 🟢)4.959s (-56.9% 🟢)0.002s (+Infinity% 🔺)5.651s (-53.1% 🟢)1.766s111.00x
▲ VercelNitro4.900s (+60.6% 🔺)6.154s (+40.1% 🔺)0.000s (-100.0% 🟢)6.747s (+40.3% 🔺)1.847s91.26x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

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

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.289s (-27.2% 🟢)2.065s (-5.2% 🟢)0.000s (+Infinity% 🔺)2.079s (-5.4% 🟢)0.790s291.00x
🐘 PostgresNitro1.338s (-25.3% 🟢)2.068s (-3.4%)0.000s (+189.7% 🔺)2.079s (-4.4%)0.742s291.04x
🐘 PostgresNext.js (Turbopack)1.566s2.181s0.000s2.203s0.637s281.21x
💻 LocalNitro3.063s (-9.6% 🟢)3.677s (-8.8% 🟢)0.000s (-55.9% 🟢)3.680s (-8.8% 🟢)0.617s172.38x
💻 LocalExpress3.172s (-8.5% 🟢)3.904s (-3.2%)0.001s (-37.5% 🟢)3.908s (-3.2%)0.736s162.46x
💻 LocalNext.js (Turbopack)3.190s3.337s0.001s3.612s0.422s192.47x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.807s (+41.8% 🔺)7.135s (+32.8% 🔺)0.000s (+37.5% 🔺)7.605s (+31.3% 🔺)1.798s81.00x
▲ VercelNext.js (Turbopack)6.464s (+15.1% 🔺)7.633s (+9.3% 🔺)0.000s (-100.0% 🟢)8.268s (+9.6% 🔺)1.804s81.11x
▲ VercelExpress⚠️missing-----

🔍 Observability: Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNitro16/21
🐘 PostgresExpress16/21
▲ VercelNitro12/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres16/21
Next.js (Turbopack)🐘 Postgres15/21
Nitro🐘 Postgres13/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.

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

Should we memoize the getWritable() return value instead? Seems simpler to me.

@VaguelySerious

Copy link
Copy Markdown
MemberAuthor

@TooTallNate doing now

Drop the per-call guard mechanism and `pollSharedWritableLock` helper.
With memoization, the cached pipe's single `state.promise` is sufficient:
in the loop pattern `await writer.write(chunk)` already blocks until each
chunk is flushed (the sink awaits the scheduled flush before resolving),
so by the time the loop ends `pendingOps === 0` and the final
`releaseLock()` lets `pollWritableLock` resolve the state. One writable,
one pipe, one ops entry per `(runId, namespace)`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@TooTallNateTooTallNate 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 — clean fix, my earlier "memoize the return value" suggestion landed in 40106be78

The bug is real and the fix matches the issue exactly: caching serialize.writable per (runId, namespace) on the step context so repeat calls within the same step go through one serial sink instead of spawning racing pipes that fight for the same (runId, name) server stream.

What I verified

  • pnpm install --frozen-lockfile
  • pnpm turbo run build --filter @workflow/core
  • pnpm --filter @workflow/core test ✓ (1013/1013)
  • cd packages/core && pnpm exec vitest run src/step/writable-stream.test.ts ✓ (5/5 — 2 existing + 3 new)
  • CI: 65 E2E jobs pass, Unit Tests pass, Windows Tests pass, single Benchmark Vercel (express) flake unrelated to this change

Test coverage looks great

The three new tests pin the contract from three angles:

  1. returns the same writable for repeat calls with the same namespace — direct identity assertion (and distinct namespaces still get distinct writables)
  2. preserves chunk order across per-write getWritable() calls in a loop — exact repro of the issue's 'nov', 'o', ' e', '2', 'e', ' ok' scenario, decoded through the matching deserialize stream
  3. registers exactly one pipe per (runId, namespace), regardless of call count — asserts ops.length === 2 (one shared + one for the distinct namespace)

The chunk-order test is particularly good because it goes through the full serialize → record → deserialize round trip rather than asserting against mock internals.

Per-call ctx.ops guards — no longer needed

The simplification from 0d076e0c40106be78 correctly drops per-caller ops registration. Since all callers now share one state.promise, that one promise already covers every writer that holds the lock — when any writer has the lock the pipe is active, when none do the state resolves. The "wait for every caller's writes to drain" semantics fall out for free.

One subtle behavior change worth calling out

A user who does getWritable().close() and then calls getWritable() again will now get back the same closed writable instead of a fresh one. Pre-PR, the second call would have created a new writable they could continue writing to. Post-PR, that second call returns a closed handle.

I don't think anyone is actually depending on this — the issue describes the bug as users not realizing re-acquisition was unsafe, so "close then re-acquire to keep writing" is unlikely to be a real pattern. The AI SDK / per-chunk-loop case (which IS the motivating example) only does releaseLock(), not close(). So this should be fine in practice. Worth mentioning in case anyone hits it post-release.

Branch hygiene note (not a blocker)

The branch is forked from a point before #2091 landed, so the GitHub diff against current main shows misleading "deletions" of .changeset/changelog.mjs, .changeset/config.json's changelog generator reference, and 5 other unrelated changesets. Those are all artifacts of being stale — the PR's actual tree doesn't have those files at all (because they were added on main after the branch point), so a fresh sync with main would make the diff display correctly. Squash-merge will produce a clean single commit regardless, so this doesn't affect what lands. Just flagging because the github UI looks scary at first glance.

Approving.

@VaguelySerious
VaguelySerious merged commit 2050656 into mainMay 23, 2026
185 of 192 checks passed
@VaguelySerious
VaguelySerious deleted the peter/issue-2058 branch May 23, 2026 07:52
@github-actionsgithub-actionsBot mentioned this pull request May 28, 2026
Sign up for freeto 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.

getWritable() returns a new TransformStream per call — racing pipes reorder chunks when callers acquire per-write

2 participants

@VaguelySerious@TooTallNate
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [core] Memoize `getWritable()` to fix chunk reordering when called repeatedly by VaguelySerious · Pull Request #2086 · vercel/workflow · GitHub
Skip to content

[core] Memoize getWritable() to fix chunk reordering when called repeatedly - #2086

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/issue-2058
May 23, 2026
Merged

[core] Memoize getWritable() to fix chunk reordering when called repeatedly#2086
VaguelySerious merged 2 commits into
mainfrom
peter/issue-2058

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented May 22, 2026

Copy link
Copy Markdown
Member

Fixes#2058, where calling getWritable multiple times in the same function causes chunk re-ordering without any user-facing warnings. I'm not sure if this is the right approach or we should just throw an error or warn.

The issue

Each call to getWritable() constructed a fresh TransformStream + WorkflowServerWritableStream + background flushablePipe(...), all flushing to the same (runId, name). On Vercel the 50-100ms HTTP write latency turned the inter-pipe race into deterministic reordering — most visibly when callers acquired a fresh writer per chunk (e.g. an AI SDK text-delta loop). Locally world-local made it look fine because filesystem writes are effectively instant.

The fix

  • Share a single TransformStream + flushablePipe per (runId, namespace) within a step context so repeat calls to getWritable() no longer spawn racing pipes.
  • Add per-call guards on ctx.ops so the step still waits for every caller's writes to drain.

Repeat calls to `getWritable()` from the same step previously spawned
independent TransformStream + flushablePipe pairs that all flushed to
the same (runId, name) on the server. On Vercel the 50-100ms HTTP write
latency turned the inter-pipe race into deterministic reordering — most
visibly when callers acquired a fresh writer per chunk (e.g. an AI SDK
text-delta loop). Locally the world-local filesystem path made the race
invisible.
Cache the writable + pipe state per (runId, namespace) in the step
context so repeat calls share one serial sink. Each call still registers
a per-call guard in ctx.ops so the step waits for every caller's writes
to flush before completing, matching the prior multi-pipe semantics.
Closes#2058
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel

vercelBot commented May 22, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented May 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 40106be

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

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
workflowPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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-actionsBot commented May 22, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production122202191441
✅ 💻 Local Development161502191834
✅ 📦 Local Production161502191834
✅ 🐘 Local Postgres161502191834
✅ 🪟 Windows13100131
✅ 📋 Other7410176917
Total6939010527991

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro105026
✅ example105026
✅ express105026
✅ fastify105026
✅ hono105026
✅ nextjs-turbopack12902
✅ nextjs-webpack12902
✅ nitro105026
✅ nuxt105026
✅ sveltekit12407
✅ vite105026
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable106025
✅ express-stable106025
✅ fastify-stable106025
✅ hono-stable106025
✅ nextjs-turbopack-canary112019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13100
✅ nextjs-turbopack-stable-lazy-discovery-enabled13100
✅ nextjs-webpack-canary112019
✅ nextjs-webpack-stable-lazy-discovery-disabled13100
✅ nextjs-webpack-stable-lazy-discovery-enabled13100
✅ nitro-stable106025
✅ nuxt-stable106025
✅ sveltekit-stable12506
✅ vite-stable106025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable106025
✅ express-stable106025
✅ fastify-stable106025
✅ hono-stable106025
✅ nextjs-turbopack-canary112019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13100
✅ nextjs-turbopack-stable-lazy-discovery-enabled13100
✅ nextjs-webpack-canary112019
✅ nextjs-webpack-stable-lazy-discovery-disabled13100
✅ nextjs-webpack-stable-lazy-discovery-enabled13100
✅ nitro-stable106025
✅ nuxt-stable106025
✅ sveltekit-stable12506
✅ vite-stable106025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable106025
✅ express-stable106025
✅ fastify-stable106025
✅ hono-stable106025
✅ nextjs-turbopack-canary112019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13100
✅ nextjs-turbopack-stable-lazy-discovery-enabled13100
✅ nextjs-webpack-canary112019
✅ nextjs-webpack-stable-lazy-discovery-disabled13100
✅ nextjs-webpack-stable-lazy-discovery-enabled13100
✅ nitro-stable106025
✅ nuxt-stable106025
✅ sveltekit-stable12506
✅ vite-stable106025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack13100
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable106025
✅ e2e-local-dev-tanstack-start-106025
✅ e2e-local-postgres-nest-stable106025
✅ e2e-local-postgres-tanstack-start-106025
✅ e2e-local-prod-nest-stable106025
✅ e2e-local-prod-tanstack-start-106025
✅ e2e-vercel-prod-tanstack-start105026

📋 View full workflow run

@github-actions

github-actionsBot commented May 22, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

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

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro0.029s (-32.7% 🟢)1.005s (~)0.976s101.00x
💻 LocalExpress0.030s (-32.3% 🟢)1.006s (~)0.976s101.03x
💻 LocalNext.js (Turbopack)0.049s1.006s0.957s101.69x
🐘 PostgresNitro0.050s (-47.6% 🟢)1.012s (-3.0%)0.962s101.72x
🐘 PostgresExpress0.051s (-11.9% 🟢)1.012s (~)0.961s101.76x
🐘 PostgresNext.js (Turbopack)0.055s1.010s0.955s101.89x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.260s (+3.2%)2.424s (+3.9%)2.165s101.00x
▲ VercelNitro0.274s (-33.1% 🟢)2.158s (-14.0% 🟢)1.885s101.06x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.066s (-5.3% 🟢)2.006s (~)0.940s101.00x
💻 LocalNitro1.069s (-5.5% 🟢)2.006s (~)0.937s101.00x
🐘 PostgresExpress1.083s (-5.5% 🟢)2.009s (~)0.926s101.02x
🐘 PostgresNitro1.086s (-4.7%)2.009s (~)0.923s101.02x
💻 LocalNext.js (Turbopack)1.107s2.006s0.899s101.04x
🐘 PostgresNext.js (Turbopack)1.123s2.009s0.886s101.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)1.590s (-21.9% 🟢)3.378s (-11.8% 🟢)1.788s101.00x
▲ VercelNitro1.608s (-58.7% 🟢)3.228s (-45.4% 🟢)1.620s101.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro10.397s (-5.0% 🟢)11.022s (~)0.625s31.00x
💻 LocalExpress10.408s (-4.7%)11.023s (~)0.615s31.00x
🐘 PostgresExpress10.413s (-5.0% 🟢)11.013s (~)0.600s31.00x
🐘 PostgresNitro10.451s (-3.9%)11.021s (~)0.570s31.01x
💻 LocalNext.js (Turbopack)10.660s11.022s0.363s31.03x
🐘 PostgresNext.js (Turbopack)10.710s11.017s0.307s31.03x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro13.267s (-44.1% 🟢)14.695s (-41.5% 🟢)1.428s31.00x
▲ VercelNext.js (Turbopack)13.473s (-22.2% 🟢)14.947s (-22.9% 🟢)1.474s31.02x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro13.429s (-10.8% 🟢)14.027s (-12.5% 🟢)0.598s51.00x
💻 LocalExpress13.457s (-10.1% 🟢)14.028s (-6.7% 🟢)0.571s51.00x
🐘 PostgresNitro13.469s (-7.7% 🟢)14.019s (-6.7% 🟢)0.550s51.00x
🐘 PostgresExpress13.471s (-7.6% 🟢)14.018s (-6.7% 🟢)0.547s51.00x
💻 LocalNext.js (Turbopack)14.075s15.031s0.956s41.05x
🐘 PostgresNext.js (Turbopack)14.118s15.016s0.898s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro21.328s (-66.9% 🟢)22.835s (-65.7% 🟢)1.507s31.00x
▲ VercelNext.js (Turbopack)22.554s (-57.1% 🟢)24.075s (-55.9% 🟢)1.521s31.06x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro11.788s (-29.8% 🟢)12.022s (-29.4% 🟢)0.234s81.00x
🐘 PostgresExpress11.906s (-15.0% 🟢)12.145s (-16.8% 🟢)0.239s81.01x
💻 LocalExpress11.932s (-28.1% 🟢)12.150s (-28.7% 🟢)0.218s81.01x
🐘 PostgresNitro11.972s (-14.3% 🟢)12.268s (-14.3% 🟢)0.296s81.02x
💻 LocalNext.js (Turbopack)13.074s14.028s0.954s71.11x
🐘 PostgresNext.js (Turbopack)13.117s13.733s0.616s71.11x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro31.064s (-92.7% 🟢)32.780s (-92.3% 🟢)1.716s31.00x
▲ VercelNext.js (Turbopack)34.306s (-91.3% 🟢)36.360s (-90.8% 🟢)2.055s31.10x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.137s (-9.8% 🟢)2.008s (~)0.871s151.00x
💻 LocalNitro1.165s (-28.6% 🟢)2.006s (-3.3%)0.841s151.02x
💻 LocalExpress1.174s (-21.1% 🟢)2.006s (~)0.832s151.03x
🐘 PostgresNitro1.184s (-7.1% 🟢)2.007s (~)0.823s151.04x
🐘 PostgresNext.js (Turbopack)1.202s2.007s0.805s151.06x
💻 LocalNext.js (Turbopack)1.285s2.006s0.721s151.13x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.789s (-17.9% 🟢)4.385s (-11.1% 🟢)1.596s71.00x
▲ VercelNitro2.886s (+2.4%)4.249s (-1.7%)1.363s81.03x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.196s (-49.4% 🟢)2.007s (-33.3% 🟢)0.811s151.00x
🐘 PostgresNitro1.216s (-48.3% 🟢)2.007s (-33.3% 🟢)0.791s151.02x
🐘 PostgresNext.js (Turbopack)1.349s2.008s0.659s151.13x
💻 LocalNitro1.677s (-46.6% 🟢)2.006s (-48.4% 🟢)0.328s151.40x
💻 LocalNext.js (Turbopack)1.680s2.006s0.326s151.40x
💻 LocalExpress1.712s (-42.0% 🟢)2.006s (-41.9% 🟢)0.294s151.43x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.701s (-47.9% 🟢)5.327s (-40.2% 🟢)1.625s61.00x
▲ VercelNitro3.953s (-2.5%)5.162s (-12.8% 🟢)1.210s61.07x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.329s (-61.9% 🟢)2.008s (-49.9% 🟢)0.679s151.00x
🐘 PostgresNitro1.346s (-61.3% 🟢)2.006s (-49.9% 🟢)0.660s151.01x
🐘 PostgresNext.js (Turbopack)1.584s2.007s0.423s151.19x
💻 LocalNitro4.494s (-46.2% 🟢)5.011s (-44.5% 🟢)0.516s73.38x
💻 LocalNext.js (Turbopack)4.764s5.178s0.414s63.58x
💻 LocalExpress4.815s (-42.3% 🟢)5.179s (-42.6% 🟢)0.364s63.62x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)5.312s (-40.4% 🟢)7.208s (-34.2% 🟢)1.896s51.00x
▲ VercelNitro6.035s (+71.2% 🔺)7.638s (+38.0% 🔺)1.603s41.14x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.145s (-8.9% 🟢)2.008s (~)0.863s151.00x
🐘 PostgresExpress1.156s (-8.1% 🟢)2.008s (~)0.852s151.01x
🐘 PostgresNext.js (Turbopack)1.203s2.007s0.804s151.05x
💻 LocalNext.js (Turbopack)1.324s2.006s0.682s151.16x
💻 LocalNitro1.393s (-25.4% 🟢)2.006s (-14.3% 🟢)0.613s151.22x
💻 LocalExpress1.405s (-25.8% 🟢)2.006s (-15.1% 🟢)0.600s151.23x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.604s (+5.9% 🔺)4.035s (-3.2%)1.431s81.00x
▲ VercelNext.js (Turbopack)2.746s (-6.3% 🟢)4.374s (-5.8% 🟢)1.628s71.05x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.208s (-48.4% 🟢)2.009s (-33.2% 🟢)0.802s151.00x
🐘 PostgresExpress1.210s (-48.3% 🟢)2.009s (-33.3% 🟢)0.799s151.00x
🐘 PostgresNext.js (Turbopack)1.323s2.008s0.685s151.10x
💻 LocalExpress2.041s (-34.8% 🟢)2.469s (-34.4% 🟢)0.428s131.69x
💻 LocalNext.js (Turbopack)2.061s2.826s0.766s111.71x
💻 LocalNitro2.063s (-32.7% 🟢)2.593s (-33.3% 🟢)0.530s121.71x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.297s (+2.0%)4.707s (-7.3% 🟢)1.410s71.00x
▲ VercelNext.js (Turbopack)4.012s (+27.7% 🔺)5.468s (+20.9% 🔺)1.456s61.22x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.318s (-62.3% 🟢)2.008s (-49.9% 🟢)0.689s151.00x
🐘 PostgresNitro1.326s (-61.9% 🟢)2.009s (-49.9% 🟢)0.683s151.01x
🐘 PostgresNext.js (Turbopack)1.567s2.008s0.441s151.19x
💻 LocalExpress4.705s (-46.5% 🟢)5.348s (-42.3% 🟢)0.644s63.57x
💻 LocalNitro5.421s (-40.7% 🟢)6.013s (-40.0% 🟢)0.592s54.11x
💻 LocalNext.js (Turbopack)5.757s6.215s0.458s54.37x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.000s (-1.8%)6.567s (-3.7%)1.566s51.00x
▲ VercelNext.js (Turbopack)6.349s (-6.0% 🟢)8.030s (-6.0% 🟢)1.681s41.27x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.438s (-47.8% 🟢)1.007s (-1.6%)0.569s601.00x
💻 LocalNitro0.462s (-52.9% 🟢)1.004s (-8.2% 🟢)0.541s601.06x
🐘 PostgresNitro0.483s (-41.1% 🟢)1.024s (+1.8%)0.541s591.10x
💻 LocalExpress0.511s (-48.1% 🟢)1.039s (-3.5%)0.527s581.17x
🐘 PostgresNext.js (Turbopack)0.656s1.006s0.350s601.50x
💻 LocalNext.js (Turbopack)0.757s1.004s0.247s601.73x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.423s (-75.4% 🟢)6.932s (-71.1% 🟢)1.510s91.00x
▲ VercelNext.js (Turbopack)5.556s (-61.7% 🟢)7.267s (-54.8% 🟢)1.711s91.02x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.023s (-48.2% 🟢)1.549s (-31.4% 🟢)0.526s591.00x
🐘 PostgresNitro1.091s (-43.4% 🟢)1.903s (-9.4% 🟢)0.812s481.07x
💻 LocalNitro1.166s (-61.6% 🟢)2.005s (-46.6% 🟢)0.839s451.14x
💻 LocalExpress1.203s (-60.1% 🟢)2.006s (-44.1% 🟢)0.802s451.18x
🐘 PostgresNext.js (Turbopack)1.576s2.007s0.431s451.54x
💻 LocalNext.js (Turbopack)1.842s2.051s0.209s441.80x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro12.637s (-68.0% 🟢)14.394s (-65.1% 🟢)1.757s71.00x
▲ VercelNext.js (Turbopack)13.164s (-73.6% 🟢)15.106s (-70.8% 🟢)1.942s71.04x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.994s (-50.0% 🟢)2.335s (-46.6% 🟢)0.342s521.00x
🐘 PostgresNitro2.150s (-47.6% 🟢)2.799s (-39.2% 🟢)0.649s431.08x
💻 LocalNitro2.645s (-71.6% 🟢)3.007s (-70.0% 🟢)0.362s401.33x
💻 LocalExpress2.743s (-70.2% 🟢)3.033s (-69.7% 🟢)0.290s401.38x
🐘 PostgresNext.js (Turbopack)3.120s4.010s0.890s301.56x
💻 LocalNext.js (Turbopack)3.899s4.215s0.316s291.96x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro25.288s (-73.9% 🟢)27.188s (-72.4% 🟢)1.900s51.00x
▲ VercelNext.js (Turbopack)27.396s (-74.4% 🟢)29.427s (-73.0% 🟢)2.031s51.08x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.176s (-37.8% 🟢)1.006s (~)0.829s601.00x
🐘 PostgresExpress0.180s (-36.1% 🟢)1.006s (~)0.825s601.02x
🐘 PostgresNext.js (Turbopack)0.226s1.006s0.780s601.28x
💻 LocalExpress0.392s (-30.0% 🟢)1.004s (~)0.612s602.22x
💻 LocalNitro0.395s (-34.7% 🟢)1.004s (-1.7%)0.609s602.24x
💻 LocalNext.js (Turbopack)0.549s1.004s0.455s603.11x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.378s (+43.2% 🔺)3.815s (+13.8% 🔺)1.437s161.00x
▲ VercelNext.js (Turbopack)2.893s (+43.0% 🔺)4.315s (+13.7% 🔺)1.422s141.22x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.301s (-41.0% 🟢)1.006s (~)0.705s901.00x
🐘 PostgresNitro0.305s (-38.6% 🟢)1.006s (~)0.702s901.01x
🐘 PostgresNext.js (Turbopack)0.422s1.006s0.584s901.40x
💻 LocalNitro2.135s (-15.9% 🟢)2.715s (-9.8% 🟢)0.580s347.09x
💻 LocalExpress2.178s (-13.3% 🟢)2.796s (-7.1% 🟢)0.618s337.24x
💻 LocalNext.js (Turbopack)2.381s3.010s0.630s307.91x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.031s (+56.0% 🔺)6.621s (+37.3% 🔺)1.590s141.00x
▲ VercelNext.js (Turbopack)5.780s (+63.5% 🔺)7.417s (+42.8% 🔺)1.636s131.15x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.617s (-24.7% 🟢)1.006s (-1.1%)0.389s1201.00x
🐘 PostgresNitro0.661s (-16.4% 🟢)1.006s (~)0.345s1201.07x
🐘 PostgresNext.js (Turbopack)0.866s1.023s0.157s1181.40x
💻 LocalNitro9.855s (-11.9% 🟢)10.275s (-11.9% 🟢)0.420s1215.98x
💻 LocalExpress10.320s (-7.8% 🟢)10.781s (-9.7% 🟢)0.461s1216.73x
💻 LocalNext.js (Turbopack)11.376s11.939s0.563s1118.44x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)16.735s (+62.0% 🔺)18.540s (+50.9% 🔺)1.805s71.00x
▲ VercelNitro24.069s (+211.7% 🔺)25.599s (+172.3% 🔺)1.529s51.44x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro1.130s (+428.7% 🔺)2.005s (+99.6% 🔺)0.010s (-18.4% 🟢)2.017s (+98.0% 🔺)0.887s101.00x
💻 LocalExpress1.142s (+473.5% 🔺)2.005s (+99.6% 🔺)0.011s (-13.2% 🟢)2.018s (+98.2% 🔺)0.876s101.01x
🐘 PostgresExpress1.145s (+458.5% 🔺)1.997s (+99.9% 🔺)0.001s (-31.3% 🟢)2.010s (+98.7% 🔺)0.864s101.01x
🐘 PostgresNitro1.159s (+465.3% 🔺)2.002s (+100.2% 🔺)0.002s (+13.3% 🔺)2.011s (+98.9% 🔺)0.852s101.03x
💻 LocalNext.js (Turbopack)1.180s2.004s0.011s2.018s0.839s101.04x
🐘 PostgresNext.js (Turbopack)1.196s2.002s0.002s2.010s0.814s101.06x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.306s (-66.4% 🟢)3.176s (-63.3% 🟢)2.309s (+265.5% 🔺)5.934s (-39.4% 🟢)3.628s101.00x
▲ VercelNitro2.375s (-38.0% 🟢)3.202s (-39.3% 🟢)14.034s (+1791.1% 🔺)17.640s (+172.1% 🔺)15.265s101.03x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.519s (+141.2% 🔺)2.003s (+99.0% 🔺)0.004s (+1.8%)2.026s (+98.0% 🔺)0.506s301.00x
🐘 PostgresNitro1.566s (+150.9% 🔺)2.008s (+99.5% 🔺)0.004s (-1.7%)2.025s (+98.1% 🔺)0.459s301.03x
🐘 PostgresNext.js (Turbopack)1.652s2.010s0.004s2.026s0.374s301.09x
💻 LocalNitro1.694s (+102.0% 🔺)2.010s (+98.6% 🔺)0.009s (-2.4%)2.200s (+97.2% 🔺)0.507s281.11x
💻 LocalExpress1.703s (+125.0% 🔺)2.010s (+95.4% 🔺)0.011s (+12.7% 🔺)2.202s (+111.8% 🔺)0.499s281.12x
💻 LocalNext.js (Turbopack)1.871s2.011s0.009s2.203s0.332s281.23x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)5.720s (-66.2% 🟢)6.903s (-62.2% 🟢)0.256s (+20.9% 🔺)7.611s (-59.8% 🟢)1.891s81.00x
▲ VercelNitro5.938s (-79.8% 🟢)7.093s (-77.0% 🟢)0.210s (+87.5% 🔺)7.760s (-75.6% 🟢)1.823s81.04x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.651s (-32.3% 🟢)1.027s (-19.6% 🟢)0.000s (-61.0% 🟢)1.040s (-20.4% 🟢)0.390s591.00x
🐘 PostgresNitro0.674s (-30.4% 🟢)1.070s (-14.2% 🟢)0.000s (-15.8% 🟢)1.077s (-14.3% 🟢)0.403s571.04x
🐘 PostgresNext.js (Turbopack)0.750s1.035s0.000s1.042s0.292s581.15x
💻 LocalNitro1.341s (+9.7% 🔺)2.015s (~)0.001s (+433.3% 🔺)2.018s (~)0.677s302.06x
💻 LocalExpress1.376s (+12.3% 🔺)2.015s (~)0.000s (~)2.018s (~)0.642s302.11x
💻 LocalNext.js (Turbopack)1.482s2.014s0.000s2.017s0.535s302.28x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.886s (-61.8% 🟢)4.959s (-56.9% 🟢)0.002s (+Infinity% 🔺)5.651s (-53.1% 🟢)1.766s111.00x
▲ VercelNitro4.900s (+60.6% 🔺)6.154s (+40.1% 🔺)0.000s (-100.0% 🟢)6.747s (+40.3% 🔺)1.847s91.26x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

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

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.289s (-27.2% 🟢)2.065s (-5.2% 🟢)0.000s (+Infinity% 🔺)2.079s (-5.4% 🟢)0.790s291.00x
🐘 PostgresNitro1.338s (-25.3% 🟢)2.068s (-3.4%)0.000s (+189.7% 🔺)2.079s (-4.4%)0.742s291.04x
🐘 PostgresNext.js (Turbopack)1.566s2.181s0.000s2.203s0.637s281.21x
💻 LocalNitro3.063s (-9.6% 🟢)3.677s (-8.8% 🟢)0.000s (-55.9% 🟢)3.680s (-8.8% 🟢)0.617s172.38x
💻 LocalExpress3.172s (-8.5% 🟢)3.904s (-3.2%)0.001s (-37.5% 🟢)3.908s (-3.2%)0.736s162.46x
💻 LocalNext.js (Turbopack)3.190s3.337s0.001s3.612s0.422s192.47x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.807s (+41.8% 🔺)7.135s (+32.8% 🔺)0.000s (+37.5% 🔺)7.605s (+31.3% 🔺)1.798s81.00x
▲ VercelNext.js (Turbopack)6.464s (+15.1% 🔺)7.633s (+9.3% 🔺)0.000s (-100.0% 🟢)8.268s (+9.6% 🔺)1.804s81.11x
▲ VercelExpress⚠️missing-----

🔍 Observability: Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNitro16/21
🐘 PostgresExpress16/21
▲ VercelNitro12/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres16/21
Next.js (Turbopack)🐘 Postgres15/21
Nitro🐘 Postgres13/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.

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

Should we memoize the getWritable() return value instead? Seems simpler to me.

@VaguelySerious

Copy link
Copy Markdown
MemberAuthor

@TooTallNate doing now

Drop the per-call guard mechanism and `pollSharedWritableLock` helper.
With memoization, the cached pipe's single `state.promise` is sufficient:
in the loop pattern `await writer.write(chunk)` already blocks until each
chunk is flushed (the sink awaits the scheduled flush before resolving),
so by the time the loop ends `pendingOps === 0` and the final
`releaseLock()` lets `pollWritableLock` resolve the state. One writable,
one pipe, one ops entry per `(runId, namespace)`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@TooTallNateTooTallNate 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 — clean fix, my earlier "memoize the return value" suggestion landed in 40106be78

The bug is real and the fix matches the issue exactly: caching serialize.writable per (runId, namespace) on the step context so repeat calls within the same step go through one serial sink instead of spawning racing pipes that fight for the same (runId, name) server stream.

What I verified

  • pnpm install --frozen-lockfile
  • pnpm turbo run build --filter @workflow/core
  • pnpm --filter @workflow/core test ✓ (1013/1013)
  • cd packages/core && pnpm exec vitest run src/step/writable-stream.test.ts ✓ (5/5 — 2 existing + 3 new)
  • CI: 65 E2E jobs pass, Unit Tests pass, Windows Tests pass, single Benchmark Vercel (express) flake unrelated to this change

Test coverage looks great

The three new tests pin the contract from three angles:

  1. returns the same writable for repeat calls with the same namespace — direct identity assertion (and distinct namespaces still get distinct writables)
  2. preserves chunk order across per-write getWritable() calls in a loop — exact repro of the issue's 'nov', 'o', ' e', '2', 'e', ' ok' scenario, decoded through the matching deserialize stream
  3. registers exactly one pipe per (runId, namespace), regardless of call count — asserts ops.length === 2 (one shared + one for the distinct namespace)

The chunk-order test is particularly good because it goes through the full serialize → record → deserialize round trip rather than asserting against mock internals.

Per-call ctx.ops guards — no longer needed

The simplification from 0d076e0c40106be78 correctly drops per-caller ops registration. Since all callers now share one state.promise, that one promise already covers every writer that holds the lock — when any writer has the lock the pipe is active, when none do the state resolves. The "wait for every caller's writes to drain" semantics fall out for free.

One subtle behavior change worth calling out

A user who does getWritable().close() and then calls getWritable() again will now get back the same closed writable instead of a fresh one. Pre-PR, the second call would have created a new writable they could continue writing to. Post-PR, that second call returns a closed handle.

I don't think anyone is actually depending on this — the issue describes the bug as users not realizing re-acquisition was unsafe, so "close then re-acquire to keep writing" is unlikely to be a real pattern. The AI SDK / per-chunk-loop case (which IS the motivating example) only does releaseLock(), not close(). So this should be fine in practice. Worth mentioning in case anyone hits it post-release.

Branch hygiene note (not a blocker)

The branch is forked from a point before #2091 landed, so the GitHub diff against current main shows misleading "deletions" of .changeset/changelog.mjs, .changeset/config.json's changelog generator reference, and 5 other unrelated changesets. Those are all artifacts of being stale — the PR's actual tree doesn't have those files at all (because they were added on main after the branch point), so a fresh sync with main would make the diff display correctly. Squash-merge will produce a clean single commit regardless, so this doesn't affect what lands. Just flagging because the github UI looks scary at first glance.

Approving.

@VaguelySerious
VaguelySerious merged commit 2050656 into mainMay 23, 2026
185 of 192 checks passed
@VaguelySerious
VaguelySerious deleted the peter/issue-2058 branch May 23, 2026 07:52
@github-actionsgithub-actionsBot mentioned this pull request May 28, 2026
Sign up for freeto 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.

getWritable() returns a new TransformStream per call — racing pipes reorder chunks when callers acquire per-write

2 participants

@VaguelySerious@TooTallNate
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [core] Memoize `getWritable()` to fix chunk reordering when called repeatedly by VaguelySerious · Pull Request #2086 · vercel/workflow · GitHub
Skip to content

[core] Memoize getWritable() to fix chunk reordering when called repeatedly - #2086

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/issue-2058
May 23, 2026
Merged

[core] Memoize getWritable() to fix chunk reordering when called repeatedly#2086
VaguelySerious merged 2 commits into
mainfrom
peter/issue-2058

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented May 22, 2026

Copy link
Copy Markdown
Member

Fixes#2058, where calling getWritable multiple times in the same function causes chunk re-ordering without any user-facing warnings. I'm not sure if this is the right approach or we should just throw an error or warn.

The issue

Each call to getWritable() constructed a fresh TransformStream + WorkflowServerWritableStream + background flushablePipe(...), all flushing to the same (runId, name). On Vercel the 50-100ms HTTP write latency turned the inter-pipe race into deterministic reordering — most visibly when callers acquired a fresh writer per chunk (e.g. an AI SDK text-delta loop). Locally world-local made it look fine because filesystem writes are effectively instant.

The fix

  • Share a single TransformStream + flushablePipe per (runId, namespace) within a step context so repeat calls to getWritable() no longer spawn racing pipes.
  • Add per-call guards on ctx.ops so the step still waits for every caller's writes to drain.

Repeat calls to `getWritable()` from the same step previously spawned
independent TransformStream + flushablePipe pairs that all flushed to
the same (runId, name) on the server. On Vercel the 50-100ms HTTP write
latency turned the inter-pipe race into deterministic reordering — most
visibly when callers acquired a fresh writer per chunk (e.g. an AI SDK
text-delta loop). Locally the world-local filesystem path made the race
invisible.
Cache the writable + pipe state per (runId, namespace) in the step
context so repeat calls share one serial sink. Each call still registers
a per-call guard in ctx.ops so the step waits for every caller's writes
to flush before completing, matching the prior multi-pipe semantics.
Closes#2058
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel

vercelBot commented May 22, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented May 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 40106be

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

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
workflowPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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-actionsBot commented May 22, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production122202191441
✅ 💻 Local Development161502191834
✅ 📦 Local Production161502191834
✅ 🐘 Local Postgres161502191834
✅ 🪟 Windows13100131
✅ 📋 Other7410176917
Total6939010527991

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro105026
✅ example105026
✅ express105026
✅ fastify105026
✅ hono105026
✅ nextjs-turbopack12902
✅ nextjs-webpack12902
✅ nitro105026
✅ nuxt105026
✅ sveltekit12407
✅ vite105026
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable106025
✅ express-stable106025
✅ fastify-stable106025
✅ hono-stable106025
✅ nextjs-turbopack-canary112019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13100
✅ nextjs-turbopack-stable-lazy-discovery-enabled13100
✅ nextjs-webpack-canary112019
✅ nextjs-webpack-stable-lazy-discovery-disabled13100
✅ nextjs-webpack-stable-lazy-discovery-enabled13100
✅ nitro-stable106025
✅ nuxt-stable106025
✅ sveltekit-stable12506
✅ vite-stable106025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable106025
✅ express-stable106025
✅ fastify-stable106025
✅ hono-stable106025
✅ nextjs-turbopack-canary112019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13100
✅ nextjs-turbopack-stable-lazy-discovery-enabled13100
✅ nextjs-webpack-canary112019
✅ nextjs-webpack-stable-lazy-discovery-disabled13100
✅ nextjs-webpack-stable-lazy-discovery-enabled13100
✅ nitro-stable106025
✅ nuxt-stable106025
✅ sveltekit-stable12506
✅ vite-stable106025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable106025
✅ express-stable106025
✅ fastify-stable106025
✅ hono-stable106025
✅ nextjs-turbopack-canary112019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13100
✅ nextjs-turbopack-stable-lazy-discovery-enabled13100
✅ nextjs-webpack-canary112019
✅ nextjs-webpack-stable-lazy-discovery-disabled13100
✅ nextjs-webpack-stable-lazy-discovery-enabled13100
✅ nitro-stable106025
✅ nuxt-stable106025
✅ sveltekit-stable12506
✅ vite-stable106025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack13100
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable106025
✅ e2e-local-dev-tanstack-start-106025
✅ e2e-local-postgres-nest-stable106025
✅ e2e-local-postgres-tanstack-start-106025
✅ e2e-local-prod-nest-stable106025
✅ e2e-local-prod-tanstack-start-106025
✅ e2e-vercel-prod-tanstack-start105026

📋 View full workflow run

@github-actions

github-actionsBot commented May 22, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

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

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro0.029s (-32.7% 🟢)1.005s (~)0.976s101.00x
💻 LocalExpress0.030s (-32.3% 🟢)1.006s (~)0.976s101.03x
💻 LocalNext.js (Turbopack)0.049s1.006s0.957s101.69x
🐘 PostgresNitro0.050s (-47.6% 🟢)1.012s (-3.0%)0.962s101.72x
🐘 PostgresExpress0.051s (-11.9% 🟢)1.012s (~)0.961s101.76x
🐘 PostgresNext.js (Turbopack)0.055s1.010s0.955s101.89x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.260s (+3.2%)2.424s (+3.9%)2.165s101.00x
▲ VercelNitro0.274s (-33.1% 🟢)2.158s (-14.0% 🟢)1.885s101.06x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.066s (-5.3% 🟢)2.006s (~)0.940s101.00x
💻 LocalNitro1.069s (-5.5% 🟢)2.006s (~)0.937s101.00x
🐘 PostgresExpress1.083s (-5.5% 🟢)2.009s (~)0.926s101.02x
🐘 PostgresNitro1.086s (-4.7%)2.009s (~)0.923s101.02x
💻 LocalNext.js (Turbopack)1.107s2.006s0.899s101.04x
🐘 PostgresNext.js (Turbopack)1.123s2.009s0.886s101.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)1.590s (-21.9% 🟢)3.378s (-11.8% 🟢)1.788s101.00x
▲ VercelNitro1.608s (-58.7% 🟢)3.228s (-45.4% 🟢)1.620s101.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro10.397s (-5.0% 🟢)11.022s (~)0.625s31.00x
💻 LocalExpress10.408s (-4.7%)11.023s (~)0.615s31.00x
🐘 PostgresExpress10.413s (-5.0% 🟢)11.013s (~)0.600s31.00x
🐘 PostgresNitro10.451s (-3.9%)11.021s (~)0.570s31.01x
💻 LocalNext.js (Turbopack)10.660s11.022s0.363s31.03x
🐘 PostgresNext.js (Turbopack)10.710s11.017s0.307s31.03x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro13.267s (-44.1% 🟢)14.695s (-41.5% 🟢)1.428s31.00x
▲ VercelNext.js (Turbopack)13.473s (-22.2% 🟢)14.947s (-22.9% 🟢)1.474s31.02x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro13.429s (-10.8% 🟢)14.027s (-12.5% 🟢)0.598s51.00x
💻 LocalExpress13.457s (-10.1% 🟢)14.028s (-6.7% 🟢)0.571s51.00x
🐘 PostgresNitro13.469s (-7.7% 🟢)14.019s (-6.7% 🟢)0.550s51.00x
🐘 PostgresExpress13.471s (-7.6% 🟢)14.018s (-6.7% 🟢)0.547s51.00x
💻 LocalNext.js (Turbopack)14.075s15.031s0.956s41.05x
🐘 PostgresNext.js (Turbopack)14.118s15.016s0.898s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro21.328s (-66.9% 🟢)22.835s (-65.7% 🟢)1.507s31.00x
▲ VercelNext.js (Turbopack)22.554s (-57.1% 🟢)24.075s (-55.9% 🟢)1.521s31.06x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro11.788s (-29.8% 🟢)12.022s (-29.4% 🟢)0.234s81.00x
🐘 PostgresExpress11.906s (-15.0% 🟢)12.145s (-16.8% 🟢)0.239s81.01x
💻 LocalExpress11.932s (-28.1% 🟢)12.150s (-28.7% 🟢)0.218s81.01x
🐘 PostgresNitro11.972s (-14.3% 🟢)12.268s (-14.3% 🟢)0.296s81.02x
💻 LocalNext.js (Turbopack)13.074s14.028s0.954s71.11x
🐘 PostgresNext.js (Turbopack)13.117s13.733s0.616s71.11x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro31.064s (-92.7% 🟢)32.780s (-92.3% 🟢)1.716s31.00x
▲ VercelNext.js (Turbopack)34.306s (-91.3% 🟢)36.360s (-90.8% 🟢)2.055s31.10x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.137s (-9.8% 🟢)2.008s (~)0.871s151.00x
💻 LocalNitro1.165s (-28.6% 🟢)2.006s (-3.3%)0.841s151.02x
💻 LocalExpress1.174s (-21.1% 🟢)2.006s (~)0.832s151.03x
🐘 PostgresNitro1.184s (-7.1% 🟢)2.007s (~)0.823s151.04x
🐘 PostgresNext.js (Turbopack)1.202s2.007s0.805s151.06x
💻 LocalNext.js (Turbopack)1.285s2.006s0.721s151.13x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.789s (-17.9% 🟢)4.385s (-11.1% 🟢)1.596s71.00x
▲ VercelNitro2.886s (+2.4%)4.249s (-1.7%)1.363s81.03x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.196s (-49.4% 🟢)2.007s (-33.3% 🟢)0.811s151.00x
🐘 PostgresNitro1.216s (-48.3% 🟢)2.007s (-33.3% 🟢)0.791s151.02x
🐘 PostgresNext.js (Turbopack)1.349s2.008s0.659s151.13x
💻 LocalNitro1.677s (-46.6% 🟢)2.006s (-48.4% 🟢)0.328s151.40x
💻 LocalNext.js (Turbopack)1.680s2.006s0.326s151.40x
💻 LocalExpress1.712s (-42.0% 🟢)2.006s (-41.9% 🟢)0.294s151.43x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.701s (-47.9% 🟢)5.327s (-40.2% 🟢)1.625s61.00x
▲ VercelNitro3.953s (-2.5%)5.162s (-12.8% 🟢)1.210s61.07x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.329s (-61.9% 🟢)2.008s (-49.9% 🟢)0.679s151.00x
🐘 PostgresNitro1.346s (-61.3% 🟢)2.006s (-49.9% 🟢)0.660s151.01x
🐘 PostgresNext.js (Turbopack)1.584s2.007s0.423s151.19x
💻 LocalNitro4.494s (-46.2% 🟢)5.011s (-44.5% 🟢)0.516s73.38x
💻 LocalNext.js (Turbopack)4.764s5.178s0.414s63.58x
💻 LocalExpress4.815s (-42.3% 🟢)5.179s (-42.6% 🟢)0.364s63.62x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)5.312s (-40.4% 🟢)7.208s (-34.2% 🟢)1.896s51.00x
▲ VercelNitro6.035s (+71.2% 🔺)7.638s (+38.0% 🔺)1.603s41.14x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.145s (-8.9% 🟢)2.008s (~)0.863s151.00x
🐘 PostgresExpress1.156s (-8.1% 🟢)2.008s (~)0.852s151.01x
🐘 PostgresNext.js (Turbopack)1.203s2.007s0.804s151.05x
💻 LocalNext.js (Turbopack)1.324s2.006s0.682s151.16x
💻 LocalNitro1.393s (-25.4% 🟢)2.006s (-14.3% 🟢)0.613s151.22x
💻 LocalExpress1.405s (-25.8% 🟢)2.006s (-15.1% 🟢)0.600s151.23x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.604s (+5.9% 🔺)4.035s (-3.2%)1.431s81.00x
▲ VercelNext.js (Turbopack)2.746s (-6.3% 🟢)4.374s (-5.8% 🟢)1.628s71.05x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.208s (-48.4% 🟢)2.009s (-33.2% 🟢)0.802s151.00x
🐘 PostgresExpress1.210s (-48.3% 🟢)2.009s (-33.3% 🟢)0.799s151.00x
🐘 PostgresNext.js (Turbopack)1.323s2.008s0.685s151.10x
💻 LocalExpress2.041s (-34.8% 🟢)2.469s (-34.4% 🟢)0.428s131.69x
💻 LocalNext.js (Turbopack)2.061s2.826s0.766s111.71x
💻 LocalNitro2.063s (-32.7% 🟢)2.593s (-33.3% 🟢)0.530s121.71x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.297s (+2.0%)4.707s (-7.3% 🟢)1.410s71.00x
▲ VercelNext.js (Turbopack)4.012s (+27.7% 🔺)5.468s (+20.9% 🔺)1.456s61.22x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.318s (-62.3% 🟢)2.008s (-49.9% 🟢)0.689s151.00x
🐘 PostgresNitro1.326s (-61.9% 🟢)2.009s (-49.9% 🟢)0.683s151.01x
🐘 PostgresNext.js (Turbopack)1.567s2.008s0.441s151.19x
💻 LocalExpress4.705s (-46.5% 🟢)5.348s (-42.3% 🟢)0.644s63.57x
💻 LocalNitro5.421s (-40.7% 🟢)6.013s (-40.0% 🟢)0.592s54.11x
💻 LocalNext.js (Turbopack)5.757s6.215s0.458s54.37x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.000s (-1.8%)6.567s (-3.7%)1.566s51.00x
▲ VercelNext.js (Turbopack)6.349s (-6.0% 🟢)8.030s (-6.0% 🟢)1.681s41.27x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.438s (-47.8% 🟢)1.007s (-1.6%)0.569s601.00x
💻 LocalNitro0.462s (-52.9% 🟢)1.004s (-8.2% 🟢)0.541s601.06x
🐘 PostgresNitro0.483s (-41.1% 🟢)1.024s (+1.8%)0.541s591.10x
💻 LocalExpress0.511s (-48.1% 🟢)1.039s (-3.5%)0.527s581.17x
🐘 PostgresNext.js (Turbopack)0.656s1.006s0.350s601.50x
💻 LocalNext.js (Turbopack)0.757s1.004s0.247s601.73x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.423s (-75.4% 🟢)6.932s (-71.1% 🟢)1.510s91.00x
▲ VercelNext.js (Turbopack)5.556s (-61.7% 🟢)7.267s (-54.8% 🟢)1.711s91.02x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.023s (-48.2% 🟢)1.549s (-31.4% 🟢)0.526s591.00x
🐘 PostgresNitro1.091s (-43.4% 🟢)1.903s (-9.4% 🟢)0.812s481.07x
💻 LocalNitro1.166s (-61.6% 🟢)2.005s (-46.6% 🟢)0.839s451.14x
💻 LocalExpress1.203s (-60.1% 🟢)2.006s (-44.1% 🟢)0.802s451.18x
🐘 PostgresNext.js (Turbopack)1.576s2.007s0.431s451.54x
💻 LocalNext.js (Turbopack)1.842s2.051s0.209s441.80x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro12.637s (-68.0% 🟢)14.394s (-65.1% 🟢)1.757s71.00x
▲ VercelNext.js (Turbopack)13.164s (-73.6% 🟢)15.106s (-70.8% 🟢)1.942s71.04x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.994s (-50.0% 🟢)2.335s (-46.6% 🟢)0.342s521.00x
🐘 PostgresNitro2.150s (-47.6% 🟢)2.799s (-39.2% 🟢)0.649s431.08x
💻 LocalNitro2.645s (-71.6% 🟢)3.007s (-70.0% 🟢)0.362s401.33x
💻 LocalExpress2.743s (-70.2% 🟢)3.033s (-69.7% 🟢)0.290s401.38x
🐘 PostgresNext.js (Turbopack)3.120s4.010s0.890s301.56x
💻 LocalNext.js (Turbopack)3.899s4.215s0.316s291.96x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro25.288s (-73.9% 🟢)27.188s (-72.4% 🟢)1.900s51.00x
▲ VercelNext.js (Turbopack)27.396s (-74.4% 🟢)29.427s (-73.0% 🟢)2.031s51.08x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.176s (-37.8% 🟢)1.006s (~)0.829s601.00x
🐘 PostgresExpress0.180s (-36.1% 🟢)1.006s (~)0.825s601.02x
🐘 PostgresNext.js (Turbopack)0.226s1.006s0.780s601.28x
💻 LocalExpress0.392s (-30.0% 🟢)1.004s (~)0.612s602.22x
💻 LocalNitro0.395s (-34.7% 🟢)1.004s (-1.7%)0.609s602.24x
💻 LocalNext.js (Turbopack)0.549s1.004s0.455s603.11x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.378s (+43.2% 🔺)3.815s (+13.8% 🔺)1.437s161.00x
▲ VercelNext.js (Turbopack)2.893s (+43.0% 🔺)4.315s (+13.7% 🔺)1.422s141.22x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.301s (-41.0% 🟢)1.006s (~)0.705s901.00x
🐘 PostgresNitro0.305s (-38.6% 🟢)1.006s (~)0.702s901.01x
🐘 PostgresNext.js (Turbopack)0.422s1.006s0.584s901.40x
💻 LocalNitro2.135s (-15.9% 🟢)2.715s (-9.8% 🟢)0.580s347.09x
💻 LocalExpress2.178s (-13.3% 🟢)2.796s (-7.1% 🟢)0.618s337.24x
💻 LocalNext.js (Turbopack)2.381s3.010s0.630s307.91x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.031s (+56.0% 🔺)6.621s (+37.3% 🔺)1.590s141.00x
▲ VercelNext.js (Turbopack)5.780s (+63.5% 🔺)7.417s (+42.8% 🔺)1.636s131.15x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.617s (-24.7% 🟢)1.006s (-1.1%)0.389s1201.00x
🐘 PostgresNitro0.661s (-16.4% 🟢)1.006s (~)0.345s1201.07x
🐘 PostgresNext.js (Turbopack)0.866s1.023s0.157s1181.40x
💻 LocalNitro9.855s (-11.9% 🟢)10.275s (-11.9% 🟢)0.420s1215.98x
💻 LocalExpress10.320s (-7.8% 🟢)10.781s (-9.7% 🟢)0.461s1216.73x
💻 LocalNext.js (Turbopack)11.376s11.939s0.563s1118.44x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)16.735s (+62.0% 🔺)18.540s (+50.9% 🔺)1.805s71.00x
▲ VercelNitro24.069s (+211.7% 🔺)25.599s (+172.3% 🔺)1.529s51.44x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro1.130s (+428.7% 🔺)2.005s (+99.6% 🔺)0.010s (-18.4% 🟢)2.017s (+98.0% 🔺)0.887s101.00x
💻 LocalExpress1.142s (+473.5% 🔺)2.005s (+99.6% 🔺)0.011s (-13.2% 🟢)2.018s (+98.2% 🔺)0.876s101.01x
🐘 PostgresExpress1.145s (+458.5% 🔺)1.997s (+99.9% 🔺)0.001s (-31.3% 🟢)2.010s (+98.7% 🔺)0.864s101.01x
🐘 PostgresNitro1.159s (+465.3% 🔺)2.002s (+100.2% 🔺)0.002s (+13.3% 🔺)2.011s (+98.9% 🔺)0.852s101.03x
💻 LocalNext.js (Turbopack)1.180s2.004s0.011s2.018s0.839s101.04x
🐘 PostgresNext.js (Turbopack)1.196s2.002s0.002s2.010s0.814s101.06x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.306s (-66.4% 🟢)3.176s (-63.3% 🟢)2.309s (+265.5% 🔺)5.934s (-39.4% 🟢)3.628s101.00x
▲ VercelNitro2.375s (-38.0% 🟢)3.202s (-39.3% 🟢)14.034s (+1791.1% 🔺)17.640s (+172.1% 🔺)15.265s101.03x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.519s (+141.2% 🔺)2.003s (+99.0% 🔺)0.004s (+1.8%)2.026s (+98.0% 🔺)0.506s301.00x
🐘 PostgresNitro1.566s (+150.9% 🔺)2.008s (+99.5% 🔺)0.004s (-1.7%)2.025s (+98.1% 🔺)0.459s301.03x
🐘 PostgresNext.js (Turbopack)1.652s2.010s0.004s2.026s0.374s301.09x
💻 LocalNitro1.694s (+102.0% 🔺)2.010s (+98.6% 🔺)0.009s (-2.4%)2.200s (+97.2% 🔺)0.507s281.11x
💻 LocalExpress1.703s (+125.0% 🔺)2.010s (+95.4% 🔺)0.011s (+12.7% 🔺)2.202s (+111.8% 🔺)0.499s281.12x
💻 LocalNext.js (Turbopack)1.871s2.011s0.009s2.203s0.332s281.23x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)5.720s (-66.2% 🟢)6.903s (-62.2% 🟢)0.256s (+20.9% 🔺)7.611s (-59.8% 🟢)1.891s81.00x
▲ VercelNitro5.938s (-79.8% 🟢)7.093s (-77.0% 🟢)0.210s (+87.5% 🔺)7.760s (-75.6% 🟢)1.823s81.04x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.651s (-32.3% 🟢)1.027s (-19.6% 🟢)0.000s (-61.0% 🟢)1.040s (-20.4% 🟢)0.390s591.00x
🐘 PostgresNitro0.674s (-30.4% 🟢)1.070s (-14.2% 🟢)0.000s (-15.8% 🟢)1.077s (-14.3% 🟢)0.403s571.04x
🐘 PostgresNext.js (Turbopack)0.750s1.035s0.000s1.042s0.292s581.15x
💻 LocalNitro1.341s (+9.7% 🔺)2.015s (~)0.001s (+433.3% 🔺)2.018s (~)0.677s302.06x
💻 LocalExpress1.376s (+12.3% 🔺)2.015s (~)0.000s (~)2.018s (~)0.642s302.11x
💻 LocalNext.js (Turbopack)1.482s2.014s0.000s2.017s0.535s302.28x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.886s (-61.8% 🟢)4.959s (-56.9% 🟢)0.002s (+Infinity% 🔺)5.651s (-53.1% 🟢)1.766s111.00x
▲ VercelNitro4.900s (+60.6% 🔺)6.154s (+40.1% 🔺)0.000s (-100.0% 🟢)6.747s (+40.3% 🔺)1.847s91.26x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

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

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.289s (-27.2% 🟢)2.065s (-5.2% 🟢)0.000s (+Infinity% 🔺)2.079s (-5.4% 🟢)0.790s291.00x
🐘 PostgresNitro1.338s (-25.3% 🟢)2.068s (-3.4%)0.000s (+189.7% 🔺)2.079s (-4.4%)0.742s291.04x
🐘 PostgresNext.js (Turbopack)1.566s2.181s0.000s2.203s0.637s281.21x
💻 LocalNitro3.063s (-9.6% 🟢)3.677s (-8.8% 🟢)0.000s (-55.9% 🟢)3.680s (-8.8% 🟢)0.617s172.38x
💻 LocalExpress3.172s (-8.5% 🟢)3.904s (-3.2%)0.001s (-37.5% 🟢)3.908s (-3.2%)0.736s162.46x
💻 LocalNext.js (Turbopack)3.190s3.337s0.001s3.612s0.422s192.47x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.807s (+41.8% 🔺)7.135s (+32.8% 🔺)0.000s (+37.5% 🔺)7.605s (+31.3% 🔺)1.798s81.00x
▲ VercelNext.js (Turbopack)6.464s (+15.1% 🔺)7.633s (+9.3% 🔺)0.000s (-100.0% 🟢)8.268s (+9.6% 🔺)1.804s81.11x
▲ VercelExpress⚠️missing-----

🔍 Observability: Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNitro16/21
🐘 PostgresExpress16/21
▲ VercelNitro12/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres16/21
Next.js (Turbopack)🐘 Postgres15/21
Nitro🐘 Postgres13/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.

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

Should we memoize the getWritable() return value instead? Seems simpler to me.

@VaguelySerious

Copy link
Copy Markdown
MemberAuthor

@TooTallNate doing now

Drop the per-call guard mechanism and `pollSharedWritableLock` helper.
With memoization, the cached pipe's single `state.promise` is sufficient:
in the loop pattern `await writer.write(chunk)` already blocks until each
chunk is flushed (the sink awaits the scheduled flush before resolving),
so by the time the loop ends `pendingOps === 0` and the final
`releaseLock()` lets `pollWritableLock` resolve the state. One writable,
one pipe, one ops entry per `(runId, namespace)`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@TooTallNateTooTallNate 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 — clean fix, my earlier "memoize the return value" suggestion landed in 40106be78

The bug is real and the fix matches the issue exactly: caching serialize.writable per (runId, namespace) on the step context so repeat calls within the same step go through one serial sink instead of spawning racing pipes that fight for the same (runId, name) server stream.

What I verified

  • pnpm install --frozen-lockfile
  • pnpm turbo run build --filter @workflow/core
  • pnpm --filter @workflow/core test ✓ (1013/1013)
  • cd packages/core && pnpm exec vitest run src/step/writable-stream.test.ts ✓ (5/5 — 2 existing + 3 new)
  • CI: 65 E2E jobs pass, Unit Tests pass, Windows Tests pass, single Benchmark Vercel (express) flake unrelated to this change

Test coverage looks great

The three new tests pin the contract from three angles:

  1. returns the same writable for repeat calls with the same namespace — direct identity assertion (and distinct namespaces still get distinct writables)
  2. preserves chunk order across per-write getWritable() calls in a loop — exact repro of the issue's 'nov', 'o', ' e', '2', 'e', ' ok' scenario, decoded through the matching deserialize stream
  3. registers exactly one pipe per (runId, namespace), regardless of call count — asserts ops.length === 2 (one shared + one for the distinct namespace)

The chunk-order test is particularly good because it goes through the full serialize → record → deserialize round trip rather than asserting against mock internals.

Per-call ctx.ops guards — no longer needed

The simplification from 0d076e0c40106be78 correctly drops per-caller ops registration. Since all callers now share one state.promise, that one promise already covers every writer that holds the lock — when any writer has the lock the pipe is active, when none do the state resolves. The "wait for every caller's writes to drain" semantics fall out for free.

One subtle behavior change worth calling out

A user who does getWritable().close() and then calls getWritable() again will now get back the same closed writable instead of a fresh one. Pre-PR, the second call would have created a new writable they could continue writing to. Post-PR, that second call returns a closed handle.

I don't think anyone is actually depending on this — the issue describes the bug as users not realizing re-acquisition was unsafe, so "close then re-acquire to keep writing" is unlikely to be a real pattern. The AI SDK / per-chunk-loop case (which IS the motivating example) only does releaseLock(), not close(). So this should be fine in practice. Worth mentioning in case anyone hits it post-release.

Branch hygiene note (not a blocker)

The branch is forked from a point before #2091 landed, so the GitHub diff against current main shows misleading "deletions" of .changeset/changelog.mjs, .changeset/config.json's changelog generator reference, and 5 other unrelated changesets. Those are all artifacts of being stale — the PR's actual tree doesn't have those files at all (because they were added on main after the branch point), so a fresh sync with main would make the diff display correctly. Squash-merge will produce a clean single commit regardless, so this doesn't affect what lands. Just flagging because the github UI looks scary at first glance.

Approving.

@VaguelySerious
VaguelySerious merged commit 2050656 into mainMay 23, 2026
185 of 192 checks passed
@VaguelySerious
VaguelySerious deleted the peter/issue-2058 branch May 23, 2026 07:52
@github-actionsgithub-actionsBot mentioned this pull request May 28, 2026
Sign up for freeto 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.

getWritable() returns a new TransformStream per call — racing pipes reorder chunks when callers acquire per-write

2 participants

@VaguelySerious@TooTallNate
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); [core] Memoize `getWritable()` to fix chunk reordering when called repeatedly by VaguelySerious · Pull Request #2086 · vercel/workflow · GitHub
Skip to content

[core] Memoize getWritable() to fix chunk reordering when called repeatedly - #2086

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/issue-2058
May 23, 2026
Merged

[core] Memoize getWritable() to fix chunk reordering when called repeatedly#2086
VaguelySerious merged 2 commits into
mainfrom
peter/issue-2058

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented May 22, 2026

Copy link
Copy Markdown
Member

Fixes#2058, where calling getWritable multiple times in the same function causes chunk re-ordering without any user-facing warnings. I'm not sure if this is the right approach or we should just throw an error or warn.

The issue

Each call to getWritable() constructed a fresh TransformStream + WorkflowServerWritableStream + background flushablePipe(...), all flushing to the same (runId, name). On Vercel the 50-100ms HTTP write latency turned the inter-pipe race into deterministic reordering — most visibly when callers acquired a fresh writer per chunk (e.g. an AI SDK text-delta loop). Locally world-local made it look fine because filesystem writes are effectively instant.

The fix

  • Share a single TransformStream + flushablePipe per (runId, namespace) within a step context so repeat calls to getWritable() no longer spawn racing pipes.
  • Add per-call guards on ctx.ops so the step still waits for every caller's writes to drain.

Repeat calls to `getWritable()` from the same step previously spawned
independent TransformStream + flushablePipe pairs that all flushed to
the same (runId, name) on the server. On Vercel the 50-100ms HTTP write
latency turned the inter-pipe race into deterministic reordering — most
visibly when callers acquired a fresh writer per chunk (e.g. an AI SDK
text-delta loop). Locally the world-local filesystem path made the race
invisible.
Cache the writable + pipe state per (runId, namespace) in the step
context so repeat calls share one serial sink. Each call still registers
a per-call guard in ctx.ops so the step waits for every caller's writes
to flush before completing, matching the prior multi-pipe semantics.
Closes#2058
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel

vercelBot commented May 22, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented May 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 40106be

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

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
workflowPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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-actionsBot commented May 22, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production122202191441
✅ 💻 Local Development161502191834
✅ 📦 Local Production161502191834
✅ 🐘 Local Postgres161502191834
✅ 🪟 Windows13100131
✅ 📋 Other7410176917
Total6939010527991

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro105026
✅ example105026
✅ express105026
✅ fastify105026
✅ hono105026
✅ nextjs-turbopack12902
✅ nextjs-webpack12902
✅ nitro105026
✅ nuxt105026
✅ sveltekit12407
✅ vite105026
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable106025
✅ express-stable106025
✅ fastify-stable106025
✅ hono-stable106025
✅ nextjs-turbopack-canary112019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13100
✅ nextjs-turbopack-stable-lazy-discovery-enabled13100
✅ nextjs-webpack-canary112019
✅ nextjs-webpack-stable-lazy-discovery-disabled13100
✅ nextjs-webpack-stable-lazy-discovery-enabled13100
✅ nitro-stable106025
✅ nuxt-stable106025
✅ sveltekit-stable12506
✅ vite-stable106025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable106025
✅ express-stable106025
✅ fastify-stable106025
✅ hono-stable106025
✅ nextjs-turbopack-canary112019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13100
✅ nextjs-turbopack-stable-lazy-discovery-enabled13100
✅ nextjs-webpack-canary112019
✅ nextjs-webpack-stable-lazy-discovery-disabled13100
✅ nextjs-webpack-stable-lazy-discovery-enabled13100
✅ nitro-stable106025
✅ nuxt-stable106025
✅ sveltekit-stable12506
✅ vite-stable106025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable106025
✅ express-stable106025
✅ fastify-stable106025
✅ hono-stable106025
✅ nextjs-turbopack-canary112019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13100
✅ nextjs-turbopack-stable-lazy-discovery-enabled13100
✅ nextjs-webpack-canary112019
✅ nextjs-webpack-stable-lazy-discovery-disabled13100
✅ nextjs-webpack-stable-lazy-discovery-enabled13100
✅ nitro-stable106025
✅ nuxt-stable106025
✅ sveltekit-stable12506
✅ vite-stable106025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack13100
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable106025
✅ e2e-local-dev-tanstack-start-106025
✅ e2e-local-postgres-nest-stable106025
✅ e2e-local-postgres-tanstack-start-106025
✅ e2e-local-prod-nest-stable106025
✅ e2e-local-prod-tanstack-start-106025
✅ e2e-vercel-prod-tanstack-start105026

📋 View full workflow run

@github-actions

github-actionsBot commented May 22, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

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

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro0.029s (-32.7% 🟢)1.005s (~)0.976s101.00x
💻 LocalExpress0.030s (-32.3% 🟢)1.006s (~)0.976s101.03x
💻 LocalNext.js (Turbopack)0.049s1.006s0.957s101.69x
🐘 PostgresNitro0.050s (-47.6% 🟢)1.012s (-3.0%)0.962s101.72x
🐘 PostgresExpress0.051s (-11.9% 🟢)1.012s (~)0.961s101.76x
🐘 PostgresNext.js (Turbopack)0.055s1.010s0.955s101.89x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.260s (+3.2%)2.424s (+3.9%)2.165s101.00x
▲ VercelNitro0.274s (-33.1% 🟢)2.158s (-14.0% 🟢)1.885s101.06x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.066s (-5.3% 🟢)2.006s (~)0.940s101.00x
💻 LocalNitro1.069s (-5.5% 🟢)2.006s (~)0.937s101.00x
🐘 PostgresExpress1.083s (-5.5% 🟢)2.009s (~)0.926s101.02x
🐘 PostgresNitro1.086s (-4.7%)2.009s (~)0.923s101.02x
💻 LocalNext.js (Turbopack)1.107s2.006s0.899s101.04x
🐘 PostgresNext.js (Turbopack)1.123s2.009s0.886s101.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)1.590s (-21.9% 🟢)3.378s (-11.8% 🟢)1.788s101.00x
▲ VercelNitro1.608s (-58.7% 🟢)3.228s (-45.4% 🟢)1.620s101.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro10.397s (-5.0% 🟢)11.022s (~)0.625s31.00x
💻 LocalExpress10.408s (-4.7%)11.023s (~)0.615s31.00x
🐘 PostgresExpress10.413s (-5.0% 🟢)11.013s (~)0.600s31.00x
🐘 PostgresNitro10.451s (-3.9%)11.021s (~)0.570s31.01x
💻 LocalNext.js (Turbopack)10.660s11.022s0.363s31.03x
🐘 PostgresNext.js (Turbopack)10.710s11.017s0.307s31.03x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro13.267s (-44.1% 🟢)14.695s (-41.5% 🟢)1.428s31.00x
▲ VercelNext.js (Turbopack)13.473s (-22.2% 🟢)14.947s (-22.9% 🟢)1.474s31.02x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro13.429s (-10.8% 🟢)14.027s (-12.5% 🟢)0.598s51.00x
💻 LocalExpress13.457s (-10.1% 🟢)14.028s (-6.7% 🟢)0.571s51.00x
🐘 PostgresNitro13.469s (-7.7% 🟢)14.019s (-6.7% 🟢)0.550s51.00x
🐘 PostgresExpress13.471s (-7.6% 🟢)14.018s (-6.7% 🟢)0.547s51.00x
💻 LocalNext.js (Turbopack)14.075s15.031s0.956s41.05x
🐘 PostgresNext.js (Turbopack)14.118s15.016s0.898s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro21.328s (-66.9% 🟢)22.835s (-65.7% 🟢)1.507s31.00x
▲ VercelNext.js (Turbopack)22.554s (-57.1% 🟢)24.075s (-55.9% 🟢)1.521s31.06x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro11.788s (-29.8% 🟢)12.022s (-29.4% 🟢)0.234s81.00x
🐘 PostgresExpress11.906s (-15.0% 🟢)12.145s (-16.8% 🟢)0.239s81.01x
💻 LocalExpress11.932s (-28.1% 🟢)12.150s (-28.7% 🟢)0.218s81.01x
🐘 PostgresNitro11.972s (-14.3% 🟢)12.268s (-14.3% 🟢)0.296s81.02x
💻 LocalNext.js (Turbopack)13.074s14.028s0.954s71.11x
🐘 PostgresNext.js (Turbopack)13.117s13.733s0.616s71.11x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro31.064s (-92.7% 🟢)32.780s (-92.3% 🟢)1.716s31.00x
▲ VercelNext.js (Turbopack)34.306s (-91.3% 🟢)36.360s (-90.8% 🟢)2.055s31.10x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.137s (-9.8% 🟢)2.008s (~)0.871s151.00x
💻 LocalNitro1.165s (-28.6% 🟢)2.006s (-3.3%)0.841s151.02x
💻 LocalExpress1.174s (-21.1% 🟢)2.006s (~)0.832s151.03x
🐘 PostgresNitro1.184s (-7.1% 🟢)2.007s (~)0.823s151.04x
🐘 PostgresNext.js (Turbopack)1.202s2.007s0.805s151.06x
💻 LocalNext.js (Turbopack)1.285s2.006s0.721s151.13x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.789s (-17.9% 🟢)4.385s (-11.1% 🟢)1.596s71.00x
▲ VercelNitro2.886s (+2.4%)4.249s (-1.7%)1.363s81.03x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.196s (-49.4% 🟢)2.007s (-33.3% 🟢)0.811s151.00x
🐘 PostgresNitro1.216s (-48.3% 🟢)2.007s (-33.3% 🟢)0.791s151.02x
🐘 PostgresNext.js (Turbopack)1.349s2.008s0.659s151.13x
💻 LocalNitro1.677s (-46.6% 🟢)2.006s (-48.4% 🟢)0.328s151.40x
💻 LocalNext.js (Turbopack)1.680s2.006s0.326s151.40x
💻 LocalExpress1.712s (-42.0% 🟢)2.006s (-41.9% 🟢)0.294s151.43x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.701s (-47.9% 🟢)5.327s (-40.2% 🟢)1.625s61.00x
▲ VercelNitro3.953s (-2.5%)5.162s (-12.8% 🟢)1.210s61.07x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.329s (-61.9% 🟢)2.008s (-49.9% 🟢)0.679s151.00x
🐘 PostgresNitro1.346s (-61.3% 🟢)2.006s (-49.9% 🟢)0.660s151.01x
🐘 PostgresNext.js (Turbopack)1.584s2.007s0.423s151.19x
💻 LocalNitro4.494s (-46.2% 🟢)5.011s (-44.5% 🟢)0.516s73.38x
💻 LocalNext.js (Turbopack)4.764s5.178s0.414s63.58x
💻 LocalExpress4.815s (-42.3% 🟢)5.179s (-42.6% 🟢)0.364s63.62x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)5.312s (-40.4% 🟢)7.208s (-34.2% 🟢)1.896s51.00x
▲ VercelNitro6.035s (+71.2% 🔺)7.638s (+38.0% 🔺)1.603s41.14x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.145s (-8.9% 🟢)2.008s (~)0.863s151.00x
🐘 PostgresExpress1.156s (-8.1% 🟢)2.008s (~)0.852s151.01x
🐘 PostgresNext.js (Turbopack)1.203s2.007s0.804s151.05x
💻 LocalNext.js (Turbopack)1.324s2.006s0.682s151.16x
💻 LocalNitro1.393s (-25.4% 🟢)2.006s (-14.3% 🟢)0.613s151.22x
💻 LocalExpress1.405s (-25.8% 🟢)2.006s (-15.1% 🟢)0.600s151.23x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.604s (+5.9% 🔺)4.035s (-3.2%)1.431s81.00x
▲ VercelNext.js (Turbopack)2.746s (-6.3% 🟢)4.374s (-5.8% 🟢)1.628s71.05x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.208s (-48.4% 🟢)2.009s (-33.2% 🟢)0.802s151.00x
🐘 PostgresExpress1.210s (-48.3% 🟢)2.009s (-33.3% 🟢)0.799s151.00x
🐘 PostgresNext.js (Turbopack)1.323s2.008s0.685s151.10x
💻 LocalExpress2.041s (-34.8% 🟢)2.469s (-34.4% 🟢)0.428s131.69x
💻 LocalNext.js (Turbopack)2.061s2.826s0.766s111.71x
💻 LocalNitro2.063s (-32.7% 🟢)2.593s (-33.3% 🟢)0.530s121.71x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.297s (+2.0%)4.707s (-7.3% 🟢)1.410s71.00x
▲ VercelNext.js (Turbopack)4.012s (+27.7% 🔺)5.468s (+20.9% 🔺)1.456s61.22x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.318s (-62.3% 🟢)2.008s (-49.9% 🟢)0.689s151.00x
🐘 PostgresNitro1.326s (-61.9% 🟢)2.009s (-49.9% 🟢)0.683s151.01x
🐘 PostgresNext.js (Turbopack)1.567s2.008s0.441s151.19x
💻 LocalExpress4.705s (-46.5% 🟢)5.348s (-42.3% 🟢)0.644s63.57x
💻 LocalNitro5.421s (-40.7% 🟢)6.013s (-40.0% 🟢)0.592s54.11x
💻 LocalNext.js (Turbopack)5.757s6.215s0.458s54.37x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.000s (-1.8%)6.567s (-3.7%)1.566s51.00x
▲ VercelNext.js (Turbopack)6.349s (-6.0% 🟢)8.030s (-6.0% 🟢)1.681s41.27x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.438s (-47.8% 🟢)1.007s (-1.6%)0.569s601.00x
💻 LocalNitro0.462s (-52.9% 🟢)1.004s (-8.2% 🟢)0.541s601.06x
🐘 PostgresNitro0.483s (-41.1% 🟢)1.024s (+1.8%)0.541s591.10x
💻 LocalExpress0.511s (-48.1% 🟢)1.039s (-3.5%)0.527s581.17x
🐘 PostgresNext.js (Turbopack)0.656s1.006s0.350s601.50x
💻 LocalNext.js (Turbopack)0.757s1.004s0.247s601.73x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.423s (-75.4% 🟢)6.932s (-71.1% 🟢)1.510s91.00x
▲ VercelNext.js (Turbopack)5.556s (-61.7% 🟢)7.267s (-54.8% 🟢)1.711s91.02x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.023s (-48.2% 🟢)1.549s (-31.4% 🟢)0.526s591.00x
🐘 PostgresNitro1.091s (-43.4% 🟢)1.903s (-9.4% 🟢)0.812s481.07x
💻 LocalNitro1.166s (-61.6% 🟢)2.005s (-46.6% 🟢)0.839s451.14x
💻 LocalExpress1.203s (-60.1% 🟢)2.006s (-44.1% 🟢)0.802s451.18x
🐘 PostgresNext.js (Turbopack)1.576s2.007s0.431s451.54x
💻 LocalNext.js (Turbopack)1.842s2.051s0.209s441.80x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro12.637s (-68.0% 🟢)14.394s (-65.1% 🟢)1.757s71.00x
▲ VercelNext.js (Turbopack)13.164s (-73.6% 🟢)15.106s (-70.8% 🟢)1.942s71.04x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.994s (-50.0% 🟢)2.335s (-46.6% 🟢)0.342s521.00x
🐘 PostgresNitro2.150s (-47.6% 🟢)2.799s (-39.2% 🟢)0.649s431.08x
💻 LocalNitro2.645s (-71.6% 🟢)3.007s (-70.0% 🟢)0.362s401.33x
💻 LocalExpress2.743s (-70.2% 🟢)3.033s (-69.7% 🟢)0.290s401.38x
🐘 PostgresNext.js (Turbopack)3.120s4.010s0.890s301.56x
💻 LocalNext.js (Turbopack)3.899s4.215s0.316s291.96x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro25.288s (-73.9% 🟢)27.188s (-72.4% 🟢)1.900s51.00x
▲ VercelNext.js (Turbopack)27.396s (-74.4% 🟢)29.427s (-73.0% 🟢)2.031s51.08x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.176s (-37.8% 🟢)1.006s (~)0.829s601.00x
🐘 PostgresExpress0.180s (-36.1% 🟢)1.006s (~)0.825s601.02x
🐘 PostgresNext.js (Turbopack)0.226s1.006s0.780s601.28x
💻 LocalExpress0.392s (-30.0% 🟢)1.004s (~)0.612s602.22x
💻 LocalNitro0.395s (-34.7% 🟢)1.004s (-1.7%)0.609s602.24x
💻 LocalNext.js (Turbopack)0.549s1.004s0.455s603.11x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.378s (+43.2% 🔺)3.815s (+13.8% 🔺)1.437s161.00x
▲ VercelNext.js (Turbopack)2.893s (+43.0% 🔺)4.315s (+13.7% 🔺)1.422s141.22x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.301s (-41.0% 🟢)1.006s (~)0.705s901.00x
🐘 PostgresNitro0.305s (-38.6% 🟢)1.006s (~)0.702s901.01x
🐘 PostgresNext.js (Turbopack)0.422s1.006s0.584s901.40x
💻 LocalNitro2.135s (-15.9% 🟢)2.715s (-9.8% 🟢)0.580s347.09x
💻 LocalExpress2.178s (-13.3% 🟢)2.796s (-7.1% 🟢)0.618s337.24x
💻 LocalNext.js (Turbopack)2.381s3.010s0.630s307.91x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.031s (+56.0% 🔺)6.621s (+37.3% 🔺)1.590s141.00x
▲ VercelNext.js (Turbopack)5.780s (+63.5% 🔺)7.417s (+42.8% 🔺)1.636s131.15x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.617s (-24.7% 🟢)1.006s (-1.1%)0.389s1201.00x
🐘 PostgresNitro0.661s (-16.4% 🟢)1.006s (~)0.345s1201.07x
🐘 PostgresNext.js (Turbopack)0.866s1.023s0.157s1181.40x
💻 LocalNitro9.855s (-11.9% 🟢)10.275s (-11.9% 🟢)0.420s1215.98x
💻 LocalExpress10.320s (-7.8% 🟢)10.781s (-9.7% 🟢)0.461s1216.73x
💻 LocalNext.js (Turbopack)11.376s11.939s0.563s1118.44x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)16.735s (+62.0% 🔺)18.540s (+50.9% 🔺)1.805s71.00x
▲ VercelNitro24.069s (+211.7% 🔺)25.599s (+172.3% 🔺)1.529s51.44x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro1.130s (+428.7% 🔺)2.005s (+99.6% 🔺)0.010s (-18.4% 🟢)2.017s (+98.0% 🔺)0.887s101.00x
💻 LocalExpress1.142s (+473.5% 🔺)2.005s (+99.6% 🔺)0.011s (-13.2% 🟢)2.018s (+98.2% 🔺)0.876s101.01x
🐘 PostgresExpress1.145s (+458.5% 🔺)1.997s (+99.9% 🔺)0.001s (-31.3% 🟢)2.010s (+98.7% 🔺)0.864s101.01x
🐘 PostgresNitro1.159s (+465.3% 🔺)2.002s (+100.2% 🔺)0.002s (+13.3% 🔺)2.011s (+98.9% 🔺)0.852s101.03x
💻 LocalNext.js (Turbopack)1.180s2.004s0.011s2.018s0.839s101.04x
🐘 PostgresNext.js (Turbopack)1.196s2.002s0.002s2.010s0.814s101.06x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.306s (-66.4% 🟢)3.176s (-63.3% 🟢)2.309s (+265.5% 🔺)5.934s (-39.4% 🟢)3.628s101.00x
▲ VercelNitro2.375s (-38.0% 🟢)3.202s (-39.3% 🟢)14.034s (+1791.1% 🔺)17.640s (+172.1% 🔺)15.265s101.03x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.519s (+141.2% 🔺)2.003s (+99.0% 🔺)0.004s (+1.8%)2.026s (+98.0% 🔺)0.506s301.00x
🐘 PostgresNitro1.566s (+150.9% 🔺)2.008s (+99.5% 🔺)0.004s (-1.7%)2.025s (+98.1% 🔺)0.459s301.03x
🐘 PostgresNext.js (Turbopack)1.652s2.010s0.004s2.026s0.374s301.09x
💻 LocalNitro1.694s (+102.0% 🔺)2.010s (+98.6% 🔺)0.009s (-2.4%)2.200s (+97.2% 🔺)0.507s281.11x
💻 LocalExpress1.703s (+125.0% 🔺)2.010s (+95.4% 🔺)0.011s (+12.7% 🔺)2.202s (+111.8% 🔺)0.499s281.12x
💻 LocalNext.js (Turbopack)1.871s2.011s0.009s2.203s0.332s281.23x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)5.720s (-66.2% 🟢)6.903s (-62.2% 🟢)0.256s (+20.9% 🔺)7.611s (-59.8% 🟢)1.891s81.00x
▲ VercelNitro5.938s (-79.8% 🟢)7.093s (-77.0% 🟢)0.210s (+87.5% 🔺)7.760s (-75.6% 🟢)1.823s81.04x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.651s (-32.3% 🟢)1.027s (-19.6% 🟢)0.000s (-61.0% 🟢)1.040s (-20.4% 🟢)0.390s591.00x
🐘 PostgresNitro0.674s (-30.4% 🟢)1.070s (-14.2% 🟢)0.000s (-15.8% 🟢)1.077s (-14.3% 🟢)0.403s571.04x
🐘 PostgresNext.js (Turbopack)0.750s1.035s0.000s1.042s0.292s581.15x
💻 LocalNitro1.341s (+9.7% 🔺)2.015s (~)0.001s (+433.3% 🔺)2.018s (~)0.677s302.06x
💻 LocalExpress1.376s (+12.3% 🔺)2.015s (~)0.000s (~)2.018s (~)0.642s302.11x
💻 LocalNext.js (Turbopack)1.482s2.014s0.000s2.017s0.535s302.28x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.886s (-61.8% 🟢)4.959s (-56.9% 🟢)0.002s (+Infinity% 🔺)5.651s (-53.1% 🟢)1.766s111.00x
▲ VercelNitro4.900s (+60.6% 🔺)6.154s (+40.1% 🔺)0.000s (-100.0% 🟢)6.747s (+40.3% 🔺)1.847s91.26x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

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

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.289s (-27.2% 🟢)2.065s (-5.2% 🟢)0.000s (+Infinity% 🔺)2.079s (-5.4% 🟢)0.790s291.00x
🐘 PostgresNitro1.338s (-25.3% 🟢)2.068s (-3.4%)0.000s (+189.7% 🔺)2.079s (-4.4%)0.742s291.04x
🐘 PostgresNext.js (Turbopack)1.566s2.181s0.000s2.203s0.637s281.21x
💻 LocalNitro3.063s (-9.6% 🟢)3.677s (-8.8% 🟢)0.000s (-55.9% 🟢)3.680s (-8.8% 🟢)0.617s172.38x
💻 LocalExpress3.172s (-8.5% 🟢)3.904s (-3.2%)0.001s (-37.5% 🟢)3.908s (-3.2%)0.736s162.46x
💻 LocalNext.js (Turbopack)3.190s3.337s0.001s3.612s0.422s192.47x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.807s (+41.8% 🔺)7.135s (+32.8% 🔺)0.000s (+37.5% 🔺)7.605s (+31.3% 🔺)1.798s81.00x
▲ VercelNext.js (Turbopack)6.464s (+15.1% 🔺)7.633s (+9.3% 🔺)0.000s (-100.0% 🟢)8.268s (+9.6% 🔺)1.804s81.11x
▲ VercelExpress⚠️missing-----

🔍 Observability: Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNitro16/21
🐘 PostgresExpress16/21
▲ VercelNitro12/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres16/21
Next.js (Turbopack)🐘 Postgres15/21
Nitro🐘 Postgres13/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.

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

Should we memoize the getWritable() return value instead? Seems simpler to me.

@VaguelySerious

Copy link
Copy Markdown
MemberAuthor

@TooTallNate doing now

Drop the per-call guard mechanism and `pollSharedWritableLock` helper.
With memoization, the cached pipe's single `state.promise` is sufficient:
in the loop pattern `await writer.write(chunk)` already blocks until each
chunk is flushed (the sink awaits the scheduled flush before resolving),
so by the time the loop ends `pendingOps === 0` and the final
`releaseLock()` lets `pollWritableLock` resolve the state. One writable,
one pipe, one ops entry per `(runId, namespace)`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@TooTallNateTooTallNate 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 — clean fix, my earlier "memoize the return value" suggestion landed in 40106be78

The bug is real and the fix matches the issue exactly: caching serialize.writable per (runId, namespace) on the step context so repeat calls within the same step go through one serial sink instead of spawning racing pipes that fight for the same (runId, name) server stream.

What I verified

  • pnpm install --frozen-lockfile
  • pnpm turbo run build --filter @workflow/core
  • pnpm --filter @workflow/core test ✓ (1013/1013)
  • cd packages/core && pnpm exec vitest run src/step/writable-stream.test.ts ✓ (5/5 — 2 existing + 3 new)
  • CI: 65 E2E jobs pass, Unit Tests pass, Windows Tests pass, single Benchmark Vercel (express) flake unrelated to this change

Test coverage looks great

The three new tests pin the contract from three angles:

  1. returns the same writable for repeat calls with the same namespace — direct identity assertion (and distinct namespaces still get distinct writables)
  2. preserves chunk order across per-write getWritable() calls in a loop — exact repro of the issue's 'nov', 'o', ' e', '2', 'e', ' ok' scenario, decoded through the matching deserialize stream
  3. registers exactly one pipe per (runId, namespace), regardless of call count — asserts ops.length === 2 (one shared + one for the distinct namespace)

The chunk-order test is particularly good because it goes through the full serialize → record → deserialize round trip rather than asserting against mock internals.

Per-call ctx.ops guards — no longer needed

The simplification from 0d076e0c40106be78 correctly drops per-caller ops registration. Since all callers now share one state.promise, that one promise already covers every writer that holds the lock — when any writer has the lock the pipe is active, when none do the state resolves. The "wait for every caller's writes to drain" semantics fall out for free.

One subtle behavior change worth calling out

A user who does getWritable().close() and then calls getWritable() again will now get back the same closed writable instead of a fresh one. Pre-PR, the second call would have created a new writable they could continue writing to. Post-PR, that second call returns a closed handle.

I don't think anyone is actually depending on this — the issue describes the bug as users not realizing re-acquisition was unsafe, so "close then re-acquire to keep writing" is unlikely to be a real pattern. The AI SDK / per-chunk-loop case (which IS the motivating example) only does releaseLock(), not close(). So this should be fine in practice. Worth mentioning in case anyone hits it post-release.

Branch hygiene note (not a blocker)

The branch is forked from a point before #2091 landed, so the GitHub diff against current main shows misleading "deletions" of .changeset/changelog.mjs, .changeset/config.json's changelog generator reference, and 5 other unrelated changesets. Those are all artifacts of being stale — the PR's actual tree doesn't have those files at all (because they were added on main after the branch point), so a fresh sync with main would make the diff display correctly. Squash-merge will produce a clean single commit regardless, so this doesn't affect what lands. Just flagging because the github UI looks scary at first glance.

Approving.

@VaguelySerious
VaguelySerious merged commit 2050656 into mainMay 23, 2026
185 of 192 checks passed
@VaguelySerious
VaguelySerious deleted the peter/issue-2058 branch May 23, 2026 07:52
@github-actionsgithub-actionsBot mentioned this pull request May 28, 2026
Sign up for freeto 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.

getWritable() returns a new TransformStream per call — racing pipes reorder chunks when callers acquire per-write

2 participants

@VaguelySerious@TooTallNate