Skip to content

[world] Add stream pagination and metadata endpoints - #1470

Merged
VaguelySerious merged 32 commits into
mainfrom
peter/stream-metadata-and-pages
Mar 24, 2026
Merged

[world] Add stream pagination and metadata endpoints#1470
VaguelySerious merged 32 commits into
mainfrom
peter/stream-metadata-and-pages

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

No description provided.

VaguelySeriousand others added 13 commits March 19, 2026 17:44
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Negative startIndex values (e.g. -3) resolve to n chunks before the
known end of the stream. All world implementations (local, postgres,
vercel) support this. Includes unit tests, e2e test, and doc updates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Replaces three separate tests (no startIndex, positive, negative) with a
single describe block that iterates over startIndex cases. Fixes the
negative startIndex test by waiting for workflow completion before
connecting the reader — the backend resolves negative indices at
connection time using knownChunkCount, which is 0 if the stream hasn't
been fully written yet.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Replaces three separate tests (no startIndex, positive, negative) with a
single describe block that iterates over startIndex cases. Fixes the
negative startIndex test by waiting for workflow completion before
connecting the reader — the backend resolves negative indices at
connection time using knownChunkCount, which is 0 if the stream hasn't
been fully written yet.
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
- Clarify that negative startIndex is for custom consumers, not WorkflowChatTransport
- Add live-stream caveat for negative startIndex resolution timing
- Add pagination limitation callout for live streams
- Use explicit typeof guards for negative startIndex checks (world-local, world-postgres)
- Add cost comment for EOF marker disk read in world-local
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel

vercelBot commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Mar 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a82fbe7

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

This PR includes changesets to release 20 packages
NameType
@workflow/worldPatch
@workflow/world-localPatch
@workflow/world-vercelPatch
@workflow/world-postgresPatch
@workflow/corePatch
@workflow/aiPatch
@workflow/cliPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
workflowPatch
@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 Mar 20, 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🥇 Express0.043s (-5.4% 🟢)1.006s (~)0.962s101.00x
💻 LocalNitro0.044s (+14.4% 🔺)1.005s (~)0.962s101.00x
💻 LocalNext.js (Turbopack)0.049s1.005s0.956s101.13x
🌐 RedisNext.js (Turbopack)0.055s1.005s0.950s101.26x
🐘 PostgresExpress0.062s (+10.8% 🔺)1.011s (~)0.949s101.42x
🐘 PostgresNitro0.064s (-7.9% 🟢)1.011s (~)0.947s101.47x
🐘 PostgresNext.js (Turbopack)0.065s1.012s0.947s101.50x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro0.458s (-21.6% 🟢)2.677s (+25.7% 🔺)2.219s101.00x
▲ VercelNext.js (Turbopack)0.542s (-17.9% 🟢)2.725s (+14.0% 🔺)2.183s101.18x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.123s2.006s0.883s101.00x
🌐 RedisNext.js (Turbopack)1.125s2.006s0.882s101.00x
💻 LocalExpress1.128s (~)2.006s (~)0.878s101.00x
💻 LocalNitro1.131s (+3.5%)2.006s (~)0.875s101.01x
🐘 PostgresNext.js (Turbopack)1.142s2.013s0.871s101.02x
🐘 PostgresNitro1.149s (-1.2%)2.012s (~)0.863s101.02x
🐘 PostgresExpress1.162s (+0.7%)2.012s (~)0.851s101.03x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.139s (+2.5%)4.147s (+23.1% 🔺)2.008s101.00x
▲ VercelNext.js (Turbopack)2.152s (+4.3%)4.007s (+9.3% 🔺)1.855s101.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)10.776s11.022s0.246s31.00x
💻 LocalNext.js (Turbopack)10.792s11.024s0.232s31.00x
🐘 PostgresExpress10.871s (-0.6%)11.020s (~)0.148s31.01x
🐘 PostgresNext.js (Turbopack)10.875s11.024s0.149s31.01x
💻 LocalExpress10.906s (-0.5%)11.023s (~)0.118s31.01x
💻 LocalNitro10.923s (+2.9%)11.023s (~)0.100s31.01x
🐘 PostgresNitro10.938s (-1.8%)11.026s (-8.5% 🟢)0.088s31.02x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)17.601s (+2.3%)19.403s (+1.9%)1.803s21.00x
▲ VercelNitro17.930s (~)20.036s (+2.1%)2.106s21.02x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)14.213s15.028s0.815s41.00x
🐘 PostgresNext.js (Turbopack)14.424s15.026s0.602s41.01x
💻 LocalNext.js (Turbopack)14.540s15.029s0.489s41.02x
🐘 PostgresExpress14.580s (-1.6%)15.024s (~)0.444s41.03x
🐘 PostgresNitro14.643s (-1.7%)15.033s (~)0.390s41.03x
💻 LocalExpress14.944s (-0.6%)15.029s (-4.8%)0.084s41.05x
💻 LocalNitro14.984s (+5.4% 🔺)15.033s (~)0.049s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro34.179s (+4.6%)35.468s (+4.2%)1.289s21.00x
▲ VercelNext.js (Turbopack)35.203s (+4.7%)37.010s (+6.3% 🔺)1.807s21.03x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)13.441s14.026s0.585s71.00x
🐘 PostgresNext.js (Turbopack)13.969s14.167s0.198s71.04x
🐘 PostgresExpress13.969s (-3.2%)14.452s (-3.9%)0.482s71.04x
🐘 PostgresNitro14.112s (-5.0% 🟢)14.741s (-3.1%)0.629s71.05x
💻 LocalNext.js (Turbopack)16.013s16.362s0.348s61.19x
💻 LocalExpress16.580s (-2.7%)17.030s (-1.9%)0.450s61.23x
💻 LocalNitro16.653s (+11.9% 🔺)17.031s (+13.3% 🔺)0.378s61.24x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro58.422s (-5.3% 🟢)61.010s (-3.0%)2.587s21.00x
▲ VercelNext.js (Turbopack)63.237s (+3.3%)64.613s (+3.3%)1.376s21.08x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)1.235s2.010s0.775s151.00x
🐘 PostgresNitro1.257s (-3.4%)2.012s (~)0.756s151.02x
🐘 PostgresExpress1.261s (-1.7%)2.012s (~)0.751s151.02x
🌐 RedisNext.js (Turbopack)1.321s2.007s0.685s151.07x
💻 LocalExpress1.527s (~)2.005s (~)0.478s151.24x
💻 LocalNitro1.527s (+4.8%)2.005s (~)0.478s151.24x
💻 LocalNext.js (Turbopack)1.572s2.006s0.434s151.27x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.597s (+2.8%)4.602s (+19.8% 🔺)2.005s71.00x
▲ VercelNext.js (Turbopack)2.630s (+1.5%)4.054s (-1.1%)1.423s81.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express2.354s (-3.6%)3.010s (~)0.657s101.00x
🐘 PostgresNitro2.354s (-5.6% 🟢)3.010s (~)0.656s101.00x
🐘 PostgresNext.js (Turbopack)2.412s3.010s0.599s101.02x
🌐 RedisNext.js (Turbopack)2.599s3.008s0.409s101.10x
💻 LocalNext.js (Turbopack)2.975s3.565s0.590s91.26x
💻 LocalNitro3.009s (+19.1% 🔺)3.760s (+25.0% 🔺)0.751s81.28x
💻 LocalExpress3.030s (-2.3%)3.760s (-6.2% 🟢)0.730s81.29x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.782s (+6.8% 🔺)5.013s (+36.6% 🔺)2.230s61.00x
▲ VercelNext.js (Turbopack)3.142s (+7.0% 🔺)4.966s (+15.8% 🔺)1.824s71.13x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro3.464s (-5.5% 🟢)4.015s (~)0.551s81.00x
🐘 PostgresExpress3.475s (-2.7%)4.014s (~)0.539s81.00x
🐘 PostgresNext.js (Turbopack)3.663s4.010s0.347s81.06x
🌐 RedisNext.js (Turbopack)4.261s5.011s0.750s61.23x
💻 LocalNext.js (Turbopack)7.418s8.016s0.597s42.14x
💻 LocalExpress8.564s (-1.7%)9.021s (-2.7%)0.457s42.47x
💻 LocalNitro9.200s (+37.7% 🔺)9.772s (+39.3% 🔺)0.571s42.66x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.261s (-3.7%)5.062s (+5.6% 🔺)1.801s71.00x
▲ VercelNext.js (Turbopack)3.526s (-15.3% 🟢)6.284s (+13.1% 🔺)2.758s51.08x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)1.219s2.009s0.790s151.00x
🐘 PostgresNitro1.252s (-3.0%)2.009s (~)0.757s151.03x
🐘 PostgresExpress1.255s (-2.8%)2.009s (~)0.754s151.03x
🌐 RedisNext.js (Turbopack)1.305s2.006s0.701s151.07x
💻 LocalNext.js (Turbopack)1.508s2.005s0.497s151.24x
💻 LocalNitro1.573s (+7.4% 🔺)2.007s (~)0.434s151.29x
💻 LocalExpress1.598s (+3.5%)2.072s (+3.3%)0.474s151.31x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.243s (-13.3% 🟢)3.995s (+5.7% 🔺)1.752s81.00x
▲ VercelNitro2.260s (-2.0%)4.048s (+7.7% 🔺)1.787s81.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.334s (-6.4% 🟢)3.011s (~)0.677s101.00x
🐘 PostgresExpress2.339s (-4.7%)3.010s (~)0.672s101.00x
🐘 PostgresNext.js (Turbopack)2.402s3.010s0.609s101.03x
🌐 RedisNext.js (Turbopack)2.544s3.008s0.464s101.09x
💻 LocalNext.js (Turbopack)2.868s3.453s0.585s91.23x
💻 LocalExpress2.992s (-5.7% 🟢)3.762s (-6.2% 🟢)0.770s81.28x
💻 LocalNitro3.086s (+7.6% 🔺)3.760s (+21.0% 🔺)0.674s81.32x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.905s (+1.0%)5.039s (+17.1% 🔺)2.134s71.00x
▲ VercelNext.js (Turbopack)3.300s (+10.1% 🔺)4.771s (+11.2% 🔺)1.471s71.14x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro3.469s (-4.9%)4.013s (~)0.544s81.00x
🐘 PostgresExpress3.473s (-3.4%)4.014s (~)0.541s81.00x
🐘 PostgresNext.js (Turbopack)3.670s4.012s0.342s81.06x
🌐 RedisNext.js (Turbopack)4.275s4.868s0.593s71.23x
💻 LocalExpress8.786s (-4.0%)9.022s (-10.0% 🟢)0.237s42.53x
💻 LocalNext.js (Turbopack)8.883s9.772s0.889s42.56x
💻 LocalNitro9.326s (+26.1% 🔺)10.024s (+25.0% 🔺)0.698s32.69x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.294s (+6.2% 🔺)5.002s (+6.1% 🔺)1.707s61.00x
▲ VercelNext.js (Turbopack)4.267s (+19.0% 🔺)6.135s (+24.6% 🔺)1.869s51.30x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)0.699s1.005s0.305s601.00x
🐘 PostgresNext.js (Turbopack)0.769s1.007s0.238s601.10x
🐘 PostgresExpress0.801s (-12.5% 🟢)1.007s (-6.7% 🟢)0.206s601.15x
🐘 PostgresNitro0.829s (-14.4% 🟢)1.007s (-18.5% 🟢)0.178s601.19x
💻 LocalNext.js (Turbopack)0.841s1.021s0.180s591.20x
💻 LocalExpress0.977s (-3.4%)1.076s (-30.3% 🟢)0.098s561.40x
💻 LocalNitro1.015s (+50.6% 🔺)1.628s (+62.1% 🔺)0.613s371.45x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro10.274s (+2.1%)12.099s (+5.9% 🔺)1.825s51.00x
▲ VercelNext.js (Turbopack)10.675s (+7.4% 🔺)12.483s (+1.0%)1.808s51.04x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.674s2.006s0.332s451.00x
🐘 PostgresNext.js (Turbopack)1.861s2.030s0.169s451.11x
🐘 PostgresExpress1.920s (-13.7% 🟢)2.077s (-31.0% 🟢)0.157s441.15x
🐘 PostgresNitro1.957s (-15.4% 🟢)2.176s (-27.8% 🟢)0.219s421.17x
💻 LocalNext.js (Turbopack)2.613s3.007s0.395s301.56x
💻 LocalExpress2.984s (-1.6%)3.341s (-9.4% 🟢)0.357s271.78x
💻 LocalNitro3.007s (+37.1% 🔺)3.547s (+18.0% 🔺)0.541s261.80x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro32.892s (+9.6% 🔺)34.652s (+8.7% 🔺)1.761s31.00x
▲ VercelNext.js (Turbopack)34.160s (+5.3% 🔺)51.070s (+51.8% 🔺)16.910s21.04x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)3.329s4.009s0.680s301.00x
🐘 PostgresNext.js (Turbopack)3.777s4.011s0.234s301.13x
🐘 PostgresExpress3.894s (-13.3% 🟢)4.184s (-16.6% 🟢)0.289s291.17x
🐘 PostgresNitro3.982s (-16.9% 🟢)4.404s (-13.6% 🟢)0.422s281.20x
💻 LocalNext.js (Turbopack)8.432s9.017s0.585s142.53x
💻 LocalExpress9.097s (+3.3%)9.555s (+4.3%)0.459s132.73x
💻 LocalNitro9.155s (+26.4% 🔺)9.864s (+23.1% 🔺)0.710s132.75x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro87.838s (+1.9%)104.355s (+19.6% 🔺)16.517s21.00x
▲ VercelNext.js (Turbopack)88.131s (+2.3%)90.390s (+2.2%)2.259s21.00x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)0.258s1.008s0.750s601.00x
🐘 PostgresNitro0.270s (-14.9% 🟢)1.008s (~)0.738s601.05x
🐘 PostgresExpress0.270s (-8.7% 🟢)1.008s (~)0.738s601.05x
🌐 RedisNext.js (Turbopack)0.365s1.004s0.640s601.42x
💻 LocalNext.js (Turbopack)0.551s1.004s0.453s602.14x
💻 LocalExpress0.595s (+4.7%)1.004s (~)0.410s602.31x
💻 LocalNitro0.598s (+6.7% 🔺)1.022s (+1.7%)0.423s592.32x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro1.834s (+9.2% 🔺)3.825s (+15.9% 🔺)1.992s161.00x
▲ VercelNext.js (Turbopack)2.568s (+35.7% 🔺)9.530s (+154.0% 🔺)6.962s121.40x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)0.481s1.019s0.538s891.00x
🐘 PostgresExpress0.493s (-10.0% 🟢)1.008s (~)0.515s901.03x
🐘 PostgresNitro0.498s (-14.7% 🟢)1.008s (~)0.511s901.04x
🌐 RedisNext.js (Turbopack)1.207s2.006s0.799s452.51x
💻 LocalNitro2.505s (+6.9% 🔺)3.009s (~)0.503s305.21x
💻 LocalNext.js (Turbopack)2.539s3.009s0.470s305.28x
💻 LocalExpress2.567s (+4.7%)3.009s (~)0.442s305.34x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.344s (+11.3% 🔺)5.095s (+14.5% 🔺)1.751s181.00x
▲ VercelNext.js (Turbopack)4.913s (+51.5% 🔺)6.649s (+40.7% 🔺)1.736s141.47x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)0.760s1.008s0.247s1201.00x
🐘 PostgresNitro0.767s (-20.7% 🟢)1.008s (-27.9% 🟢)0.241s1201.01x
🐘 PostgresExpress0.774s (-15.8% 🟢)1.008s (-18.7% 🟢)0.234s1201.02x
🌐 RedisNext.js (Turbopack)2.757s3.084s0.327s393.63x
💻 LocalNext.js (Turbopack)10.553s11.027s0.473s1113.88x
💻 LocalExpress11.228s (+1.9%)11.937s (+2.3%)0.708s1114.76x
💻 LocalNitro11.257s (+13.4% 🔺)12.029s (+16.1% 🔺)0.771s1014.80x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)7.133s (-77.8% 🟢)8.902s (-73.6% 🟢)1.769s141.00x
▲ VercelNitro7.220s (~)8.773s (+1.1%)1.554s141.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.175s1.001s0.011s1.017s0.842s101.00x
🌐 RedisNext.js (Turbopack)0.183s1.000s0.001s1.008s0.825s101.05x
🐘 PostgresExpress0.195s (-12.5% 🟢)0.996s (~)0.002s (+15.4% 🔺)1.011s (~)0.816s101.11x
🐘 PostgresNext.js (Turbopack)0.199s1.000s0.004s1.014s0.814s101.14x
🐘 PostgresNitro0.204s (-15.0% 🟢)0.995s (~)0.001s (-27.8% 🟢)1.011s (~)0.807s101.17x
💻 LocalNitro0.208s (+49.5% 🔺)1.003s (~)0.012s (+19.6% 🔺)1.018s (~)0.810s101.19x
💻 LocalExpress0.211s (+2.1%)1.003s (~)0.011s (-5.9% 🟢)1.017s (~)0.806s101.20x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro1.779s (+15.1% 🔺)2.497s (-4.5%)0.810s (+76.9% 🔺)3.996s (+11.2% 🔺)2.217s101.00x
▲ VercelNext.js (Turbopack)1.926s (+17.8% 🔺)2.945s (+0.9%)0.662s (+65.8% 🔺)4.322s (+11.6% 🔺)2.396s101.08x
▲ VercelExpress⚠️missing-----

🔍 Observability: Nitro | Next.js (Turbopack)

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)0.487s1.000s0.003s1.011s0.524s601.00x
🐘 PostgresExpress0.593s (-15.7% 🟢)1.003s (~)0.013s (+103.1% 🔺)1.033s (~)0.440s591.22x
🐘 PostgresNitro0.602s (-17.0% 🟢)1.004s (~)0.004s (-37.7% 🟢)1.023s (-0.7%)0.421s591.24x
🐘 PostgresNext.js (Turbopack)0.611s1.007s0.004s1.025s0.414s591.25x
💻 LocalNext.js (Turbopack)0.666s1.008s0.009s1.023s0.357s591.37x
💻 LocalNitro0.724s (+27.6% 🔺)1.009s (~)0.010s (+4.6%)1.024s (~)0.299s591.49x
💻 LocalExpress0.734s (+2.3%)1.009s (~)0.009s (-5.3% 🟢)1.022s (~)0.288s591.51x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)5.132s (+12.4% 🔺)6.256s (+11.9% 🔺)0.242s (+7.6% 🔺)13.240s (+104.9% 🔺)8.108s51.00x
▲ VercelNitro5.363s (~)6.372s (+3.7%)0.403s (-35.9% 🟢)7.632s (+1.2%)2.269s81.05x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)0.897s1.034s0.000s1.039s0.141s581.00x
🐘 PostgresNext.js (Turbopack)0.948s1.132s0.000s1.141s0.193s531.06x
🐘 PostgresExpress0.956s (-14.8% 🟢)1.271s (-28.6% 🟢)0.000s (-63.8% 🟢)1.286s (-28.9% 🟢)0.331s471.06x
🐘 PostgresNitro0.965s (-17.5% 🟢)1.298s (-35.0% 🟢)0.000s (-2.2%)1.315s (-35.0% 🟢)0.350s461.08x
💻 LocalNitro1.224s (+8.0% 🔺)2.020s (~)0.000s (+50.0% 🔺)2.023s (~)0.799s301.36x
💻 LocalExpress1.246s (+3.6%)2.020s (~)0.000s (-42.9% 🟢)2.023s (~)0.777s301.39x
💻 LocalNext.js (Turbopack)1.270s2.020s0.000s2.024s0.754s301.42x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.626s (+3.0%)4.889s (+5.1% 🔺)0.004s (+Infinity% 🔺)5.632s (+8.2% 🔺)2.006s111.00x
▲ VercelNitro3.756s (+24.1% 🔺)4.924s (+25.5% 🔺)0.002s (+1045.5% 🔺)5.654s (+27.8% 🔺)1.898s111.04x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

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

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.603s2.002s0.000s2.007s0.403s301.00x
🐘 PostgresNext.js (Turbopack)1.831s2.106s0.000s2.115s0.284s291.14x
🐘 PostgresExpress1.840s (-10.6% 🟢)2.258s (-8.8% 🟢)0.000s (NaN%)2.271s (-9.1% 🟢)0.431s271.15x
🐘 PostgresNitro1.853s (-18.2% 🟢)2.223s (-24.7% 🟢)0.000s (-100.0% 🟢)2.235s (-24.7% 🟢)0.382s271.16x
💻 LocalNitro3.585s (+3.6%)4.300s (+6.7% 🔺)0.000s (-61.5% 🟢)4.304s (+6.7% 🔺)0.720s152.24x
💻 LocalNext.js (Turbopack)3.632s4.230s0.001s4.235s0.604s152.27x
💻 LocalExpress3.670s (+8.3% 🔺)4.032s (-1.6%)0.000s (+250.0% 🔺)4.036s (-1.6%)0.366s152.29x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro4.200s (+21.3% 🔺)5.030s (+20.0% 🔺)0.000s (+Infinity% 🔺)5.900s (+24.3% 🔺)1.700s111.00x
▲ VercelNext.js (Turbopack)5.104s (+14.0% 🔺)6.017s (+11.1% 🔺)0.000s (+10.0% 🔺)6.656s (+11.9% 🔺)1.552s101.22x
▲ VercelExpress⚠️missing-----

🔍 Observability: Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)15/21
🐘 PostgresNext.js (Turbopack)13/21
▲ VercelNitro16/21
Fastest World by Framework

Winner determined by most benchmark wins

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

📋 View full workflow run


Some benchmark jobs failed:

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

Check the workflow run for details.

@github-actions

github-actionsBot commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production813067880
✅ 💻 Local Development7820178960
✅ 📦 Local Production7820178960
✅ 🐘 Local Postgres7820178960
✅ 🪟 Windows720880
❌ 🌍 Community Worlds1185630204
✅ 📋 Other198042240
Total3547566814284

❌ Failed Tests

🌍 Community Worlds (56 failed)

mongodb (3 failed):

  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KMEKZQ811N46GD5AK5RW1HD9
  • webhookWorkflow | wrun_01KMEKZZZNPHAFY93GWGMA33E4
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KMEM7DRXD1RYA1E11HJTJFPW

redis (2 failed):

  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KMEKZQ811N46GD5AK5RW1HD9
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KMEM7DRXD1RYA1E11HJTJFPW

turso (51 failed):

  • addTenWorkflow | wrun_01KMEKYFN28CV699JDRY8EVENQ
  • addTenWorkflow | wrun_01KMEKYFN28CV699JDRY8EVENQ
  • wellKnownAgentWorkflow (.well-known/agent) | wrun_01KMEKZV5Y7H35GS496CWE2PWY
  • should work with react rendering in step
  • promiseAllWorkflow | wrun_01KMEKYP9VT3R0SKBHBMF46WF1
  • promiseRaceWorkflow | wrun_01KMEKYV2NHKMK1PT16PHGC0TE
  • promiseAnyWorkflow | wrun_01KMEKYYRJ4XWER78HC3BC88RH
  • importedStepOnlyWorkflow | wrun_01KMEM07SDMHGNED3K1Z8822HA
  • hookWorkflow | wrun_01KMEKZB3KDGNSRDK1ZP1ZWCS2
  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KMEKZQ811N46GD5AK5RW1HD9
  • webhookWorkflow | wrun_01KMEKZZZNPHAFY93GWGMA33E4
  • sleepingWorkflow | wrun_01KMEM07AP1ZHHP0B1Y28XPTPF
  • parallelSleepWorkflow | wrun_01KMEM0M62BA11GZH9N30PA01V
  • nullByteWorkflow | wrun_01KMEM0RNC851K91RM7QKG9J0A
  • workflowAndStepMetadataWorkflow | wrun_01KMEM0TS1NDEE94P4ZK643V6C
  • fetchWorkflow | wrun_01KMEM3MRRD40CZ6RZHVTBN0QE
  • promiseRaceStressTestWorkflow | wrun_01KMEM3RJJYY05KEDAWHC7YZCF
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion | wrun_01KMEM6SVSD36537C7RMSNZ7K2
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KMEM7DRXD1RYA1E11HJTJFPW
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running | wrun_01KMEM84D71052EEDK1NTW09QR
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars) | wrun_01KMEM8QRVX2ZN0ZY8KSFXYRFW
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument | wrun_01KMEM90F9YAZG8G7CZZR9KQWQ
  • closureVariableWorkflow - nested step functions with closure variables | wrun_01KMEM96EMKN1H5C673QHQM0RJ
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step | wrun_01KMEM9A0HFZKBNE055EZ9P5H4
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly | wrun_01KMEM9RJS5TNK0QPDKP86M45K
  • Calculator.calculate - static workflow method using static step methods from another class | wrun_01KMEM9XZANZTCTYJ5TEE55MRZ
  • AllInOneService.processNumber - static workflow method using sibling static step methods | wrun_01KMEMA4B58C28JE56JM1YFVQV
  • ChainableService.processWithThis - static step methods using this to reference the class | wrun_01KMEMAATKT6G9NX72T548BXF1
  • thisSerializationWorkflow - step function invoked with .call() and .apply() | wrun_01KMEMAH46HQ91CQS2CS66CDC6
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE | wrun_01KMEMAQHGV2M8JHE762DZMKC6
  • instanceMethodStepWorkflow - instance methods with "use step" directive | wrun_01KMEMAYB4Y9GXM4F81Y6CNJ6B
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context | wrun_01KMEMB8XA6D9RX061RHB81PKZ
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument | wrun_01KMEMBGE2CX800GQHW1H2HAJF
  • cancelRun - cancelling a running workflow | wrun_01KMEMBQWVXW1JB5P0HV743Q4P
  • cancelRun via CLI - cancelling a running workflow | wrun_01KMEMC0XTPK7JCE38TSAYE9N8
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep | wrun_01KMEMCCDFRYCKTFTV8TJ03YRG
  • sleepInLoopWorkflow - sleep inside loop with steps actually delays each iteration | wrun_01KMEMD0561BCM0VA669B3A6CS
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control) | wrun_01KMEMDAE0C841QKT27361H8BN

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro7307
✅ example7307
✅ express7307
✅ fastify7307
✅ hono7307
✅ nextjs-turbopack7802
✅ nextjs-webpack7802
✅ nitro7307
✅ nuxt7307
✅ sveltekit7307
✅ vite7307
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable66014
✅ express-stable66014
✅ fastify-stable66014
✅ hono-stable66014
✅ nextjs-turbopack-canary55025
✅ nextjs-turbopack-stable7208
✅ nextjs-webpack-canary55025
✅ nextjs-webpack-stable7208
✅ nitro-stable66014
✅ nuxt-stable66014
✅ sveltekit-stable66014
✅ vite-stable66014
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable66014
✅ express-stable66014
✅ fastify-stable66014
✅ hono-stable66014
✅ nextjs-turbopack-canary55025
✅ nextjs-turbopack-stable7208
✅ nextjs-webpack-canary55025
✅ nextjs-webpack-stable7208
✅ nitro-stable66014
✅ nuxt-stable66014
✅ sveltekit-stable66014
✅ vite-stable66014
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable66014
✅ express-stable66014
✅ fastify-stable66014
✅ hono-stable66014
✅ nextjs-turbopack-canary55025
✅ nextjs-turbopack-stable7208
✅ nextjs-webpack-canary55025
✅ nextjs-webpack-stable7208
✅ nitro-stable66014
✅ nuxt-stable66014
✅ sveltekit-stable66014
✅ vite-stable66014
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack7208
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev302
❌ mongodb5238
✅ redis-dev302
❌ redis5328
✅ turso-dev302
❌ turso4518
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable66014
✅ e2e-local-postgres-nest-stable66014
✅ e2e-local-prod-nest-stable66014

📋 View full workflow run

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Comment threadpackages/core/e2e/e2e.test.ts Outdated

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Here's my review of PR #1470:

Summary

This is a feature PR by @VaguelySerious that adds two new Streamer interface methods (getStreamChunks and getStreamInfo) across all world implementations, extends Run.getReadable() with a getTailIndex() helper, and adds initialStartIndex support to WorkflowChatTransport for resuming streams from the end.

No Blockers

CI is in excellent shape. The code changes are well-structured, well-documented, and backward-compatible.

CI Status

Passing:

  • All Vercel Production E2E tests: 11/11 frameworks pass (802 passed, 0 failed)
  • All Local Dev E2E tests: 12/12 pass (782 passed)
  • All Local Prod E2E tests: 12/12 pass
  • All Local Postgres E2E tests: 12/12 pass
  • Windows E2E tests pass
  • Unit tests pass (ubuntu + windows)
  • Vitest plugin tests pass
  • All Vercel deployments succeed (except workbench-nestjs-workflow - discussed below)
  • Benchmarks pass for local/postgres; no performance regressions
  • Docs code samples and links pass

Failing (non-blocking / pre-existing):

  • workbench-nestjs-workflow Vercel deployment fails - this is likely a pre-existing issue unrelated to streaming changes (the nest workbench has no streaming code changes in this PR)
  • Benchmark Vercel (nitro-v3) fails at ~30min - likely infra timeout, not PR-related
  • Community World (Turso) - 51 failures, pre-existing community world issue (same pattern as other PRs)
  • Community World (MongoDB/Redis) - 3/2 failures on hook-related tests, pre-existing

Code Review

1. Streamer interface additions (packages/world/src/interfaces.ts) - Clean

Two new methods added to the Streamer interface:

  • getStreamChunks(name, runId, options?) - cursor-based pagination for stream chunks
  • getStreamInfo(name, runId) - lightweight metadata (tailIndex, done flag)

New types (StreamChunk, StreamChunksResponse, GetChunksOptions, StreamInfoResponse) are well-documented in packages/world/src/shared.ts. The interface is additive and non-breaking.

2. world-vercel implementation (packages/world-vercel/src/streamer.ts) - Clean

Uses makeRequest + Zod schemas to call two new server endpoints:

  • GET /v2/runs/:runId/streams/:name/chunks?limit=&cursor=
  • GET /v2/runs/:runId/streams/:name/info

The StreamChunksResponseSchema correctly expects z.instanceof(Uint8Array) since CBOR handles binary natively. Clean and minimal.

3. world-local implementation (packages/world-local/src/streamer.ts) - Works but verbose

The implementation reads chunk files from disk, builds a file extension map, sorts by ULID, and paginates. One observation:

  • getStreamInfo reads every chunk file to count data chunks and detect EOF. For large streams this could be expensive. The getStreamChunks implementation has the same pattern. Both could potentially be optimized with a count-based approach, but for local dev this is fine.
  • The cursor encoding (base64-encoded JSON { i: index }) is a reasonable pattern.

4. world-postgres implementation (packages/world-postgres/src/streamer.ts) - Solid

Uses Drizzle ORM with proper query patterns:

  • Fetches limit + 1 rows to detect hasMore efficiently
  • Correctly handles the EOF row being mixed into results
  • Has a follow-up EOF check query when !streamDone && !hasMore to correctly report done status
  • Cursor encodes both chunk ID and running index ({ c: chunkId, i: baseIndex })

One minor cleanup: void fn() on line 407 (changing cleanups.forEach((fn) => fn()) to cleanups.forEach((fn) => void fn())) is a good lint fix to suppress floating promise warnings.

5. WorkflowReadableStream type and getTailIndex() (packages/core/src/runtime/run.ts) - Clean

Uses Object.assign to add getTailIndex() to the ReadableStream. This is a clean pattern that avoids subclassing. The type is properly exported through packages/workflow/src/api.ts.

6. WorkflowChatTransport changes (packages/ai/src/workflow-chat-transport.ts) - Well thought out

The negative initialStartIndex feature is carefully designed:

  • First request uses the explicit (potentially negative) startIndex
  • On retry, resolves to an absolute position using x-workflow-stream-tail-index header
  • Falls back to startIndex=0 with a warning if the header is missing
  • useExplicitStartIndex flag ensures the negative value is only used once, then subsequent retries use the running chunkIndex

The replayFromStart flag handles the edge case where the header is absent gracefully.

7. Tests - Good coverage

  • Two new e2e tests for getTailIndex and getStreamChunks pagination (skipped on local deployments since they require server-side endpoints)
  • Two new unit tests for WorkflowChatTransport covering negative initialStartIndex with and without the tail-index header

8. Docs - Thorough

Updated resumable-streams.mdx with:

  • New "Resuming from the end of the stream" section
  • Updated stream reconnection endpoint to include getTailIndex() and the x-workflow-stream-tail-index header
  • Callout about header requirement for negative initialStartIndex
  • API reference docs updated

9. Changeset - Present and correct

Includes @workflow/world, world-local, world-vercel, world-postgres, core, and ai as patch changes.

Minor Notes

  • The PR description is empty ("No description provided"). Worth adding a summary for posterity.
  • The getStreamChunks method uses _runId (unused) in local/postgres implementations since the stream name is already unique there. The vercel implementation passes it to the server endpoint. This is fine - the interface is designed for the vercel world's needs.

Verdict

No blockers or regressions. This is a well-implemented, additive feature with good test coverage and documentation. The only CI failures are pre-existing (nest deployment, community world Turso/MongoDB/Redis, nitro benchmark timeout). Ready to merge once it gets a review approval.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall this is a well-designed, additive feature. The negative initialStartIndex logic in the transport is carefully thought through, the interface additions are clean, and the docs/tests cover the new behavior well. A few issues worth addressing below.

GetChunksOptions,
StreamChunk,
StreamChunksResponse,
} from './shared.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The explicit export type { GetChunksOptions, StreamChunk, StreamChunksResponse } is redundant with the export type * from './shared.js' on line 34 — and it's also missing StreamInfoResponse. Since line 34 already re-exports all types from shared.js, you can just remove this block entirely (lines 39-43).

.select({ chunkId: streams.chunkId })
.from(streams)
.where(and(eq(streams.streamId, name), eq(streams.eof, false)))
.orderBy(asc(streams.chunkId));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

getStreamInfo fetches all data rows into memory just to count them. For streams with thousands of chunks this is wasteful — a SELECT COUNT(*) FROM streams WHERE streamId = $1 AND eof = false would be much cheaper. Consider using Drizzle's count() aggregate or a raw SQL count instead of selecting all chunk IDs.

hasMore,
done: streamDone,
};
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The file-listing + extension-mapping logic (~30 lines) is duplicated verbatim between getStreamChunks and getStreamInfo. Consider extracting a helper like listStreamDataFiles(name) that returns { dataFiles, fileExtMap, streamDone } to keep this DRY.

Comment threadpackages/world-local/src/streamer.ts Outdated
break;
}
dataFiles.push(file);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

getStreamChunks reads and deserializes every chunk file from disk on every call (to separate data files from the EOF marker), regardless of the cursor position. For a stream with 10k chunks, page 2 still reads all 10k files.

Two possible improvements:

  1. Only read files starting from the cursor position (the ULID sort gives chronological order, so you can skip earlier files)
  2. At minimum, stop reading once you have enough files past the cursor — you only need startIndex + limit data files plus potentially the EOF marker

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Deeper review: testing, docs, observability, edge cases, and performance

Testing gaps

world-local and world-postgres have zero unit tests for the new getStreamChunks and getStreamInfo methods. The only coverage comes from two e2e tests that are skipIf(isLocalDeployment()), so local dev never exercises these paths. Cursor pagination logic (invalid cursors, out-of-range cursors, single-item pages), empty stream behavior (tailIndex = -1), and mid-flight streams (calling before workflow completes) are all untested.

The transport unit tests cover the happy path for negative initialStartIndex but miss: NaN/malformed x-workflow-stream-tail-index header values (the !Number.isNaN guard is untested), positive explicit startIndex override, boundary clamping when magnitude exceeds stream length, and multi-retry chains where the header varies across attempts.

Documentation gaps

  1. building-a-world.mdx still shows the old Streamer interface without getStreamChunks or getStreamInfo. Custom world implementers won't know these methods are required.
  2. No API reference for WorkflowReadableStream or getTailIndex() — the get-run.mdx page documents WorkflowReadableStreamOptions but not the new return type.
  3. Changeset mentions getStreamInfo but omits getStreamChunks.

Observability

Existing storage methods are wrapped with instrumentObject() for automatic OTEL spans (e.g. world.runs.get, world.events.create). Streamer methods — both existing and new — are not instrumented at all. No spans, no semantic conventions for stream names/indices, and no structured logging in any world implementation's streaming code. The only observability signal is the console.warn in WorkflowChatTransport for missing headers. This is consistent with the pre-existing pattern (streaming was never instrumented), but it means production debugging of pagination issues, tail-index mismatches, or reconnection failures will be blind.

Edge cases and performance (see inline comments below)

Several specific issues are called out inline.

break;
}
dataRows.push(row);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Edge case: EOF row appearing mid-batch. The query fetches limit + 1 rows ordered by chunkId without filtering out EOF rows. If the EOF row sorts between data rows (e.g. due to concurrent writes or clock skew), the loop breaks early on row.eof, and subsequent data rows in the result set are silently dropped.

Then hasMore = !streamDone && dataRows.length > limit evaluates to false (because streamDone = true), so the client sees done: true, hasMore: false even though there were more data rows in the fetch.

This is probably fine in practice since the EOF ULID is generated after all data chunks, but it's worth noting the implicit ordering assumption. Adding eq(streams.eof, false) to the main query and doing a separate EOF check (like the lookahead on line 250) would make this robust.

.select({ chunkId: streams.chunkId })
.from(streams)
.where(and(eq(streams.streamId, name), eq(streams.eof, false)))
.orderBy(asc(streams.chunkId));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Performance: O(N) memory to count rows. This SELECT chunkId FROM streams WHERE ... loads every non-EOF row into Node.js memory just to take .length. For a stream with 100k chunks, that's 100k objects allocated and immediately discarded.

Use a COUNT(*) aggregate instead:

const[{ count }]=awaitdrizzle.select({count: sql<number>`count(*)`}).from(streams).where(and(eq(streams.streamId,name),eq(streams.eof,false)));return{tailIndex: (count??0)-1,done: !!eofRow};

Comment threadpackages/world-local/src/streamer.ts Outdated
break;
}
dataFiles.push(file);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Performance: every page reads ALL chunk files.getStreamChunks deserializes every chunk file from disk (lines 286-296) to separate data files from the EOF marker, even for chunks that precede the cursor. Page 50 of a 10k-chunk stream still reads all 10k files.

getStreamInfo (line 372) has the exact same pattern — reads all files just to count them.

For local dev this is tolerable, but if these methods get called frequently (e.g. polling for tail index), the cost adds up. A lighter approach: since ULID sort gives chronological order, you could skip files before the cursor index and stop after collecting limit data files + EOF check.

const readable = run.getReadable();
const tailIndex = await readable.getTailIndex();

// outputStreamWorkflow writes 2 chunks to the default stream

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Testing gap: no empty-stream or mid-flight test. These tests only run after await run.returnValue (stream fully complete) and outputStreamWorkflow always writes exactly 2 chunks.

Missing scenarios:

  • Empty stream: What does getTailIndex() return when 0 chunks have been written? (should be -1 per the type docs)
  • Mid-flight stream: What does getTailIndex() / getStreamChunks() return while the workflow is still running and the stream isn't closed yet?
  • getStreamInfo directly: Only tested indirectly via getTailIndex() — no direct assertion on the done flag


warnSpy.mockRestore();
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Testing gap: malformed header values. The implementation has a !Number.isNaN(tailIndex) guard (transport line 362) but no test covers it. Consider adding a case where the header is "not-a-number"parseInt returns NaN, the guard should skip resolution, and the code should fall through without setting chunkIndex.

Also missing: a test for initialStartIndex: -1000 on a 5-chunk stream (should clamp to 0 via Math.max), and a test where a positive startIndex is passed via reconnectToStream options to override initialStartIndex.

const tailIndexHeader = res.headers.get('x-workflow-stream-tail-index');
if (tailIndexHeader !== null) {
const tailIndex = parseInt(tailIndexHeader, 10);
if (!Number.isNaN(tailIndex)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Edge case: NaN guard doesn't fall through cleanly. When parseInt returns NaN, the if (!Number.isNaN(tailIndex)) block is skipped, but replayFromStart is NOT set (that only happens in the else branch for tailIndexHeader === null). So on the next iteration, useExplicitStartIndex is false and replayFromStart is false, meaning the code falls through to chunkIndex — which is still 0 from initialization.

This accidentally works (retries from 0), but the intent is unclear. If the header is present but malformed, should it warn and set replayFromStart = true like the missing-header case? As-is, a malformed header silently degrades to replaying from 0 without the console warning.

GetChunksOptions,
StreamChunk,
StreamChunksResponse,
} from './shared.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Redundant + incomplete export. Line 34 already has export type * from './shared.js' which re-exports all types. This explicit block is redundant and also missing StreamInfoResponse. Just delete lines 39-43.

VaguelySeriousand others added 2 commits March 23, 2026 17:22
- world-postgres: use COUNT(*) in getStreamInfo instead of fetching all
rows; filter EOF rows in getStreamChunks query to prevent mid-batch
data loss from clock-skewed EOF rows
- world-local: skip files before cursor in getStreamChunks, read only
EOF byte in getStreamInfo and pre-cursor files
- WorkflowChatTransport: treat unparseable tail-index header same as
missing (set replayFromStart); add unit test for NaN header fallback
- e2e: add test for getTailIndex returning -1 on nonexistent stream
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- building-a-world.mdx: add getStreamChunks and getStreamInfo to
Streamer interface definition
- get-run.mdx: document WorkflowReadableStream and getTailIndex()
- Changeset: mention getStreamChunks alongside getStreamInfo
- world-vercel, world-local: wrap streamer with instrumentObject for
OTEL spans (world.streams.* prefix), matching storage instrumentation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fresh review after update

This update addresses all the critical issues from the previous round. Nice work cleaning this up.

Previously flagged — now resolved

  • Postgres getStreamInfo: Now uses COUNT(*) aggregate instead of fetching all rows into memory
  • Postgres getStreamChunks EOF mid-batch: Main query now filters eq(streams.eof, false), with a separate EOF check — no more risk of EOF silently dropping data rows
  • Transport NaN guard: Missing and malformed headers now both flow through the same else branch — replayFromStart = true + console.warn in both cases
  • world-local getStreamChunks perf: No longer deserializes every chunk — pre-cursor files use isEofChunk() (byte check only), and the loop stops early after collecting enough data
  • world-local getStreamInfo: Also uses isEofChunk() instead of full deserialization
  • Changeset: Now mentions both getStreamChunks() and getStreamInfo()
  • Docs: building-a-world.mdx includes the new Streamer methods with explanatory text. get-run.mdx has a WorkflowReadableStream section with getTailIndex() reference.
  • E2e tests: Added tailIndex returns -1 test using a nonexistent namespace. Added NaN header unit test.
  • OTEL: Both world-vercel and world-local now wrap the streamer with instrumentObject('world.streams', ...)
  • Redundant export: StreamInfoResponse added to the explicit export list in world/src/index.ts

Minor remaining items (non-blocking)

  1. world-local file-listing boilerplate still duplicated — the ~20-line block that lists files by extension, builds the fileExtMap, and filters by stream name is copy-pasted between getStreamChunks (lines 266-286) and getStreamInfo (lines 367-389). Extracting a listChunkFilesForStream(name) helper would reduce maintenance surface.

  2. world-postgres streamer not instrumentedworld-vercel and world-local both wrap their streamer with instrumentObject('world.streams', ...), giving the new methods automatic OTEL spans. world-postgres spreads the streamer directly without instrumentation (packages/world-postgres/src/index.ts:49). Pre-existing inconsistency, but worth noting since this PR adds new methods that would benefit from tracing.

  3. No unit tests for world-local or world-postgres implementations of getStreamChunks/getStreamInfo. The cursor pagination logic (invalid cursors, boundary conditions, EOF detection) is only tested via e2e tests that are skipped in local dev. Consider adding unit tests in a follow-up.

dataCount++;
}

return { tailIndex: dataCount - 1, done: streamDone };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: The file-listing + extension-mapping block (~20 lines) is duplicated between getStreamChunks (lines 266-286) and getStreamInfo (lines 367-389). A shared listChunkFilesForStream(name) helper returning { chunkFiles, fileExtMap } would keep these two methods DRY.

@ghostghost mentioned this pull request Mar 23, 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.

3 participants

@VaguelySerious@pranaygp@karthikscale3
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 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" + '
[world] Add stream pagination and metadata endpoints by VaguelySerious · Pull Request #1470 · vercel/workflow · GitHub
Skip to content

[world] Add stream pagination and metadata endpoints - #1470

Merged
VaguelySerious merged 32 commits into
mainfrom
peter/stream-metadata-and-pages
Mar 24, 2026
Merged

[world] Add stream pagination and metadata endpoints#1470
VaguelySerious merged 32 commits into
mainfrom
peter/stream-metadata-and-pages

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

No description provided.

VaguelySeriousand others added 13 commits March 19, 2026 17:44
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Negative startIndex values (e.g. -3) resolve to n chunks before the
known end of the stream. All world implementations (local, postgres,
vercel) support this. Includes unit tests, e2e test, and doc updates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Replaces three separate tests (no startIndex, positive, negative) with a
single describe block that iterates over startIndex cases. Fixes the
negative startIndex test by waiting for workflow completion before
connecting the reader — the backend resolves negative indices at
connection time using knownChunkCount, which is 0 if the stream hasn't
been fully written yet.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Replaces three separate tests (no startIndex, positive, negative) with a
single describe block that iterates over startIndex cases. Fixes the
negative startIndex test by waiting for workflow completion before
connecting the reader — the backend resolves negative indices at
connection time using knownChunkCount, which is 0 if the stream hasn't
been fully written yet.
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
- Clarify that negative startIndex is for custom consumers, not WorkflowChatTransport
- Add live-stream caveat for negative startIndex resolution timing
- Add pagination limitation callout for live streams
- Use explicit typeof guards for negative startIndex checks (world-local, world-postgres)
- Add cost comment for EOF marker disk read in world-local
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel

vercelBot commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Mar 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a82fbe7

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

This PR includes changesets to release 20 packages
NameType
@workflow/worldPatch
@workflow/world-localPatch
@workflow/world-vercelPatch
@workflow/world-postgresPatch
@workflow/corePatch
@workflow/aiPatch
@workflow/cliPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
workflowPatch
@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 Mar 20, 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🥇 Express0.043s (-5.4% 🟢)1.006s (~)0.962s101.00x
💻 LocalNitro0.044s (+14.4% 🔺)1.005s (~)0.962s101.00x
💻 LocalNext.js (Turbopack)0.049s1.005s0.956s101.13x
🌐 RedisNext.js (Turbopack)0.055s1.005s0.950s101.26x
🐘 PostgresExpress0.062s (+10.8% 🔺)1.011s (~)0.949s101.42x
🐘 PostgresNitro0.064s (-7.9% 🟢)1.011s (~)0.947s101.47x
🐘 PostgresNext.js (Turbopack)0.065s1.012s0.947s101.50x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro0.458s (-21.6% 🟢)2.677s (+25.7% 🔺)2.219s101.00x
▲ VercelNext.js (Turbopack)0.542s (-17.9% 🟢)2.725s (+14.0% 🔺)2.183s101.18x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.123s2.006s0.883s101.00x
🌐 RedisNext.js (Turbopack)1.125s2.006s0.882s101.00x
💻 LocalExpress1.128s (~)2.006s (~)0.878s101.00x
💻 LocalNitro1.131s (+3.5%)2.006s (~)0.875s101.01x
🐘 PostgresNext.js (Turbopack)1.142s2.013s0.871s101.02x
🐘 PostgresNitro1.149s (-1.2%)2.012s (~)0.863s101.02x
🐘 PostgresExpress1.162s (+0.7%)2.012s (~)0.851s101.03x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.139s (+2.5%)4.147s (+23.1% 🔺)2.008s101.00x
▲ VercelNext.js (Turbopack)2.152s (+4.3%)4.007s (+9.3% 🔺)1.855s101.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)10.776s11.022s0.246s31.00x
💻 LocalNext.js (Turbopack)10.792s11.024s0.232s31.00x
🐘 PostgresExpress10.871s (-0.6%)11.020s (~)0.148s31.01x
🐘 PostgresNext.js (Turbopack)10.875s11.024s0.149s31.01x
💻 LocalExpress10.906s (-0.5%)11.023s (~)0.118s31.01x
💻 LocalNitro10.923s (+2.9%)11.023s (~)0.100s31.01x
🐘 PostgresNitro10.938s (-1.8%)11.026s (-8.5% 🟢)0.088s31.02x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)17.601s (+2.3%)19.403s (+1.9%)1.803s21.00x
▲ VercelNitro17.930s (~)20.036s (+2.1%)2.106s21.02x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)14.213s15.028s0.815s41.00x
🐘 PostgresNext.js (Turbopack)14.424s15.026s0.602s41.01x
💻 LocalNext.js (Turbopack)14.540s15.029s0.489s41.02x
🐘 PostgresExpress14.580s (-1.6%)15.024s (~)0.444s41.03x
🐘 PostgresNitro14.643s (-1.7%)15.033s (~)0.390s41.03x
💻 LocalExpress14.944s (-0.6%)15.029s (-4.8%)0.084s41.05x
💻 LocalNitro14.984s (+5.4% 🔺)15.033s (~)0.049s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro34.179s (+4.6%)35.468s (+4.2%)1.289s21.00x
▲ VercelNext.js (Turbopack)35.203s (+4.7%)37.010s (+6.3% 🔺)1.807s21.03x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)13.441s14.026s0.585s71.00x
🐘 PostgresNext.js (Turbopack)13.969s14.167s0.198s71.04x
🐘 PostgresExpress13.969s (-3.2%)14.452s (-3.9%)0.482s71.04x
🐘 PostgresNitro14.112s (-5.0% 🟢)14.741s (-3.1%)0.629s71.05x
💻 LocalNext.js (Turbopack)16.013s16.362s0.348s61.19x
💻 LocalExpress16.580s (-2.7%)17.030s (-1.9%)0.450s61.23x
💻 LocalNitro16.653s (+11.9% 🔺)17.031s (+13.3% 🔺)0.378s61.24x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro58.422s (-5.3% 🟢)61.010s (-3.0%)2.587s21.00x
▲ VercelNext.js (Turbopack)63.237s (+3.3%)64.613s (+3.3%)1.376s21.08x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)1.235s2.010s0.775s151.00x
🐘 PostgresNitro1.257s (-3.4%)2.012s (~)0.756s151.02x
🐘 PostgresExpress1.261s (-1.7%)2.012s (~)0.751s151.02x
🌐 RedisNext.js (Turbopack)1.321s2.007s0.685s151.07x
💻 LocalExpress1.527s (~)2.005s (~)0.478s151.24x
💻 LocalNitro1.527s (+4.8%)2.005s (~)0.478s151.24x
💻 LocalNext.js (Turbopack)1.572s2.006s0.434s151.27x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.597s (+2.8%)4.602s (+19.8% 🔺)2.005s71.00x
▲ VercelNext.js (Turbopack)2.630s (+1.5%)4.054s (-1.1%)1.423s81.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express2.354s (-3.6%)3.010s (~)0.657s101.00x
🐘 PostgresNitro2.354s (-5.6% 🟢)3.010s (~)0.656s101.00x
🐘 PostgresNext.js (Turbopack)2.412s3.010s0.599s101.02x
🌐 RedisNext.js (Turbopack)2.599s3.008s0.409s101.10x
💻 LocalNext.js (Turbopack)2.975s3.565s0.590s91.26x
💻 LocalNitro3.009s (+19.1% 🔺)3.760s (+25.0% 🔺)0.751s81.28x
💻 LocalExpress3.030s (-2.3%)3.760s (-6.2% 🟢)0.730s81.29x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.782s (+6.8% 🔺)5.013s (+36.6% 🔺)2.230s61.00x
▲ VercelNext.js (Turbopack)3.142s (+7.0% 🔺)4.966s (+15.8% 🔺)1.824s71.13x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro3.464s (-5.5% 🟢)4.015s (~)0.551s81.00x
🐘 PostgresExpress3.475s (-2.7%)4.014s (~)0.539s81.00x
🐘 PostgresNext.js (Turbopack)3.663s4.010s0.347s81.06x
🌐 RedisNext.js (Turbopack)4.261s5.011s0.750s61.23x
💻 LocalNext.js (Turbopack)7.418s8.016s0.597s42.14x
💻 LocalExpress8.564s (-1.7%)9.021s (-2.7%)0.457s42.47x
💻 LocalNitro9.200s (+37.7% 🔺)9.772s (+39.3% 🔺)0.571s42.66x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.261s (-3.7%)5.062s (+5.6% 🔺)1.801s71.00x
▲ VercelNext.js (Turbopack)3.526s (-15.3% 🟢)6.284s (+13.1% 🔺)2.758s51.08x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)1.219s2.009s0.790s151.00x
🐘 PostgresNitro1.252s (-3.0%)2.009s (~)0.757s151.03x
🐘 PostgresExpress1.255s (-2.8%)2.009s (~)0.754s151.03x
🌐 RedisNext.js (Turbopack)1.305s2.006s0.701s151.07x
💻 LocalNext.js (Turbopack)1.508s2.005s0.497s151.24x
💻 LocalNitro1.573s (+7.4% 🔺)2.007s (~)0.434s151.29x
💻 LocalExpress1.598s (+3.5%)2.072s (+3.3%)0.474s151.31x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.243s (-13.3% 🟢)3.995s (+5.7% 🔺)1.752s81.00x
▲ VercelNitro2.260s (-2.0%)4.048s (+7.7% 🔺)1.787s81.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.334s (-6.4% 🟢)3.011s (~)0.677s101.00x
🐘 PostgresExpress2.339s (-4.7%)3.010s (~)0.672s101.00x
🐘 PostgresNext.js (Turbopack)2.402s3.010s0.609s101.03x
🌐 RedisNext.js (Turbopack)2.544s3.008s0.464s101.09x
💻 LocalNext.js (Turbopack)2.868s3.453s0.585s91.23x
💻 LocalExpress2.992s (-5.7% 🟢)3.762s (-6.2% 🟢)0.770s81.28x
💻 LocalNitro3.086s (+7.6% 🔺)3.760s (+21.0% 🔺)0.674s81.32x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.905s (+1.0%)5.039s (+17.1% 🔺)2.134s71.00x
▲ VercelNext.js (Turbopack)3.300s (+10.1% 🔺)4.771s (+11.2% 🔺)1.471s71.14x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro3.469s (-4.9%)4.013s (~)0.544s81.00x
🐘 PostgresExpress3.473s (-3.4%)4.014s (~)0.541s81.00x
🐘 PostgresNext.js (Turbopack)3.670s4.012s0.342s81.06x
🌐 RedisNext.js (Turbopack)4.275s4.868s0.593s71.23x
💻 LocalExpress8.786s (-4.0%)9.022s (-10.0% 🟢)0.237s42.53x
💻 LocalNext.js (Turbopack)8.883s9.772s0.889s42.56x
💻 LocalNitro9.326s (+26.1% 🔺)10.024s (+25.0% 🔺)0.698s32.69x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.294s (+6.2% 🔺)5.002s (+6.1% 🔺)1.707s61.00x
▲ VercelNext.js (Turbopack)4.267s (+19.0% 🔺)6.135s (+24.6% 🔺)1.869s51.30x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)0.699s1.005s0.305s601.00x
🐘 PostgresNext.js (Turbopack)0.769s1.007s0.238s601.10x
🐘 PostgresExpress0.801s (-12.5% 🟢)1.007s (-6.7% 🟢)0.206s601.15x
🐘 PostgresNitro0.829s (-14.4% 🟢)1.007s (-18.5% 🟢)0.178s601.19x
💻 LocalNext.js (Turbopack)0.841s1.021s0.180s591.20x
💻 LocalExpress0.977s (-3.4%)1.076s (-30.3% 🟢)0.098s561.40x
💻 LocalNitro1.015s (+50.6% 🔺)1.628s (+62.1% 🔺)0.613s371.45x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro10.274s (+2.1%)12.099s (+5.9% 🔺)1.825s51.00x
▲ VercelNext.js (Turbopack)10.675s (+7.4% 🔺)12.483s (+1.0%)1.808s51.04x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.674s2.006s0.332s451.00x
🐘 PostgresNext.js (Turbopack)1.861s2.030s0.169s451.11x
🐘 PostgresExpress1.920s (-13.7% 🟢)2.077s (-31.0% 🟢)0.157s441.15x
🐘 PostgresNitro1.957s (-15.4% 🟢)2.176s (-27.8% 🟢)0.219s421.17x
💻 LocalNext.js (Turbopack)2.613s3.007s0.395s301.56x
💻 LocalExpress2.984s (-1.6%)3.341s (-9.4% 🟢)0.357s271.78x
💻 LocalNitro3.007s (+37.1% 🔺)3.547s (+18.0% 🔺)0.541s261.80x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro32.892s (+9.6% 🔺)34.652s (+8.7% 🔺)1.761s31.00x
▲ VercelNext.js (Turbopack)34.160s (+5.3% 🔺)51.070s (+51.8% 🔺)16.910s21.04x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)3.329s4.009s0.680s301.00x
🐘 PostgresNext.js (Turbopack)3.777s4.011s0.234s301.13x
🐘 PostgresExpress3.894s (-13.3% 🟢)4.184s (-16.6% 🟢)0.289s291.17x
🐘 PostgresNitro3.982s (-16.9% 🟢)4.404s (-13.6% 🟢)0.422s281.20x
💻 LocalNext.js (Turbopack)8.432s9.017s0.585s142.53x
💻 LocalExpress9.097s (+3.3%)9.555s (+4.3%)0.459s132.73x
💻 LocalNitro9.155s (+26.4% 🔺)9.864s (+23.1% 🔺)0.710s132.75x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro87.838s (+1.9%)104.355s (+19.6% 🔺)16.517s21.00x
▲ VercelNext.js (Turbopack)88.131s (+2.3%)90.390s (+2.2%)2.259s21.00x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)0.258s1.008s0.750s601.00x
🐘 PostgresNitro0.270s (-14.9% 🟢)1.008s (~)0.738s601.05x
🐘 PostgresExpress0.270s (-8.7% 🟢)1.008s (~)0.738s601.05x
🌐 RedisNext.js (Turbopack)0.365s1.004s0.640s601.42x
💻 LocalNext.js (Turbopack)0.551s1.004s0.453s602.14x
💻 LocalExpress0.595s (+4.7%)1.004s (~)0.410s602.31x
💻 LocalNitro0.598s (+6.7% 🔺)1.022s (+1.7%)0.423s592.32x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro1.834s (+9.2% 🔺)3.825s (+15.9% 🔺)1.992s161.00x
▲ VercelNext.js (Turbopack)2.568s (+35.7% 🔺)9.530s (+154.0% 🔺)6.962s121.40x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)0.481s1.019s0.538s891.00x
🐘 PostgresExpress0.493s (-10.0% 🟢)1.008s (~)0.515s901.03x
🐘 PostgresNitro0.498s (-14.7% 🟢)1.008s (~)0.511s901.04x
🌐 RedisNext.js (Turbopack)1.207s2.006s0.799s452.51x
💻 LocalNitro2.505s (+6.9% 🔺)3.009s (~)0.503s305.21x
💻 LocalNext.js (Turbopack)2.539s3.009s0.470s305.28x
💻 LocalExpress2.567s (+4.7%)3.009s (~)0.442s305.34x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.344s (+11.3% 🔺)5.095s (+14.5% 🔺)1.751s181.00x
▲ VercelNext.js (Turbopack)4.913s (+51.5% 🔺)6.649s (+40.7% 🔺)1.736s141.47x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)0.760s1.008s0.247s1201.00x
🐘 PostgresNitro0.767s (-20.7% 🟢)1.008s (-27.9% 🟢)0.241s1201.01x
🐘 PostgresExpress0.774s (-15.8% 🟢)1.008s (-18.7% 🟢)0.234s1201.02x
🌐 RedisNext.js (Turbopack)2.757s3.084s0.327s393.63x
💻 LocalNext.js (Turbopack)10.553s11.027s0.473s1113.88x
💻 LocalExpress11.228s (+1.9%)11.937s (+2.3%)0.708s1114.76x
💻 LocalNitro11.257s (+13.4% 🔺)12.029s (+16.1% 🔺)0.771s1014.80x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)7.133s (-77.8% 🟢)8.902s (-73.6% 🟢)1.769s141.00x
▲ VercelNitro7.220s (~)8.773s (+1.1%)1.554s141.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.175s1.001s0.011s1.017s0.842s101.00x
🌐 RedisNext.js (Turbopack)0.183s1.000s0.001s1.008s0.825s101.05x
🐘 PostgresExpress0.195s (-12.5% 🟢)0.996s (~)0.002s (+15.4% 🔺)1.011s (~)0.816s101.11x
🐘 PostgresNext.js (Turbopack)0.199s1.000s0.004s1.014s0.814s101.14x
🐘 PostgresNitro0.204s (-15.0% 🟢)0.995s (~)0.001s (-27.8% 🟢)1.011s (~)0.807s101.17x
💻 LocalNitro0.208s (+49.5% 🔺)1.003s (~)0.012s (+19.6% 🔺)1.018s (~)0.810s101.19x
💻 LocalExpress0.211s (+2.1%)1.003s (~)0.011s (-5.9% 🟢)1.017s (~)0.806s101.20x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro1.779s (+15.1% 🔺)2.497s (-4.5%)0.810s (+76.9% 🔺)3.996s (+11.2% 🔺)2.217s101.00x
▲ VercelNext.js (Turbopack)1.926s (+17.8% 🔺)2.945s (+0.9%)0.662s (+65.8% 🔺)4.322s (+11.6% 🔺)2.396s101.08x
▲ VercelExpress⚠️missing-----

🔍 Observability: Nitro | Next.js (Turbopack)

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)0.487s1.000s0.003s1.011s0.524s601.00x
🐘 PostgresExpress0.593s (-15.7% 🟢)1.003s (~)0.013s (+103.1% 🔺)1.033s (~)0.440s591.22x
🐘 PostgresNitro0.602s (-17.0% 🟢)1.004s (~)0.004s (-37.7% 🟢)1.023s (-0.7%)0.421s591.24x
🐘 PostgresNext.js (Turbopack)0.611s1.007s0.004s1.025s0.414s591.25x
💻 LocalNext.js (Turbopack)0.666s1.008s0.009s1.023s0.357s591.37x
💻 LocalNitro0.724s (+27.6% 🔺)1.009s (~)0.010s (+4.6%)1.024s (~)0.299s591.49x
💻 LocalExpress0.734s (+2.3%)1.009s (~)0.009s (-5.3% 🟢)1.022s (~)0.288s591.51x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)5.132s (+12.4% 🔺)6.256s (+11.9% 🔺)0.242s (+7.6% 🔺)13.240s (+104.9% 🔺)8.108s51.00x
▲ VercelNitro5.363s (~)6.372s (+3.7%)0.403s (-35.9% 🟢)7.632s (+1.2%)2.269s81.05x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)0.897s1.034s0.000s1.039s0.141s581.00x
🐘 PostgresNext.js (Turbopack)0.948s1.132s0.000s1.141s0.193s531.06x
🐘 PostgresExpress0.956s (-14.8% 🟢)1.271s (-28.6% 🟢)0.000s (-63.8% 🟢)1.286s (-28.9% 🟢)0.331s471.06x
🐘 PostgresNitro0.965s (-17.5% 🟢)1.298s (-35.0% 🟢)0.000s (-2.2%)1.315s (-35.0% 🟢)0.350s461.08x
💻 LocalNitro1.224s (+8.0% 🔺)2.020s (~)0.000s (+50.0% 🔺)2.023s (~)0.799s301.36x
💻 LocalExpress1.246s (+3.6%)2.020s (~)0.000s (-42.9% 🟢)2.023s (~)0.777s301.39x
💻 LocalNext.js (Turbopack)1.270s2.020s0.000s2.024s0.754s301.42x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.626s (+3.0%)4.889s (+5.1% 🔺)0.004s (+Infinity% 🔺)5.632s (+8.2% 🔺)2.006s111.00x
▲ VercelNitro3.756s (+24.1% 🔺)4.924s (+25.5% 🔺)0.002s (+1045.5% 🔺)5.654s (+27.8% 🔺)1.898s111.04x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

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

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.603s2.002s0.000s2.007s0.403s301.00x
🐘 PostgresNext.js (Turbopack)1.831s2.106s0.000s2.115s0.284s291.14x
🐘 PostgresExpress1.840s (-10.6% 🟢)2.258s (-8.8% 🟢)0.000s (NaN%)2.271s (-9.1% 🟢)0.431s271.15x
🐘 PostgresNitro1.853s (-18.2% 🟢)2.223s (-24.7% 🟢)0.000s (-100.0% 🟢)2.235s (-24.7% 🟢)0.382s271.16x
💻 LocalNitro3.585s (+3.6%)4.300s (+6.7% 🔺)0.000s (-61.5% 🟢)4.304s (+6.7% 🔺)0.720s152.24x
💻 LocalNext.js (Turbopack)3.632s4.230s0.001s4.235s0.604s152.27x
💻 LocalExpress3.670s (+8.3% 🔺)4.032s (-1.6%)0.000s (+250.0% 🔺)4.036s (-1.6%)0.366s152.29x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro4.200s (+21.3% 🔺)5.030s (+20.0% 🔺)0.000s (+Infinity% 🔺)5.900s (+24.3% 🔺)1.700s111.00x
▲ VercelNext.js (Turbopack)5.104s (+14.0% 🔺)6.017s (+11.1% 🔺)0.000s (+10.0% 🔺)6.656s (+11.9% 🔺)1.552s101.22x
▲ VercelExpress⚠️missing-----

🔍 Observability: Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)15/21
🐘 PostgresNext.js (Turbopack)13/21
▲ VercelNitro16/21
Fastest World by Framework

Winner determined by most benchmark wins

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

📋 View full workflow run


Some benchmark jobs failed:

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

Check the workflow run for details.

@github-actions

github-actionsBot commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production813067880
✅ 💻 Local Development7820178960
✅ 📦 Local Production7820178960
✅ 🐘 Local Postgres7820178960
✅ 🪟 Windows720880
❌ 🌍 Community Worlds1185630204
✅ 📋 Other198042240
Total3547566814284

❌ Failed Tests

🌍 Community Worlds (56 failed)

mongodb (3 failed):

  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KMEKZQ811N46GD5AK5RW1HD9
  • webhookWorkflow | wrun_01KMEKZZZNPHAFY93GWGMA33E4
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KMEM7DRXD1RYA1E11HJTJFPW

redis (2 failed):

  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KMEKZQ811N46GD5AK5RW1HD9
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KMEM7DRXD1RYA1E11HJTJFPW

turso (51 failed):

  • addTenWorkflow | wrun_01KMEKYFN28CV699JDRY8EVENQ
  • addTenWorkflow | wrun_01KMEKYFN28CV699JDRY8EVENQ
  • wellKnownAgentWorkflow (.well-known/agent) | wrun_01KMEKZV5Y7H35GS496CWE2PWY
  • should work with react rendering in step
  • promiseAllWorkflow | wrun_01KMEKYP9VT3R0SKBHBMF46WF1
  • promiseRaceWorkflow | wrun_01KMEKYV2NHKMK1PT16PHGC0TE
  • promiseAnyWorkflow | wrun_01KMEKYYRJ4XWER78HC3BC88RH
  • importedStepOnlyWorkflow | wrun_01KMEM07SDMHGNED3K1Z8822HA
  • hookWorkflow | wrun_01KMEKZB3KDGNSRDK1ZP1ZWCS2
  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KMEKZQ811N46GD5AK5RW1HD9
  • webhookWorkflow | wrun_01KMEKZZZNPHAFY93GWGMA33E4
  • sleepingWorkflow | wrun_01KMEM07AP1ZHHP0B1Y28XPTPF
  • parallelSleepWorkflow | wrun_01KMEM0M62BA11GZH9N30PA01V
  • nullByteWorkflow | wrun_01KMEM0RNC851K91RM7QKG9J0A
  • workflowAndStepMetadataWorkflow | wrun_01KMEM0TS1NDEE94P4ZK643V6C
  • fetchWorkflow | wrun_01KMEM3MRRD40CZ6RZHVTBN0QE
  • promiseRaceStressTestWorkflow | wrun_01KMEM3RJJYY05KEDAWHC7YZCF
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion | wrun_01KMEM6SVSD36537C7RMSNZ7K2
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KMEM7DRXD1RYA1E11HJTJFPW
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running | wrun_01KMEM84D71052EEDK1NTW09QR
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars) | wrun_01KMEM8QRVX2ZN0ZY8KSFXYRFW
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument | wrun_01KMEM90F9YAZG8G7CZZR9KQWQ
  • closureVariableWorkflow - nested step functions with closure variables | wrun_01KMEM96EMKN1H5C673QHQM0RJ
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step | wrun_01KMEM9A0HFZKBNE055EZ9P5H4
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly | wrun_01KMEM9RJS5TNK0QPDKP86M45K
  • Calculator.calculate - static workflow method using static step methods from another class | wrun_01KMEM9XZANZTCTYJ5TEE55MRZ
  • AllInOneService.processNumber - static workflow method using sibling static step methods | wrun_01KMEMA4B58C28JE56JM1YFVQV
  • ChainableService.processWithThis - static step methods using this to reference the class | wrun_01KMEMAATKT6G9NX72T548BXF1
  • thisSerializationWorkflow - step function invoked with .call() and .apply() | wrun_01KMEMAH46HQ91CQS2CS66CDC6
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE | wrun_01KMEMAQHGV2M8JHE762DZMKC6
  • instanceMethodStepWorkflow - instance methods with "use step" directive | wrun_01KMEMAYB4Y9GXM4F81Y6CNJ6B
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context | wrun_01KMEMB8XA6D9RX061RHB81PKZ
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument | wrun_01KMEMBGE2CX800GQHW1H2HAJF
  • cancelRun - cancelling a running workflow | wrun_01KMEMBQWVXW1JB5P0HV743Q4P
  • cancelRun via CLI - cancelling a running workflow | wrun_01KMEMC0XTPK7JCE38TSAYE9N8
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep | wrun_01KMEMCCDFRYCKTFTV8TJ03YRG
  • sleepInLoopWorkflow - sleep inside loop with steps actually delays each iteration | wrun_01KMEMD0561BCM0VA669B3A6CS
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control) | wrun_01KMEMDAE0C841QKT27361H8BN

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro7307
✅ example7307
✅ express7307
✅ fastify7307
✅ hono7307
✅ nextjs-turbopack7802
✅ nextjs-webpack7802
✅ nitro7307
✅ nuxt7307
✅ sveltekit7307
✅ vite7307
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable66014
✅ express-stable66014
✅ fastify-stable66014
✅ hono-stable66014
✅ nextjs-turbopack-canary55025
✅ nextjs-turbopack-stable7208
✅ nextjs-webpack-canary55025
✅ nextjs-webpack-stable7208
✅ nitro-stable66014
✅ nuxt-stable66014
✅ sveltekit-stable66014
✅ vite-stable66014
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable66014
✅ express-stable66014
✅ fastify-stable66014
✅ hono-stable66014
✅ nextjs-turbopack-canary55025
✅ nextjs-turbopack-stable7208
✅ nextjs-webpack-canary55025
✅ nextjs-webpack-stable7208
✅ nitro-stable66014
✅ nuxt-stable66014
✅ sveltekit-stable66014
✅ vite-stable66014
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable66014
✅ express-stable66014
✅ fastify-stable66014
✅ hono-stable66014
✅ nextjs-turbopack-canary55025
✅ nextjs-turbopack-stable7208
✅ nextjs-webpack-canary55025
✅ nextjs-webpack-stable7208
✅ nitro-stable66014
✅ nuxt-stable66014
✅ sveltekit-stable66014
✅ vite-stable66014
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack7208
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev302
❌ mongodb5238
✅ redis-dev302
❌ redis5328
✅ turso-dev302
❌ turso4518
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable66014
✅ e2e-local-postgres-nest-stable66014
✅ e2e-local-prod-nest-stable66014

📋 View full workflow run

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Comment threadpackages/core/e2e/e2e.test.ts Outdated

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Here's my review of PR #1470:

Summary

This is a feature PR by @VaguelySerious that adds two new Streamer interface methods (getStreamChunks and getStreamInfo) across all world implementations, extends Run.getReadable() with a getTailIndex() helper, and adds initialStartIndex support to WorkflowChatTransport for resuming streams from the end.

No Blockers

CI is in excellent shape. The code changes are well-structured, well-documented, and backward-compatible.

CI Status

Passing:

  • All Vercel Production E2E tests: 11/11 frameworks pass (802 passed, 0 failed)
  • All Local Dev E2E tests: 12/12 pass (782 passed)
  • All Local Prod E2E tests: 12/12 pass
  • All Local Postgres E2E tests: 12/12 pass
  • Windows E2E tests pass
  • Unit tests pass (ubuntu + windows)
  • Vitest plugin tests pass
  • All Vercel deployments succeed (except workbench-nestjs-workflow - discussed below)
  • Benchmarks pass for local/postgres; no performance regressions
  • Docs code samples and links pass

Failing (non-blocking / pre-existing):

  • workbench-nestjs-workflow Vercel deployment fails - this is likely a pre-existing issue unrelated to streaming changes (the nest workbench has no streaming code changes in this PR)
  • Benchmark Vercel (nitro-v3) fails at ~30min - likely infra timeout, not PR-related
  • Community World (Turso) - 51 failures, pre-existing community world issue (same pattern as other PRs)
  • Community World (MongoDB/Redis) - 3/2 failures on hook-related tests, pre-existing

Code Review

1. Streamer interface additions (packages/world/src/interfaces.ts) - Clean

Two new methods added to the Streamer interface:

  • getStreamChunks(name, runId, options?) - cursor-based pagination for stream chunks
  • getStreamInfo(name, runId) - lightweight metadata (tailIndex, done flag)

New types (StreamChunk, StreamChunksResponse, GetChunksOptions, StreamInfoResponse) are well-documented in packages/world/src/shared.ts. The interface is additive and non-breaking.

2. world-vercel implementation (packages/world-vercel/src/streamer.ts) - Clean

Uses makeRequest + Zod schemas to call two new server endpoints:

  • GET /v2/runs/:runId/streams/:name/chunks?limit=&cursor=
  • GET /v2/runs/:runId/streams/:name/info

The StreamChunksResponseSchema correctly expects z.instanceof(Uint8Array) since CBOR handles binary natively. Clean and minimal.

3. world-local implementation (packages/world-local/src/streamer.ts) - Works but verbose

The implementation reads chunk files from disk, builds a file extension map, sorts by ULID, and paginates. One observation:

  • getStreamInfo reads every chunk file to count data chunks and detect EOF. For large streams this could be expensive. The getStreamChunks implementation has the same pattern. Both could potentially be optimized with a count-based approach, but for local dev this is fine.
  • The cursor encoding (base64-encoded JSON { i: index }) is a reasonable pattern.

4. world-postgres implementation (packages/world-postgres/src/streamer.ts) - Solid

Uses Drizzle ORM with proper query patterns:

  • Fetches limit + 1 rows to detect hasMore efficiently
  • Correctly handles the EOF row being mixed into results
  • Has a follow-up EOF check query when !streamDone && !hasMore to correctly report done status
  • Cursor encodes both chunk ID and running index ({ c: chunkId, i: baseIndex })

One minor cleanup: void fn() on line 407 (changing cleanups.forEach((fn) => fn()) to cleanups.forEach((fn) => void fn())) is a good lint fix to suppress floating promise warnings.

5. WorkflowReadableStream type and getTailIndex() (packages/core/src/runtime/run.ts) - Clean

Uses Object.assign to add getTailIndex() to the ReadableStream. This is a clean pattern that avoids subclassing. The type is properly exported through packages/workflow/src/api.ts.

6. WorkflowChatTransport changes (packages/ai/src/workflow-chat-transport.ts) - Well thought out

The negative initialStartIndex feature is carefully designed:

  • First request uses the explicit (potentially negative) startIndex
  • On retry, resolves to an absolute position using x-workflow-stream-tail-index header
  • Falls back to startIndex=0 with a warning if the header is missing
  • useExplicitStartIndex flag ensures the negative value is only used once, then subsequent retries use the running chunkIndex

The replayFromStart flag handles the edge case where the header is absent gracefully.

7. Tests - Good coverage

  • Two new e2e tests for getTailIndex and getStreamChunks pagination (skipped on local deployments since they require server-side endpoints)
  • Two new unit tests for WorkflowChatTransport covering negative initialStartIndex with and without the tail-index header

8. Docs - Thorough

Updated resumable-streams.mdx with:

  • New "Resuming from the end of the stream" section
  • Updated stream reconnection endpoint to include getTailIndex() and the x-workflow-stream-tail-index header
  • Callout about header requirement for negative initialStartIndex
  • API reference docs updated

9. Changeset - Present and correct

Includes @workflow/world, world-local, world-vercel, world-postgres, core, and ai as patch changes.

Minor Notes

  • The PR description is empty ("No description provided"). Worth adding a summary for posterity.
  • The getStreamChunks method uses _runId (unused) in local/postgres implementations since the stream name is already unique there. The vercel implementation passes it to the server endpoint. This is fine - the interface is designed for the vercel world's needs.

Verdict

No blockers or regressions. This is a well-implemented, additive feature with good test coverage and documentation. The only CI failures are pre-existing (nest deployment, community world Turso/MongoDB/Redis, nitro benchmark timeout). Ready to merge once it gets a review approval.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall this is a well-designed, additive feature. The negative initialStartIndex logic in the transport is carefully thought through, the interface additions are clean, and the docs/tests cover the new behavior well. A few issues worth addressing below.

GetChunksOptions,
StreamChunk,
StreamChunksResponse,
} from './shared.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The explicit export type { GetChunksOptions, StreamChunk, StreamChunksResponse } is redundant with the export type * from './shared.js' on line 34 — and it's also missing StreamInfoResponse. Since line 34 already re-exports all types from shared.js, you can just remove this block entirely (lines 39-43).

.select({ chunkId: streams.chunkId })
.from(streams)
.where(and(eq(streams.streamId, name), eq(streams.eof, false)))
.orderBy(asc(streams.chunkId));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

getStreamInfo fetches all data rows into memory just to count them. For streams with thousands of chunks this is wasteful — a SELECT COUNT(*) FROM streams WHERE streamId = $1 AND eof = false would be much cheaper. Consider using Drizzle's count() aggregate or a raw SQL count instead of selecting all chunk IDs.

hasMore,
done: streamDone,
};
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The file-listing + extension-mapping logic (~30 lines) is duplicated verbatim between getStreamChunks and getStreamInfo. Consider extracting a helper like listStreamDataFiles(name) that returns { dataFiles, fileExtMap, streamDone } to keep this DRY.

Comment threadpackages/world-local/src/streamer.ts Outdated
break;
}
dataFiles.push(file);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

getStreamChunks reads and deserializes every chunk file from disk on every call (to separate data files from the EOF marker), regardless of the cursor position. For a stream with 10k chunks, page 2 still reads all 10k files.

Two possible improvements:

  1. Only read files starting from the cursor position (the ULID sort gives chronological order, so you can skip earlier files)
  2. At minimum, stop reading once you have enough files past the cursor — you only need startIndex + limit data files plus potentially the EOF marker

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Deeper review: testing, docs, observability, edge cases, and performance

Testing gaps

world-local and world-postgres have zero unit tests for the new getStreamChunks and getStreamInfo methods. The only coverage comes from two e2e tests that are skipIf(isLocalDeployment()), so local dev never exercises these paths. Cursor pagination logic (invalid cursors, out-of-range cursors, single-item pages), empty stream behavior (tailIndex = -1), and mid-flight streams (calling before workflow completes) are all untested.

The transport unit tests cover the happy path for negative initialStartIndex but miss: NaN/malformed x-workflow-stream-tail-index header values (the !Number.isNaN guard is untested), positive explicit startIndex override, boundary clamping when magnitude exceeds stream length, and multi-retry chains where the header varies across attempts.

Documentation gaps

  1. building-a-world.mdx still shows the old Streamer interface without getStreamChunks or getStreamInfo. Custom world implementers won't know these methods are required.
  2. No API reference for WorkflowReadableStream or getTailIndex() — the get-run.mdx page documents WorkflowReadableStreamOptions but not the new return type.
  3. Changeset mentions getStreamInfo but omits getStreamChunks.

Observability

Existing storage methods are wrapped with instrumentObject() for automatic OTEL spans (e.g. world.runs.get, world.events.create). Streamer methods — both existing and new — are not instrumented at all. No spans, no semantic conventions for stream names/indices, and no structured logging in any world implementation's streaming code. The only observability signal is the console.warn in WorkflowChatTransport for missing headers. This is consistent with the pre-existing pattern (streaming was never instrumented), but it means production debugging of pagination issues, tail-index mismatches, or reconnection failures will be blind.

Edge cases and performance (see inline comments below)

Several specific issues are called out inline.

break;
}
dataRows.push(row);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Edge case: EOF row appearing mid-batch. The query fetches limit + 1 rows ordered by chunkId without filtering out EOF rows. If the EOF row sorts between data rows (e.g. due to concurrent writes or clock skew), the loop breaks early on row.eof, and subsequent data rows in the result set are silently dropped.

Then hasMore = !streamDone && dataRows.length > limit evaluates to false (because streamDone = true), so the client sees done: true, hasMore: false even though there were more data rows in the fetch.

This is probably fine in practice since the EOF ULID is generated after all data chunks, but it's worth noting the implicit ordering assumption. Adding eq(streams.eof, false) to the main query and doing a separate EOF check (like the lookahead on line 250) would make this robust.

.select({ chunkId: streams.chunkId })
.from(streams)
.where(and(eq(streams.streamId, name), eq(streams.eof, false)))
.orderBy(asc(streams.chunkId));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Performance: O(N) memory to count rows. This SELECT chunkId FROM streams WHERE ... loads every non-EOF row into Node.js memory just to take .length. For a stream with 100k chunks, that's 100k objects allocated and immediately discarded.

Use a COUNT(*) aggregate instead:

const[{ count }]=awaitdrizzle.select({count: sql<number>`count(*)`}).from(streams).where(and(eq(streams.streamId,name),eq(streams.eof,false)));return{tailIndex: (count??0)-1,done: !!eofRow};

Comment threadpackages/world-local/src/streamer.ts Outdated
break;
}
dataFiles.push(file);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Performance: every page reads ALL chunk files.getStreamChunks deserializes every chunk file from disk (lines 286-296) to separate data files from the EOF marker, even for chunks that precede the cursor. Page 50 of a 10k-chunk stream still reads all 10k files.

getStreamInfo (line 372) has the exact same pattern — reads all files just to count them.

For local dev this is tolerable, but if these methods get called frequently (e.g. polling for tail index), the cost adds up. A lighter approach: since ULID sort gives chronological order, you could skip files before the cursor index and stop after collecting limit data files + EOF check.

const readable = run.getReadable();
const tailIndex = await readable.getTailIndex();

// outputStreamWorkflow writes 2 chunks to the default stream

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Testing gap: no empty-stream or mid-flight test. These tests only run after await run.returnValue (stream fully complete) and outputStreamWorkflow always writes exactly 2 chunks.

Missing scenarios:

  • Empty stream: What does getTailIndex() return when 0 chunks have been written? (should be -1 per the type docs)
  • Mid-flight stream: What does getTailIndex() / getStreamChunks() return while the workflow is still running and the stream isn't closed yet?
  • getStreamInfo directly: Only tested indirectly via getTailIndex() — no direct assertion on the done flag


warnSpy.mockRestore();
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Testing gap: malformed header values. The implementation has a !Number.isNaN(tailIndex) guard (transport line 362) but no test covers it. Consider adding a case where the header is "not-a-number"parseInt returns NaN, the guard should skip resolution, and the code should fall through without setting chunkIndex.

Also missing: a test for initialStartIndex: -1000 on a 5-chunk stream (should clamp to 0 via Math.max), and a test where a positive startIndex is passed via reconnectToStream options to override initialStartIndex.

const tailIndexHeader = res.headers.get('x-workflow-stream-tail-index');
if (tailIndexHeader !== null) {
const tailIndex = parseInt(tailIndexHeader, 10);
if (!Number.isNaN(tailIndex)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Edge case: NaN guard doesn't fall through cleanly. When parseInt returns NaN, the if (!Number.isNaN(tailIndex)) block is skipped, but replayFromStart is NOT set (that only happens in the else branch for tailIndexHeader === null). So on the next iteration, useExplicitStartIndex is false and replayFromStart is false, meaning the code falls through to chunkIndex — which is still 0 from initialization.

This accidentally works (retries from 0), but the intent is unclear. If the header is present but malformed, should it warn and set replayFromStart = true like the missing-header case? As-is, a malformed header silently degrades to replaying from 0 without the console warning.

GetChunksOptions,
StreamChunk,
StreamChunksResponse,
} from './shared.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Redundant + incomplete export. Line 34 already has export type * from './shared.js' which re-exports all types. This explicit block is redundant and also missing StreamInfoResponse. Just delete lines 39-43.

VaguelySeriousand others added 2 commits March 23, 2026 17:22
- world-postgres: use COUNT(*) in getStreamInfo instead of fetching all
rows; filter EOF rows in getStreamChunks query to prevent mid-batch
data loss from clock-skewed EOF rows
- world-local: skip files before cursor in getStreamChunks, read only
EOF byte in getStreamInfo and pre-cursor files
- WorkflowChatTransport: treat unparseable tail-index header same as
missing (set replayFromStart); add unit test for NaN header fallback
- e2e: add test for getTailIndex returning -1 on nonexistent stream
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- building-a-world.mdx: add getStreamChunks and getStreamInfo to
Streamer interface definition
- get-run.mdx: document WorkflowReadableStream and getTailIndex()
- Changeset: mention getStreamChunks alongside getStreamInfo
- world-vercel, world-local: wrap streamer with instrumentObject for
OTEL spans (world.streams.* prefix), matching storage instrumentation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fresh review after update

This update addresses all the critical issues from the previous round. Nice work cleaning this up.

Previously flagged — now resolved

  • Postgres getStreamInfo: Now uses COUNT(*) aggregate instead of fetching all rows into memory
  • Postgres getStreamChunks EOF mid-batch: Main query now filters eq(streams.eof, false), with a separate EOF check — no more risk of EOF silently dropping data rows
  • Transport NaN guard: Missing and malformed headers now both flow through the same else branch — replayFromStart = true + console.warn in both cases
  • world-local getStreamChunks perf: No longer deserializes every chunk — pre-cursor files use isEofChunk() (byte check only), and the loop stops early after collecting enough data
  • world-local getStreamInfo: Also uses isEofChunk() instead of full deserialization
  • Changeset: Now mentions both getStreamChunks() and getStreamInfo()
  • Docs: building-a-world.mdx includes the new Streamer methods with explanatory text. get-run.mdx has a WorkflowReadableStream section with getTailIndex() reference.
  • E2e tests: Added tailIndex returns -1 test using a nonexistent namespace. Added NaN header unit test.
  • OTEL: Both world-vercel and world-local now wrap the streamer with instrumentObject('world.streams', ...)
  • Redundant export: StreamInfoResponse added to the explicit export list in world/src/index.ts

Minor remaining items (non-blocking)

  1. world-local file-listing boilerplate still duplicated — the ~20-line block that lists files by extension, builds the fileExtMap, and filters by stream name is copy-pasted between getStreamChunks (lines 266-286) and getStreamInfo (lines 367-389). Extracting a listChunkFilesForStream(name) helper would reduce maintenance surface.

  2. world-postgres streamer not instrumentedworld-vercel and world-local both wrap their streamer with instrumentObject('world.streams', ...), giving the new methods automatic OTEL spans. world-postgres spreads the streamer directly without instrumentation (packages/world-postgres/src/index.ts:49). Pre-existing inconsistency, but worth noting since this PR adds new methods that would benefit from tracing.

  3. No unit tests for world-local or world-postgres implementations of getStreamChunks/getStreamInfo. The cursor pagination logic (invalid cursors, boundary conditions, EOF detection) is only tested via e2e tests that are skipped in local dev. Consider adding unit tests in a follow-up.

dataCount++;
}

return { tailIndex: dataCount - 1, done: streamDone };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: The file-listing + extension-mapping block (~20 lines) is duplicated between getStreamChunks (lines 266-286) and getStreamInfo (lines 367-389). A shared listChunkFilesForStream(name) helper returning { chunkFiles, fileExtMap } would keep these two methods DRY.

@ghostghost mentioned this pull request Mar 23, 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.

3 participants

@VaguelySerious@pranaygp@karthikscale3
, '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('^' + ".*" + ' [world] Add stream pagination and metadata endpoints by VaguelySerious · Pull Request #1470 · vercel/workflow · GitHub
Skip to content

[world] Add stream pagination and metadata endpoints - #1470

Merged
VaguelySerious merged 32 commits into
mainfrom
peter/stream-metadata-and-pages
Mar 24, 2026
Merged

[world] Add stream pagination and metadata endpoints#1470
VaguelySerious merged 32 commits into
mainfrom
peter/stream-metadata-and-pages

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

No description provided.

VaguelySeriousand others added 13 commits March 19, 2026 17:44
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Negative startIndex values (e.g. -3) resolve to n chunks before the
known end of the stream. All world implementations (local, postgres,
vercel) support this. Includes unit tests, e2e test, and doc updates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Replaces three separate tests (no startIndex, positive, negative) with a
single describe block that iterates over startIndex cases. Fixes the
negative startIndex test by waiting for workflow completion before
connecting the reader — the backend resolves negative indices at
connection time using knownChunkCount, which is 0 if the stream hasn't
been fully written yet.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Replaces three separate tests (no startIndex, positive, negative) with a
single describe block that iterates over startIndex cases. Fixes the
negative startIndex test by waiting for workflow completion before
connecting the reader — the backend resolves negative indices at
connection time using knownChunkCount, which is 0 if the stream hasn't
been fully written yet.
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
- Clarify that negative startIndex is for custom consumers, not WorkflowChatTransport
- Add live-stream caveat for negative startIndex resolution timing
- Add pagination limitation callout for live streams
- Use explicit typeof guards for negative startIndex checks (world-local, world-postgres)
- Add cost comment for EOF marker disk read in world-local
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel

vercelBot commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Mar 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a82fbe7

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

This PR includes changesets to release 20 packages
NameType
@workflow/worldPatch
@workflow/world-localPatch
@workflow/world-vercelPatch
@workflow/world-postgresPatch
@workflow/corePatch
@workflow/aiPatch
@workflow/cliPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
workflowPatch
@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 Mar 20, 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🥇 Express0.043s (-5.4% 🟢)1.006s (~)0.962s101.00x
💻 LocalNitro0.044s (+14.4% 🔺)1.005s (~)0.962s101.00x
💻 LocalNext.js (Turbopack)0.049s1.005s0.956s101.13x
🌐 RedisNext.js (Turbopack)0.055s1.005s0.950s101.26x
🐘 PostgresExpress0.062s (+10.8% 🔺)1.011s (~)0.949s101.42x
🐘 PostgresNitro0.064s (-7.9% 🟢)1.011s (~)0.947s101.47x
🐘 PostgresNext.js (Turbopack)0.065s1.012s0.947s101.50x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro0.458s (-21.6% 🟢)2.677s (+25.7% 🔺)2.219s101.00x
▲ VercelNext.js (Turbopack)0.542s (-17.9% 🟢)2.725s (+14.0% 🔺)2.183s101.18x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.123s2.006s0.883s101.00x
🌐 RedisNext.js (Turbopack)1.125s2.006s0.882s101.00x
💻 LocalExpress1.128s (~)2.006s (~)0.878s101.00x
💻 LocalNitro1.131s (+3.5%)2.006s (~)0.875s101.01x
🐘 PostgresNext.js (Turbopack)1.142s2.013s0.871s101.02x
🐘 PostgresNitro1.149s (-1.2%)2.012s (~)0.863s101.02x
🐘 PostgresExpress1.162s (+0.7%)2.012s (~)0.851s101.03x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.139s (+2.5%)4.147s (+23.1% 🔺)2.008s101.00x
▲ VercelNext.js (Turbopack)2.152s (+4.3%)4.007s (+9.3% 🔺)1.855s101.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)10.776s11.022s0.246s31.00x
💻 LocalNext.js (Turbopack)10.792s11.024s0.232s31.00x
🐘 PostgresExpress10.871s (-0.6%)11.020s (~)0.148s31.01x
🐘 PostgresNext.js (Turbopack)10.875s11.024s0.149s31.01x
💻 LocalExpress10.906s (-0.5%)11.023s (~)0.118s31.01x
💻 LocalNitro10.923s (+2.9%)11.023s (~)0.100s31.01x
🐘 PostgresNitro10.938s (-1.8%)11.026s (-8.5% 🟢)0.088s31.02x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)17.601s (+2.3%)19.403s (+1.9%)1.803s21.00x
▲ VercelNitro17.930s (~)20.036s (+2.1%)2.106s21.02x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)14.213s15.028s0.815s41.00x
🐘 PostgresNext.js (Turbopack)14.424s15.026s0.602s41.01x
💻 LocalNext.js (Turbopack)14.540s15.029s0.489s41.02x
🐘 PostgresExpress14.580s (-1.6%)15.024s (~)0.444s41.03x
🐘 PostgresNitro14.643s (-1.7%)15.033s (~)0.390s41.03x
💻 LocalExpress14.944s (-0.6%)15.029s (-4.8%)0.084s41.05x
💻 LocalNitro14.984s (+5.4% 🔺)15.033s (~)0.049s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro34.179s (+4.6%)35.468s (+4.2%)1.289s21.00x
▲ VercelNext.js (Turbopack)35.203s (+4.7%)37.010s (+6.3% 🔺)1.807s21.03x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)13.441s14.026s0.585s71.00x
🐘 PostgresNext.js (Turbopack)13.969s14.167s0.198s71.04x
🐘 PostgresExpress13.969s (-3.2%)14.452s (-3.9%)0.482s71.04x
🐘 PostgresNitro14.112s (-5.0% 🟢)14.741s (-3.1%)0.629s71.05x
💻 LocalNext.js (Turbopack)16.013s16.362s0.348s61.19x
💻 LocalExpress16.580s (-2.7%)17.030s (-1.9%)0.450s61.23x
💻 LocalNitro16.653s (+11.9% 🔺)17.031s (+13.3% 🔺)0.378s61.24x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro58.422s (-5.3% 🟢)61.010s (-3.0%)2.587s21.00x
▲ VercelNext.js (Turbopack)63.237s (+3.3%)64.613s (+3.3%)1.376s21.08x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)1.235s2.010s0.775s151.00x
🐘 PostgresNitro1.257s (-3.4%)2.012s (~)0.756s151.02x
🐘 PostgresExpress1.261s (-1.7%)2.012s (~)0.751s151.02x
🌐 RedisNext.js (Turbopack)1.321s2.007s0.685s151.07x
💻 LocalExpress1.527s (~)2.005s (~)0.478s151.24x
💻 LocalNitro1.527s (+4.8%)2.005s (~)0.478s151.24x
💻 LocalNext.js (Turbopack)1.572s2.006s0.434s151.27x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.597s (+2.8%)4.602s (+19.8% 🔺)2.005s71.00x
▲ VercelNext.js (Turbopack)2.630s (+1.5%)4.054s (-1.1%)1.423s81.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express2.354s (-3.6%)3.010s (~)0.657s101.00x
🐘 PostgresNitro2.354s (-5.6% 🟢)3.010s (~)0.656s101.00x
🐘 PostgresNext.js (Turbopack)2.412s3.010s0.599s101.02x
🌐 RedisNext.js (Turbopack)2.599s3.008s0.409s101.10x
💻 LocalNext.js (Turbopack)2.975s3.565s0.590s91.26x
💻 LocalNitro3.009s (+19.1% 🔺)3.760s (+25.0% 🔺)0.751s81.28x
💻 LocalExpress3.030s (-2.3%)3.760s (-6.2% 🟢)0.730s81.29x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.782s (+6.8% 🔺)5.013s (+36.6% 🔺)2.230s61.00x
▲ VercelNext.js (Turbopack)3.142s (+7.0% 🔺)4.966s (+15.8% 🔺)1.824s71.13x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro3.464s (-5.5% 🟢)4.015s (~)0.551s81.00x
🐘 PostgresExpress3.475s (-2.7%)4.014s (~)0.539s81.00x
🐘 PostgresNext.js (Turbopack)3.663s4.010s0.347s81.06x
🌐 RedisNext.js (Turbopack)4.261s5.011s0.750s61.23x
💻 LocalNext.js (Turbopack)7.418s8.016s0.597s42.14x
💻 LocalExpress8.564s (-1.7%)9.021s (-2.7%)0.457s42.47x
💻 LocalNitro9.200s (+37.7% 🔺)9.772s (+39.3% 🔺)0.571s42.66x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.261s (-3.7%)5.062s (+5.6% 🔺)1.801s71.00x
▲ VercelNext.js (Turbopack)3.526s (-15.3% 🟢)6.284s (+13.1% 🔺)2.758s51.08x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)1.219s2.009s0.790s151.00x
🐘 PostgresNitro1.252s (-3.0%)2.009s (~)0.757s151.03x
🐘 PostgresExpress1.255s (-2.8%)2.009s (~)0.754s151.03x
🌐 RedisNext.js (Turbopack)1.305s2.006s0.701s151.07x
💻 LocalNext.js (Turbopack)1.508s2.005s0.497s151.24x
💻 LocalNitro1.573s (+7.4% 🔺)2.007s (~)0.434s151.29x
💻 LocalExpress1.598s (+3.5%)2.072s (+3.3%)0.474s151.31x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.243s (-13.3% 🟢)3.995s (+5.7% 🔺)1.752s81.00x
▲ VercelNitro2.260s (-2.0%)4.048s (+7.7% 🔺)1.787s81.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.334s (-6.4% 🟢)3.011s (~)0.677s101.00x
🐘 PostgresExpress2.339s (-4.7%)3.010s (~)0.672s101.00x
🐘 PostgresNext.js (Turbopack)2.402s3.010s0.609s101.03x
🌐 RedisNext.js (Turbopack)2.544s3.008s0.464s101.09x
💻 LocalNext.js (Turbopack)2.868s3.453s0.585s91.23x
💻 LocalExpress2.992s (-5.7% 🟢)3.762s (-6.2% 🟢)0.770s81.28x
💻 LocalNitro3.086s (+7.6% 🔺)3.760s (+21.0% 🔺)0.674s81.32x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.905s (+1.0%)5.039s (+17.1% 🔺)2.134s71.00x
▲ VercelNext.js (Turbopack)3.300s (+10.1% 🔺)4.771s (+11.2% 🔺)1.471s71.14x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro3.469s (-4.9%)4.013s (~)0.544s81.00x
🐘 PostgresExpress3.473s (-3.4%)4.014s (~)0.541s81.00x
🐘 PostgresNext.js (Turbopack)3.670s4.012s0.342s81.06x
🌐 RedisNext.js (Turbopack)4.275s4.868s0.593s71.23x
💻 LocalExpress8.786s (-4.0%)9.022s (-10.0% 🟢)0.237s42.53x
💻 LocalNext.js (Turbopack)8.883s9.772s0.889s42.56x
💻 LocalNitro9.326s (+26.1% 🔺)10.024s (+25.0% 🔺)0.698s32.69x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.294s (+6.2% 🔺)5.002s (+6.1% 🔺)1.707s61.00x
▲ VercelNext.js (Turbopack)4.267s (+19.0% 🔺)6.135s (+24.6% 🔺)1.869s51.30x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)0.699s1.005s0.305s601.00x
🐘 PostgresNext.js (Turbopack)0.769s1.007s0.238s601.10x
🐘 PostgresExpress0.801s (-12.5% 🟢)1.007s (-6.7% 🟢)0.206s601.15x
🐘 PostgresNitro0.829s (-14.4% 🟢)1.007s (-18.5% 🟢)0.178s601.19x
💻 LocalNext.js (Turbopack)0.841s1.021s0.180s591.20x
💻 LocalExpress0.977s (-3.4%)1.076s (-30.3% 🟢)0.098s561.40x
💻 LocalNitro1.015s (+50.6% 🔺)1.628s (+62.1% 🔺)0.613s371.45x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro10.274s (+2.1%)12.099s (+5.9% 🔺)1.825s51.00x
▲ VercelNext.js (Turbopack)10.675s (+7.4% 🔺)12.483s (+1.0%)1.808s51.04x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.674s2.006s0.332s451.00x
🐘 PostgresNext.js (Turbopack)1.861s2.030s0.169s451.11x
🐘 PostgresExpress1.920s (-13.7% 🟢)2.077s (-31.0% 🟢)0.157s441.15x
🐘 PostgresNitro1.957s (-15.4% 🟢)2.176s (-27.8% 🟢)0.219s421.17x
💻 LocalNext.js (Turbopack)2.613s3.007s0.395s301.56x
💻 LocalExpress2.984s (-1.6%)3.341s (-9.4% 🟢)0.357s271.78x
💻 LocalNitro3.007s (+37.1% 🔺)3.547s (+18.0% 🔺)0.541s261.80x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro32.892s (+9.6% 🔺)34.652s (+8.7% 🔺)1.761s31.00x
▲ VercelNext.js (Turbopack)34.160s (+5.3% 🔺)51.070s (+51.8% 🔺)16.910s21.04x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)3.329s4.009s0.680s301.00x
🐘 PostgresNext.js (Turbopack)3.777s4.011s0.234s301.13x
🐘 PostgresExpress3.894s (-13.3% 🟢)4.184s (-16.6% 🟢)0.289s291.17x
🐘 PostgresNitro3.982s (-16.9% 🟢)4.404s (-13.6% 🟢)0.422s281.20x
💻 LocalNext.js (Turbopack)8.432s9.017s0.585s142.53x
💻 LocalExpress9.097s (+3.3%)9.555s (+4.3%)0.459s132.73x
💻 LocalNitro9.155s (+26.4% 🔺)9.864s (+23.1% 🔺)0.710s132.75x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro87.838s (+1.9%)104.355s (+19.6% 🔺)16.517s21.00x
▲ VercelNext.js (Turbopack)88.131s (+2.3%)90.390s (+2.2%)2.259s21.00x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)0.258s1.008s0.750s601.00x
🐘 PostgresNitro0.270s (-14.9% 🟢)1.008s (~)0.738s601.05x
🐘 PostgresExpress0.270s (-8.7% 🟢)1.008s (~)0.738s601.05x
🌐 RedisNext.js (Turbopack)0.365s1.004s0.640s601.42x
💻 LocalNext.js (Turbopack)0.551s1.004s0.453s602.14x
💻 LocalExpress0.595s (+4.7%)1.004s (~)0.410s602.31x
💻 LocalNitro0.598s (+6.7% 🔺)1.022s (+1.7%)0.423s592.32x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro1.834s (+9.2% 🔺)3.825s (+15.9% 🔺)1.992s161.00x
▲ VercelNext.js (Turbopack)2.568s (+35.7% 🔺)9.530s (+154.0% 🔺)6.962s121.40x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)0.481s1.019s0.538s891.00x
🐘 PostgresExpress0.493s (-10.0% 🟢)1.008s (~)0.515s901.03x
🐘 PostgresNitro0.498s (-14.7% 🟢)1.008s (~)0.511s901.04x
🌐 RedisNext.js (Turbopack)1.207s2.006s0.799s452.51x
💻 LocalNitro2.505s (+6.9% 🔺)3.009s (~)0.503s305.21x
💻 LocalNext.js (Turbopack)2.539s3.009s0.470s305.28x
💻 LocalExpress2.567s (+4.7%)3.009s (~)0.442s305.34x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.344s (+11.3% 🔺)5.095s (+14.5% 🔺)1.751s181.00x
▲ VercelNext.js (Turbopack)4.913s (+51.5% 🔺)6.649s (+40.7% 🔺)1.736s141.47x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)0.760s1.008s0.247s1201.00x
🐘 PostgresNitro0.767s (-20.7% 🟢)1.008s (-27.9% 🟢)0.241s1201.01x
🐘 PostgresExpress0.774s (-15.8% 🟢)1.008s (-18.7% 🟢)0.234s1201.02x
🌐 RedisNext.js (Turbopack)2.757s3.084s0.327s393.63x
💻 LocalNext.js (Turbopack)10.553s11.027s0.473s1113.88x
💻 LocalExpress11.228s (+1.9%)11.937s (+2.3%)0.708s1114.76x
💻 LocalNitro11.257s (+13.4% 🔺)12.029s (+16.1% 🔺)0.771s1014.80x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)7.133s (-77.8% 🟢)8.902s (-73.6% 🟢)1.769s141.00x
▲ VercelNitro7.220s (~)8.773s (+1.1%)1.554s141.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.175s1.001s0.011s1.017s0.842s101.00x
🌐 RedisNext.js (Turbopack)0.183s1.000s0.001s1.008s0.825s101.05x
🐘 PostgresExpress0.195s (-12.5% 🟢)0.996s (~)0.002s (+15.4% 🔺)1.011s (~)0.816s101.11x
🐘 PostgresNext.js (Turbopack)0.199s1.000s0.004s1.014s0.814s101.14x
🐘 PostgresNitro0.204s (-15.0% 🟢)0.995s (~)0.001s (-27.8% 🟢)1.011s (~)0.807s101.17x
💻 LocalNitro0.208s (+49.5% 🔺)1.003s (~)0.012s (+19.6% 🔺)1.018s (~)0.810s101.19x
💻 LocalExpress0.211s (+2.1%)1.003s (~)0.011s (-5.9% 🟢)1.017s (~)0.806s101.20x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro1.779s (+15.1% 🔺)2.497s (-4.5%)0.810s (+76.9% 🔺)3.996s (+11.2% 🔺)2.217s101.00x
▲ VercelNext.js (Turbopack)1.926s (+17.8% 🔺)2.945s (+0.9%)0.662s (+65.8% 🔺)4.322s (+11.6% 🔺)2.396s101.08x
▲ VercelExpress⚠️missing-----

🔍 Observability: Nitro | Next.js (Turbopack)

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)0.487s1.000s0.003s1.011s0.524s601.00x
🐘 PostgresExpress0.593s (-15.7% 🟢)1.003s (~)0.013s (+103.1% 🔺)1.033s (~)0.440s591.22x
🐘 PostgresNitro0.602s (-17.0% 🟢)1.004s (~)0.004s (-37.7% 🟢)1.023s (-0.7%)0.421s591.24x
🐘 PostgresNext.js (Turbopack)0.611s1.007s0.004s1.025s0.414s591.25x
💻 LocalNext.js (Turbopack)0.666s1.008s0.009s1.023s0.357s591.37x
💻 LocalNitro0.724s (+27.6% 🔺)1.009s (~)0.010s (+4.6%)1.024s (~)0.299s591.49x
💻 LocalExpress0.734s (+2.3%)1.009s (~)0.009s (-5.3% 🟢)1.022s (~)0.288s591.51x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)5.132s (+12.4% 🔺)6.256s (+11.9% 🔺)0.242s (+7.6% 🔺)13.240s (+104.9% 🔺)8.108s51.00x
▲ VercelNitro5.363s (~)6.372s (+3.7%)0.403s (-35.9% 🟢)7.632s (+1.2%)2.269s81.05x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)0.897s1.034s0.000s1.039s0.141s581.00x
🐘 PostgresNext.js (Turbopack)0.948s1.132s0.000s1.141s0.193s531.06x
🐘 PostgresExpress0.956s (-14.8% 🟢)1.271s (-28.6% 🟢)0.000s (-63.8% 🟢)1.286s (-28.9% 🟢)0.331s471.06x
🐘 PostgresNitro0.965s (-17.5% 🟢)1.298s (-35.0% 🟢)0.000s (-2.2%)1.315s (-35.0% 🟢)0.350s461.08x
💻 LocalNitro1.224s (+8.0% 🔺)2.020s (~)0.000s (+50.0% 🔺)2.023s (~)0.799s301.36x
💻 LocalExpress1.246s (+3.6%)2.020s (~)0.000s (-42.9% 🟢)2.023s (~)0.777s301.39x
💻 LocalNext.js (Turbopack)1.270s2.020s0.000s2.024s0.754s301.42x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.626s (+3.0%)4.889s (+5.1% 🔺)0.004s (+Infinity% 🔺)5.632s (+8.2% 🔺)2.006s111.00x
▲ VercelNitro3.756s (+24.1% 🔺)4.924s (+25.5% 🔺)0.002s (+1045.5% 🔺)5.654s (+27.8% 🔺)1.898s111.04x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

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

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.603s2.002s0.000s2.007s0.403s301.00x
🐘 PostgresNext.js (Turbopack)1.831s2.106s0.000s2.115s0.284s291.14x
🐘 PostgresExpress1.840s (-10.6% 🟢)2.258s (-8.8% 🟢)0.000s (NaN%)2.271s (-9.1% 🟢)0.431s271.15x
🐘 PostgresNitro1.853s (-18.2% 🟢)2.223s (-24.7% 🟢)0.000s (-100.0% 🟢)2.235s (-24.7% 🟢)0.382s271.16x
💻 LocalNitro3.585s (+3.6%)4.300s (+6.7% 🔺)0.000s (-61.5% 🟢)4.304s (+6.7% 🔺)0.720s152.24x
💻 LocalNext.js (Turbopack)3.632s4.230s0.001s4.235s0.604s152.27x
💻 LocalExpress3.670s (+8.3% 🔺)4.032s (-1.6%)0.000s (+250.0% 🔺)4.036s (-1.6%)0.366s152.29x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro4.200s (+21.3% 🔺)5.030s (+20.0% 🔺)0.000s (+Infinity% 🔺)5.900s (+24.3% 🔺)1.700s111.00x
▲ VercelNext.js (Turbopack)5.104s (+14.0% 🔺)6.017s (+11.1% 🔺)0.000s (+10.0% 🔺)6.656s (+11.9% 🔺)1.552s101.22x
▲ VercelExpress⚠️missing-----

🔍 Observability: Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)15/21
🐘 PostgresNext.js (Turbopack)13/21
▲ VercelNitro16/21
Fastest World by Framework

Winner determined by most benchmark wins

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

📋 View full workflow run


Some benchmark jobs failed:

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

Check the workflow run for details.

@github-actions

github-actionsBot commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production813067880
✅ 💻 Local Development7820178960
✅ 📦 Local Production7820178960
✅ 🐘 Local Postgres7820178960
✅ 🪟 Windows720880
❌ 🌍 Community Worlds1185630204
✅ 📋 Other198042240
Total3547566814284

❌ Failed Tests

🌍 Community Worlds (56 failed)

mongodb (3 failed):

  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KMEKZQ811N46GD5AK5RW1HD9
  • webhookWorkflow | wrun_01KMEKZZZNPHAFY93GWGMA33E4
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KMEM7DRXD1RYA1E11HJTJFPW

redis (2 failed):

  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KMEKZQ811N46GD5AK5RW1HD9
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KMEM7DRXD1RYA1E11HJTJFPW

turso (51 failed):

  • addTenWorkflow | wrun_01KMEKYFN28CV699JDRY8EVENQ
  • addTenWorkflow | wrun_01KMEKYFN28CV699JDRY8EVENQ
  • wellKnownAgentWorkflow (.well-known/agent) | wrun_01KMEKZV5Y7H35GS496CWE2PWY
  • should work with react rendering in step
  • promiseAllWorkflow | wrun_01KMEKYP9VT3R0SKBHBMF46WF1
  • promiseRaceWorkflow | wrun_01KMEKYV2NHKMK1PT16PHGC0TE
  • promiseAnyWorkflow | wrun_01KMEKYYRJ4XWER78HC3BC88RH
  • importedStepOnlyWorkflow | wrun_01KMEM07SDMHGNED3K1Z8822HA
  • hookWorkflow | wrun_01KMEKZB3KDGNSRDK1ZP1ZWCS2
  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KMEKZQ811N46GD5AK5RW1HD9
  • webhookWorkflow | wrun_01KMEKZZZNPHAFY93GWGMA33E4
  • sleepingWorkflow | wrun_01KMEM07AP1ZHHP0B1Y28XPTPF
  • parallelSleepWorkflow | wrun_01KMEM0M62BA11GZH9N30PA01V
  • nullByteWorkflow | wrun_01KMEM0RNC851K91RM7QKG9J0A
  • workflowAndStepMetadataWorkflow | wrun_01KMEM0TS1NDEE94P4ZK643V6C
  • fetchWorkflow | wrun_01KMEM3MRRD40CZ6RZHVTBN0QE
  • promiseRaceStressTestWorkflow | wrun_01KMEM3RJJYY05KEDAWHC7YZCF
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion | wrun_01KMEM6SVSD36537C7RMSNZ7K2
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KMEM7DRXD1RYA1E11HJTJFPW
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running | wrun_01KMEM84D71052EEDK1NTW09QR
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars) | wrun_01KMEM8QRVX2ZN0ZY8KSFXYRFW
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument | wrun_01KMEM90F9YAZG8G7CZZR9KQWQ
  • closureVariableWorkflow - nested step functions with closure variables | wrun_01KMEM96EMKN1H5C673QHQM0RJ
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step | wrun_01KMEM9A0HFZKBNE055EZ9P5H4
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly | wrun_01KMEM9RJS5TNK0QPDKP86M45K
  • Calculator.calculate - static workflow method using static step methods from another class | wrun_01KMEM9XZANZTCTYJ5TEE55MRZ
  • AllInOneService.processNumber - static workflow method using sibling static step methods | wrun_01KMEMA4B58C28JE56JM1YFVQV
  • ChainableService.processWithThis - static step methods using this to reference the class | wrun_01KMEMAATKT6G9NX72T548BXF1
  • thisSerializationWorkflow - step function invoked with .call() and .apply() | wrun_01KMEMAH46HQ91CQS2CS66CDC6
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE | wrun_01KMEMAQHGV2M8JHE762DZMKC6
  • instanceMethodStepWorkflow - instance methods with "use step" directive | wrun_01KMEMAYB4Y9GXM4F81Y6CNJ6B
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context | wrun_01KMEMB8XA6D9RX061RHB81PKZ
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument | wrun_01KMEMBGE2CX800GQHW1H2HAJF
  • cancelRun - cancelling a running workflow | wrun_01KMEMBQWVXW1JB5P0HV743Q4P
  • cancelRun via CLI - cancelling a running workflow | wrun_01KMEMC0XTPK7JCE38TSAYE9N8
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep | wrun_01KMEMCCDFRYCKTFTV8TJ03YRG
  • sleepInLoopWorkflow - sleep inside loop with steps actually delays each iteration | wrun_01KMEMD0561BCM0VA669B3A6CS
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control) | wrun_01KMEMDAE0C841QKT27361H8BN

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro7307
✅ example7307
✅ express7307
✅ fastify7307
✅ hono7307
✅ nextjs-turbopack7802
✅ nextjs-webpack7802
✅ nitro7307
✅ nuxt7307
✅ sveltekit7307
✅ vite7307
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable66014
✅ express-stable66014
✅ fastify-stable66014
✅ hono-stable66014
✅ nextjs-turbopack-canary55025
✅ nextjs-turbopack-stable7208
✅ nextjs-webpack-canary55025
✅ nextjs-webpack-stable7208
✅ nitro-stable66014
✅ nuxt-stable66014
✅ sveltekit-stable66014
✅ vite-stable66014
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable66014
✅ express-stable66014
✅ fastify-stable66014
✅ hono-stable66014
✅ nextjs-turbopack-canary55025
✅ nextjs-turbopack-stable7208
✅ nextjs-webpack-canary55025
✅ nextjs-webpack-stable7208
✅ nitro-stable66014
✅ nuxt-stable66014
✅ sveltekit-stable66014
✅ vite-stable66014
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable66014
✅ express-stable66014
✅ fastify-stable66014
✅ hono-stable66014
✅ nextjs-turbopack-canary55025
✅ nextjs-turbopack-stable7208
✅ nextjs-webpack-canary55025
✅ nextjs-webpack-stable7208
✅ nitro-stable66014
✅ nuxt-stable66014
✅ sveltekit-stable66014
✅ vite-stable66014
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack7208
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev302
❌ mongodb5238
✅ redis-dev302
❌ redis5328
✅ turso-dev302
❌ turso4518
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable66014
✅ e2e-local-postgres-nest-stable66014
✅ e2e-local-prod-nest-stable66014

📋 View full workflow run

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Comment threadpackages/core/e2e/e2e.test.ts Outdated

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Here's my review of PR #1470:

Summary

This is a feature PR by @VaguelySerious that adds two new Streamer interface methods (getStreamChunks and getStreamInfo) across all world implementations, extends Run.getReadable() with a getTailIndex() helper, and adds initialStartIndex support to WorkflowChatTransport for resuming streams from the end.

No Blockers

CI is in excellent shape. The code changes are well-structured, well-documented, and backward-compatible.

CI Status

Passing:

  • All Vercel Production E2E tests: 11/11 frameworks pass (802 passed, 0 failed)
  • All Local Dev E2E tests: 12/12 pass (782 passed)
  • All Local Prod E2E tests: 12/12 pass
  • All Local Postgres E2E tests: 12/12 pass
  • Windows E2E tests pass
  • Unit tests pass (ubuntu + windows)
  • Vitest plugin tests pass
  • All Vercel deployments succeed (except workbench-nestjs-workflow - discussed below)
  • Benchmarks pass for local/postgres; no performance regressions
  • Docs code samples and links pass

Failing (non-blocking / pre-existing):

  • workbench-nestjs-workflow Vercel deployment fails - this is likely a pre-existing issue unrelated to streaming changes (the nest workbench has no streaming code changes in this PR)
  • Benchmark Vercel (nitro-v3) fails at ~30min - likely infra timeout, not PR-related
  • Community World (Turso) - 51 failures, pre-existing community world issue (same pattern as other PRs)
  • Community World (MongoDB/Redis) - 3/2 failures on hook-related tests, pre-existing

Code Review

1. Streamer interface additions (packages/world/src/interfaces.ts) - Clean

Two new methods added to the Streamer interface:

  • getStreamChunks(name, runId, options?) - cursor-based pagination for stream chunks
  • getStreamInfo(name, runId) - lightweight metadata (tailIndex, done flag)

New types (StreamChunk, StreamChunksResponse, GetChunksOptions, StreamInfoResponse) are well-documented in packages/world/src/shared.ts. The interface is additive and non-breaking.

2. world-vercel implementation (packages/world-vercel/src/streamer.ts) - Clean

Uses makeRequest + Zod schemas to call two new server endpoints:

  • GET /v2/runs/:runId/streams/:name/chunks?limit=&cursor=
  • GET /v2/runs/:runId/streams/:name/info

The StreamChunksResponseSchema correctly expects z.instanceof(Uint8Array) since CBOR handles binary natively. Clean and minimal.

3. world-local implementation (packages/world-local/src/streamer.ts) - Works but verbose

The implementation reads chunk files from disk, builds a file extension map, sorts by ULID, and paginates. One observation:

  • getStreamInfo reads every chunk file to count data chunks and detect EOF. For large streams this could be expensive. The getStreamChunks implementation has the same pattern. Both could potentially be optimized with a count-based approach, but for local dev this is fine.
  • The cursor encoding (base64-encoded JSON { i: index }) is a reasonable pattern.

4. world-postgres implementation (packages/world-postgres/src/streamer.ts) - Solid

Uses Drizzle ORM with proper query patterns:

  • Fetches limit + 1 rows to detect hasMore efficiently
  • Correctly handles the EOF row being mixed into results
  • Has a follow-up EOF check query when !streamDone && !hasMore to correctly report done status
  • Cursor encodes both chunk ID and running index ({ c: chunkId, i: baseIndex })

One minor cleanup: void fn() on line 407 (changing cleanups.forEach((fn) => fn()) to cleanups.forEach((fn) => void fn())) is a good lint fix to suppress floating promise warnings.

5. WorkflowReadableStream type and getTailIndex() (packages/core/src/runtime/run.ts) - Clean

Uses Object.assign to add getTailIndex() to the ReadableStream. This is a clean pattern that avoids subclassing. The type is properly exported through packages/workflow/src/api.ts.

6. WorkflowChatTransport changes (packages/ai/src/workflow-chat-transport.ts) - Well thought out

The negative initialStartIndex feature is carefully designed:

  • First request uses the explicit (potentially negative) startIndex
  • On retry, resolves to an absolute position using x-workflow-stream-tail-index header
  • Falls back to startIndex=0 with a warning if the header is missing
  • useExplicitStartIndex flag ensures the negative value is only used once, then subsequent retries use the running chunkIndex

The replayFromStart flag handles the edge case where the header is absent gracefully.

7. Tests - Good coverage

  • Two new e2e tests for getTailIndex and getStreamChunks pagination (skipped on local deployments since they require server-side endpoints)
  • Two new unit tests for WorkflowChatTransport covering negative initialStartIndex with and without the tail-index header

8. Docs - Thorough

Updated resumable-streams.mdx with:

  • New "Resuming from the end of the stream" section
  • Updated stream reconnection endpoint to include getTailIndex() and the x-workflow-stream-tail-index header
  • Callout about header requirement for negative initialStartIndex
  • API reference docs updated

9. Changeset - Present and correct

Includes @workflow/world, world-local, world-vercel, world-postgres, core, and ai as patch changes.

Minor Notes

  • The PR description is empty ("No description provided"). Worth adding a summary for posterity.
  • The getStreamChunks method uses _runId (unused) in local/postgres implementations since the stream name is already unique there. The vercel implementation passes it to the server endpoint. This is fine - the interface is designed for the vercel world's needs.

Verdict

No blockers or regressions. This is a well-implemented, additive feature with good test coverage and documentation. The only CI failures are pre-existing (nest deployment, community world Turso/MongoDB/Redis, nitro benchmark timeout). Ready to merge once it gets a review approval.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall this is a well-designed, additive feature. The negative initialStartIndex logic in the transport is carefully thought through, the interface additions are clean, and the docs/tests cover the new behavior well. A few issues worth addressing below.

GetChunksOptions,
StreamChunk,
StreamChunksResponse,
} from './shared.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The explicit export type { GetChunksOptions, StreamChunk, StreamChunksResponse } is redundant with the export type * from './shared.js' on line 34 — and it's also missing StreamInfoResponse. Since line 34 already re-exports all types from shared.js, you can just remove this block entirely (lines 39-43).

.select({ chunkId: streams.chunkId })
.from(streams)
.where(and(eq(streams.streamId, name), eq(streams.eof, false)))
.orderBy(asc(streams.chunkId));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

getStreamInfo fetches all data rows into memory just to count them. For streams with thousands of chunks this is wasteful — a SELECT COUNT(*) FROM streams WHERE streamId = $1 AND eof = false would be much cheaper. Consider using Drizzle's count() aggregate or a raw SQL count instead of selecting all chunk IDs.

hasMore,
done: streamDone,
};
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The file-listing + extension-mapping logic (~30 lines) is duplicated verbatim between getStreamChunks and getStreamInfo. Consider extracting a helper like listStreamDataFiles(name) that returns { dataFiles, fileExtMap, streamDone } to keep this DRY.

Comment threadpackages/world-local/src/streamer.ts Outdated
break;
}
dataFiles.push(file);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

getStreamChunks reads and deserializes every chunk file from disk on every call (to separate data files from the EOF marker), regardless of the cursor position. For a stream with 10k chunks, page 2 still reads all 10k files.

Two possible improvements:

  1. Only read files starting from the cursor position (the ULID sort gives chronological order, so you can skip earlier files)
  2. At minimum, stop reading once you have enough files past the cursor — you only need startIndex + limit data files plus potentially the EOF marker

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Deeper review: testing, docs, observability, edge cases, and performance

Testing gaps

world-local and world-postgres have zero unit tests for the new getStreamChunks and getStreamInfo methods. The only coverage comes from two e2e tests that are skipIf(isLocalDeployment()), so local dev never exercises these paths. Cursor pagination logic (invalid cursors, out-of-range cursors, single-item pages), empty stream behavior (tailIndex = -1), and mid-flight streams (calling before workflow completes) are all untested.

The transport unit tests cover the happy path for negative initialStartIndex but miss: NaN/malformed x-workflow-stream-tail-index header values (the !Number.isNaN guard is untested), positive explicit startIndex override, boundary clamping when magnitude exceeds stream length, and multi-retry chains where the header varies across attempts.

Documentation gaps

  1. building-a-world.mdx still shows the old Streamer interface without getStreamChunks or getStreamInfo. Custom world implementers won't know these methods are required.
  2. No API reference for WorkflowReadableStream or getTailIndex() — the get-run.mdx page documents WorkflowReadableStreamOptions but not the new return type.
  3. Changeset mentions getStreamInfo but omits getStreamChunks.

Observability

Existing storage methods are wrapped with instrumentObject() for automatic OTEL spans (e.g. world.runs.get, world.events.create). Streamer methods — both existing and new — are not instrumented at all. No spans, no semantic conventions for stream names/indices, and no structured logging in any world implementation's streaming code. The only observability signal is the console.warn in WorkflowChatTransport for missing headers. This is consistent with the pre-existing pattern (streaming was never instrumented), but it means production debugging of pagination issues, tail-index mismatches, or reconnection failures will be blind.

Edge cases and performance (see inline comments below)

Several specific issues are called out inline.

break;
}
dataRows.push(row);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Edge case: EOF row appearing mid-batch. The query fetches limit + 1 rows ordered by chunkId without filtering out EOF rows. If the EOF row sorts between data rows (e.g. due to concurrent writes or clock skew), the loop breaks early on row.eof, and subsequent data rows in the result set are silently dropped.

Then hasMore = !streamDone && dataRows.length > limit evaluates to false (because streamDone = true), so the client sees done: true, hasMore: false even though there were more data rows in the fetch.

This is probably fine in practice since the EOF ULID is generated after all data chunks, but it's worth noting the implicit ordering assumption. Adding eq(streams.eof, false) to the main query and doing a separate EOF check (like the lookahead on line 250) would make this robust.

.select({ chunkId: streams.chunkId })
.from(streams)
.where(and(eq(streams.streamId, name), eq(streams.eof, false)))
.orderBy(asc(streams.chunkId));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Performance: O(N) memory to count rows. This SELECT chunkId FROM streams WHERE ... loads every non-EOF row into Node.js memory just to take .length. For a stream with 100k chunks, that's 100k objects allocated and immediately discarded.

Use a COUNT(*) aggregate instead:

const[{ count }]=awaitdrizzle.select({count: sql<number>`count(*)`}).from(streams).where(and(eq(streams.streamId,name),eq(streams.eof,false)));return{tailIndex: (count??0)-1,done: !!eofRow};

Comment threadpackages/world-local/src/streamer.ts Outdated
break;
}
dataFiles.push(file);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Performance: every page reads ALL chunk files.getStreamChunks deserializes every chunk file from disk (lines 286-296) to separate data files from the EOF marker, even for chunks that precede the cursor. Page 50 of a 10k-chunk stream still reads all 10k files.

getStreamInfo (line 372) has the exact same pattern — reads all files just to count them.

For local dev this is tolerable, but if these methods get called frequently (e.g. polling for tail index), the cost adds up. A lighter approach: since ULID sort gives chronological order, you could skip files before the cursor index and stop after collecting limit data files + EOF check.

const readable = run.getReadable();
const tailIndex = await readable.getTailIndex();

// outputStreamWorkflow writes 2 chunks to the default stream

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Testing gap: no empty-stream or mid-flight test. These tests only run after await run.returnValue (stream fully complete) and outputStreamWorkflow always writes exactly 2 chunks.

Missing scenarios:

  • Empty stream: What does getTailIndex() return when 0 chunks have been written? (should be -1 per the type docs)
  • Mid-flight stream: What does getTailIndex() / getStreamChunks() return while the workflow is still running and the stream isn't closed yet?
  • getStreamInfo directly: Only tested indirectly via getTailIndex() — no direct assertion on the done flag


warnSpy.mockRestore();
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Testing gap: malformed header values. The implementation has a !Number.isNaN(tailIndex) guard (transport line 362) but no test covers it. Consider adding a case where the header is "not-a-number"parseInt returns NaN, the guard should skip resolution, and the code should fall through without setting chunkIndex.

Also missing: a test for initialStartIndex: -1000 on a 5-chunk stream (should clamp to 0 via Math.max), and a test where a positive startIndex is passed via reconnectToStream options to override initialStartIndex.

const tailIndexHeader = res.headers.get('x-workflow-stream-tail-index');
if (tailIndexHeader !== null) {
const tailIndex = parseInt(tailIndexHeader, 10);
if (!Number.isNaN(tailIndex)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Edge case: NaN guard doesn't fall through cleanly. When parseInt returns NaN, the if (!Number.isNaN(tailIndex)) block is skipped, but replayFromStart is NOT set (that only happens in the else branch for tailIndexHeader === null). So on the next iteration, useExplicitStartIndex is false and replayFromStart is false, meaning the code falls through to chunkIndex — which is still 0 from initialization.

This accidentally works (retries from 0), but the intent is unclear. If the header is present but malformed, should it warn and set replayFromStart = true like the missing-header case? As-is, a malformed header silently degrades to replaying from 0 without the console warning.

GetChunksOptions,
StreamChunk,
StreamChunksResponse,
} from './shared.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Redundant + incomplete export. Line 34 already has export type * from './shared.js' which re-exports all types. This explicit block is redundant and also missing StreamInfoResponse. Just delete lines 39-43.

VaguelySeriousand others added 2 commits March 23, 2026 17:22
- world-postgres: use COUNT(*) in getStreamInfo instead of fetching all
rows; filter EOF rows in getStreamChunks query to prevent mid-batch
data loss from clock-skewed EOF rows
- world-local: skip files before cursor in getStreamChunks, read only
EOF byte in getStreamInfo and pre-cursor files
- WorkflowChatTransport: treat unparseable tail-index header same as
missing (set replayFromStart); add unit test for NaN header fallback
- e2e: add test for getTailIndex returning -1 on nonexistent stream
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- building-a-world.mdx: add getStreamChunks and getStreamInfo to
Streamer interface definition
- get-run.mdx: document WorkflowReadableStream and getTailIndex()
- Changeset: mention getStreamChunks alongside getStreamInfo
- world-vercel, world-local: wrap streamer with instrumentObject for
OTEL spans (world.streams.* prefix), matching storage instrumentation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fresh review after update

This update addresses all the critical issues from the previous round. Nice work cleaning this up.

Previously flagged — now resolved

  • Postgres getStreamInfo: Now uses COUNT(*) aggregate instead of fetching all rows into memory
  • Postgres getStreamChunks EOF mid-batch: Main query now filters eq(streams.eof, false), with a separate EOF check — no more risk of EOF silently dropping data rows
  • Transport NaN guard: Missing and malformed headers now both flow through the same else branch — replayFromStart = true + console.warn in both cases
  • world-local getStreamChunks perf: No longer deserializes every chunk — pre-cursor files use isEofChunk() (byte check only), and the loop stops early after collecting enough data
  • world-local getStreamInfo: Also uses isEofChunk() instead of full deserialization
  • Changeset: Now mentions both getStreamChunks() and getStreamInfo()
  • Docs: building-a-world.mdx includes the new Streamer methods with explanatory text. get-run.mdx has a WorkflowReadableStream section with getTailIndex() reference.
  • E2e tests: Added tailIndex returns -1 test using a nonexistent namespace. Added NaN header unit test.
  • OTEL: Both world-vercel and world-local now wrap the streamer with instrumentObject('world.streams', ...)
  • Redundant export: StreamInfoResponse added to the explicit export list in world/src/index.ts

Minor remaining items (non-blocking)

  1. world-local file-listing boilerplate still duplicated — the ~20-line block that lists files by extension, builds the fileExtMap, and filters by stream name is copy-pasted between getStreamChunks (lines 266-286) and getStreamInfo (lines 367-389). Extracting a listChunkFilesForStream(name) helper would reduce maintenance surface.

  2. world-postgres streamer not instrumentedworld-vercel and world-local both wrap their streamer with instrumentObject('world.streams', ...), giving the new methods automatic OTEL spans. world-postgres spreads the streamer directly without instrumentation (packages/world-postgres/src/index.ts:49). Pre-existing inconsistency, but worth noting since this PR adds new methods that would benefit from tracing.

  3. No unit tests for world-local or world-postgres implementations of getStreamChunks/getStreamInfo. The cursor pagination logic (invalid cursors, boundary conditions, EOF detection) is only tested via e2e tests that are skipped in local dev. Consider adding unit tests in a follow-up.

dataCount++;
}

return { tailIndex: dataCount - 1, done: streamDone };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: The file-listing + extension-mapping block (~20 lines) is duplicated between getStreamChunks (lines 266-286) and getStreamInfo (lines 367-389). A shared listChunkFilesForStream(name) helper returning { chunkFiles, fileExtMap } would keep these two methods DRY.

@ghostghost mentioned this pull request Mar 23, 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.

3 participants

@VaguelySerious@pranaygp@karthikscale3
, '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('^' + ".*" + ' [world] Add stream pagination and metadata endpoints by VaguelySerious · Pull Request #1470 · vercel/workflow · GitHub
Skip to content

[world] Add stream pagination and metadata endpoints - #1470

Merged
VaguelySerious merged 32 commits into
mainfrom
peter/stream-metadata-and-pages
Mar 24, 2026
Merged

[world] Add stream pagination and metadata endpoints#1470
VaguelySerious merged 32 commits into
mainfrom
peter/stream-metadata-and-pages

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

No description provided.

VaguelySeriousand others added 13 commits March 19, 2026 17:44
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Negative startIndex values (e.g. -3) resolve to n chunks before the
known end of the stream. All world implementations (local, postgres,
vercel) support this. Includes unit tests, e2e test, and doc updates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Replaces three separate tests (no startIndex, positive, negative) with a
single describe block that iterates over startIndex cases. Fixes the
negative startIndex test by waiting for workflow completion before
connecting the reader — the backend resolves negative indices at
connection time using knownChunkCount, which is 0 if the stream hasn't
been fully written yet.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Replaces three separate tests (no startIndex, positive, negative) with a
single describe block that iterates over startIndex cases. Fixes the
negative startIndex test by waiting for workflow completion before
connecting the reader — the backend resolves negative indices at
connection time using knownChunkCount, which is 0 if the stream hasn't
been fully written yet.
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
- Clarify that negative startIndex is for custom consumers, not WorkflowChatTransport
- Add live-stream caveat for negative startIndex resolution timing
- Add pagination limitation callout for live streams
- Use explicit typeof guards for negative startIndex checks (world-local, world-postgres)
- Add cost comment for EOF marker disk read in world-local
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel

vercelBot commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Mar 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a82fbe7

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

This PR includes changesets to release 20 packages
NameType
@workflow/worldPatch
@workflow/world-localPatch
@workflow/world-vercelPatch
@workflow/world-postgresPatch
@workflow/corePatch
@workflow/aiPatch
@workflow/cliPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
workflowPatch
@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 Mar 20, 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🥇 Express0.043s (-5.4% 🟢)1.006s (~)0.962s101.00x
💻 LocalNitro0.044s (+14.4% 🔺)1.005s (~)0.962s101.00x
💻 LocalNext.js (Turbopack)0.049s1.005s0.956s101.13x
🌐 RedisNext.js (Turbopack)0.055s1.005s0.950s101.26x
🐘 PostgresExpress0.062s (+10.8% 🔺)1.011s (~)0.949s101.42x
🐘 PostgresNitro0.064s (-7.9% 🟢)1.011s (~)0.947s101.47x
🐘 PostgresNext.js (Turbopack)0.065s1.012s0.947s101.50x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro0.458s (-21.6% 🟢)2.677s (+25.7% 🔺)2.219s101.00x
▲ VercelNext.js (Turbopack)0.542s (-17.9% 🟢)2.725s (+14.0% 🔺)2.183s101.18x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.123s2.006s0.883s101.00x
🌐 RedisNext.js (Turbopack)1.125s2.006s0.882s101.00x
💻 LocalExpress1.128s (~)2.006s (~)0.878s101.00x
💻 LocalNitro1.131s (+3.5%)2.006s (~)0.875s101.01x
🐘 PostgresNext.js (Turbopack)1.142s2.013s0.871s101.02x
🐘 PostgresNitro1.149s (-1.2%)2.012s (~)0.863s101.02x
🐘 PostgresExpress1.162s (+0.7%)2.012s (~)0.851s101.03x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.139s (+2.5%)4.147s (+23.1% 🔺)2.008s101.00x
▲ VercelNext.js (Turbopack)2.152s (+4.3%)4.007s (+9.3% 🔺)1.855s101.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)10.776s11.022s0.246s31.00x
💻 LocalNext.js (Turbopack)10.792s11.024s0.232s31.00x
🐘 PostgresExpress10.871s (-0.6%)11.020s (~)0.148s31.01x
🐘 PostgresNext.js (Turbopack)10.875s11.024s0.149s31.01x
💻 LocalExpress10.906s (-0.5%)11.023s (~)0.118s31.01x
💻 LocalNitro10.923s (+2.9%)11.023s (~)0.100s31.01x
🐘 PostgresNitro10.938s (-1.8%)11.026s (-8.5% 🟢)0.088s31.02x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)17.601s (+2.3%)19.403s (+1.9%)1.803s21.00x
▲ VercelNitro17.930s (~)20.036s (+2.1%)2.106s21.02x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)14.213s15.028s0.815s41.00x
🐘 PostgresNext.js (Turbopack)14.424s15.026s0.602s41.01x
💻 LocalNext.js (Turbopack)14.540s15.029s0.489s41.02x
🐘 PostgresExpress14.580s (-1.6%)15.024s (~)0.444s41.03x
🐘 PostgresNitro14.643s (-1.7%)15.033s (~)0.390s41.03x
💻 LocalExpress14.944s (-0.6%)15.029s (-4.8%)0.084s41.05x
💻 LocalNitro14.984s (+5.4% 🔺)15.033s (~)0.049s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro34.179s (+4.6%)35.468s (+4.2%)1.289s21.00x
▲ VercelNext.js (Turbopack)35.203s (+4.7%)37.010s (+6.3% 🔺)1.807s21.03x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)13.441s14.026s0.585s71.00x
🐘 PostgresNext.js (Turbopack)13.969s14.167s0.198s71.04x
🐘 PostgresExpress13.969s (-3.2%)14.452s (-3.9%)0.482s71.04x
🐘 PostgresNitro14.112s (-5.0% 🟢)14.741s (-3.1%)0.629s71.05x
💻 LocalNext.js (Turbopack)16.013s16.362s0.348s61.19x
💻 LocalExpress16.580s (-2.7%)17.030s (-1.9%)0.450s61.23x
💻 LocalNitro16.653s (+11.9% 🔺)17.031s (+13.3% 🔺)0.378s61.24x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro58.422s (-5.3% 🟢)61.010s (-3.0%)2.587s21.00x
▲ VercelNext.js (Turbopack)63.237s (+3.3%)64.613s (+3.3%)1.376s21.08x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)1.235s2.010s0.775s151.00x
🐘 PostgresNitro1.257s (-3.4%)2.012s (~)0.756s151.02x
🐘 PostgresExpress1.261s (-1.7%)2.012s (~)0.751s151.02x
🌐 RedisNext.js (Turbopack)1.321s2.007s0.685s151.07x
💻 LocalExpress1.527s (~)2.005s (~)0.478s151.24x
💻 LocalNitro1.527s (+4.8%)2.005s (~)0.478s151.24x
💻 LocalNext.js (Turbopack)1.572s2.006s0.434s151.27x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.597s (+2.8%)4.602s (+19.8% 🔺)2.005s71.00x
▲ VercelNext.js (Turbopack)2.630s (+1.5%)4.054s (-1.1%)1.423s81.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express2.354s (-3.6%)3.010s (~)0.657s101.00x
🐘 PostgresNitro2.354s (-5.6% 🟢)3.010s (~)0.656s101.00x
🐘 PostgresNext.js (Turbopack)2.412s3.010s0.599s101.02x
🌐 RedisNext.js (Turbopack)2.599s3.008s0.409s101.10x
💻 LocalNext.js (Turbopack)2.975s3.565s0.590s91.26x
💻 LocalNitro3.009s (+19.1% 🔺)3.760s (+25.0% 🔺)0.751s81.28x
💻 LocalExpress3.030s (-2.3%)3.760s (-6.2% 🟢)0.730s81.29x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.782s (+6.8% 🔺)5.013s (+36.6% 🔺)2.230s61.00x
▲ VercelNext.js (Turbopack)3.142s (+7.0% 🔺)4.966s (+15.8% 🔺)1.824s71.13x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro3.464s (-5.5% 🟢)4.015s (~)0.551s81.00x
🐘 PostgresExpress3.475s (-2.7%)4.014s (~)0.539s81.00x
🐘 PostgresNext.js (Turbopack)3.663s4.010s0.347s81.06x
🌐 RedisNext.js (Turbopack)4.261s5.011s0.750s61.23x
💻 LocalNext.js (Turbopack)7.418s8.016s0.597s42.14x
💻 LocalExpress8.564s (-1.7%)9.021s (-2.7%)0.457s42.47x
💻 LocalNitro9.200s (+37.7% 🔺)9.772s (+39.3% 🔺)0.571s42.66x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.261s (-3.7%)5.062s (+5.6% 🔺)1.801s71.00x
▲ VercelNext.js (Turbopack)3.526s (-15.3% 🟢)6.284s (+13.1% 🔺)2.758s51.08x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)1.219s2.009s0.790s151.00x
🐘 PostgresNitro1.252s (-3.0%)2.009s (~)0.757s151.03x
🐘 PostgresExpress1.255s (-2.8%)2.009s (~)0.754s151.03x
🌐 RedisNext.js (Turbopack)1.305s2.006s0.701s151.07x
💻 LocalNext.js (Turbopack)1.508s2.005s0.497s151.24x
💻 LocalNitro1.573s (+7.4% 🔺)2.007s (~)0.434s151.29x
💻 LocalExpress1.598s (+3.5%)2.072s (+3.3%)0.474s151.31x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.243s (-13.3% 🟢)3.995s (+5.7% 🔺)1.752s81.00x
▲ VercelNitro2.260s (-2.0%)4.048s (+7.7% 🔺)1.787s81.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.334s (-6.4% 🟢)3.011s (~)0.677s101.00x
🐘 PostgresExpress2.339s (-4.7%)3.010s (~)0.672s101.00x
🐘 PostgresNext.js (Turbopack)2.402s3.010s0.609s101.03x
🌐 RedisNext.js (Turbopack)2.544s3.008s0.464s101.09x
💻 LocalNext.js (Turbopack)2.868s3.453s0.585s91.23x
💻 LocalExpress2.992s (-5.7% 🟢)3.762s (-6.2% 🟢)0.770s81.28x
💻 LocalNitro3.086s (+7.6% 🔺)3.760s (+21.0% 🔺)0.674s81.32x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.905s (+1.0%)5.039s (+17.1% 🔺)2.134s71.00x
▲ VercelNext.js (Turbopack)3.300s (+10.1% 🔺)4.771s (+11.2% 🔺)1.471s71.14x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro3.469s (-4.9%)4.013s (~)0.544s81.00x
🐘 PostgresExpress3.473s (-3.4%)4.014s (~)0.541s81.00x
🐘 PostgresNext.js (Turbopack)3.670s4.012s0.342s81.06x
🌐 RedisNext.js (Turbopack)4.275s4.868s0.593s71.23x
💻 LocalExpress8.786s (-4.0%)9.022s (-10.0% 🟢)0.237s42.53x
💻 LocalNext.js (Turbopack)8.883s9.772s0.889s42.56x
💻 LocalNitro9.326s (+26.1% 🔺)10.024s (+25.0% 🔺)0.698s32.69x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.294s (+6.2% 🔺)5.002s (+6.1% 🔺)1.707s61.00x
▲ VercelNext.js (Turbopack)4.267s (+19.0% 🔺)6.135s (+24.6% 🔺)1.869s51.30x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)0.699s1.005s0.305s601.00x
🐘 PostgresNext.js (Turbopack)0.769s1.007s0.238s601.10x
🐘 PostgresExpress0.801s (-12.5% 🟢)1.007s (-6.7% 🟢)0.206s601.15x
🐘 PostgresNitro0.829s (-14.4% 🟢)1.007s (-18.5% 🟢)0.178s601.19x
💻 LocalNext.js (Turbopack)0.841s1.021s0.180s591.20x
💻 LocalExpress0.977s (-3.4%)1.076s (-30.3% 🟢)0.098s561.40x
💻 LocalNitro1.015s (+50.6% 🔺)1.628s (+62.1% 🔺)0.613s371.45x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro10.274s (+2.1%)12.099s (+5.9% 🔺)1.825s51.00x
▲ VercelNext.js (Turbopack)10.675s (+7.4% 🔺)12.483s (+1.0%)1.808s51.04x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.674s2.006s0.332s451.00x
🐘 PostgresNext.js (Turbopack)1.861s2.030s0.169s451.11x
🐘 PostgresExpress1.920s (-13.7% 🟢)2.077s (-31.0% 🟢)0.157s441.15x
🐘 PostgresNitro1.957s (-15.4% 🟢)2.176s (-27.8% 🟢)0.219s421.17x
💻 LocalNext.js (Turbopack)2.613s3.007s0.395s301.56x
💻 LocalExpress2.984s (-1.6%)3.341s (-9.4% 🟢)0.357s271.78x
💻 LocalNitro3.007s (+37.1% 🔺)3.547s (+18.0% 🔺)0.541s261.80x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro32.892s (+9.6% 🔺)34.652s (+8.7% 🔺)1.761s31.00x
▲ VercelNext.js (Turbopack)34.160s (+5.3% 🔺)51.070s (+51.8% 🔺)16.910s21.04x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)3.329s4.009s0.680s301.00x
🐘 PostgresNext.js (Turbopack)3.777s4.011s0.234s301.13x
🐘 PostgresExpress3.894s (-13.3% 🟢)4.184s (-16.6% 🟢)0.289s291.17x
🐘 PostgresNitro3.982s (-16.9% 🟢)4.404s (-13.6% 🟢)0.422s281.20x
💻 LocalNext.js (Turbopack)8.432s9.017s0.585s142.53x
💻 LocalExpress9.097s (+3.3%)9.555s (+4.3%)0.459s132.73x
💻 LocalNitro9.155s (+26.4% 🔺)9.864s (+23.1% 🔺)0.710s132.75x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro87.838s (+1.9%)104.355s (+19.6% 🔺)16.517s21.00x
▲ VercelNext.js (Turbopack)88.131s (+2.3%)90.390s (+2.2%)2.259s21.00x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)0.258s1.008s0.750s601.00x
🐘 PostgresNitro0.270s (-14.9% 🟢)1.008s (~)0.738s601.05x
🐘 PostgresExpress0.270s (-8.7% 🟢)1.008s (~)0.738s601.05x
🌐 RedisNext.js (Turbopack)0.365s1.004s0.640s601.42x
💻 LocalNext.js (Turbopack)0.551s1.004s0.453s602.14x
💻 LocalExpress0.595s (+4.7%)1.004s (~)0.410s602.31x
💻 LocalNitro0.598s (+6.7% 🔺)1.022s (+1.7%)0.423s592.32x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro1.834s (+9.2% 🔺)3.825s (+15.9% 🔺)1.992s161.00x
▲ VercelNext.js (Turbopack)2.568s (+35.7% 🔺)9.530s (+154.0% 🔺)6.962s121.40x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)0.481s1.019s0.538s891.00x
🐘 PostgresExpress0.493s (-10.0% 🟢)1.008s (~)0.515s901.03x
🐘 PostgresNitro0.498s (-14.7% 🟢)1.008s (~)0.511s901.04x
🌐 RedisNext.js (Turbopack)1.207s2.006s0.799s452.51x
💻 LocalNitro2.505s (+6.9% 🔺)3.009s (~)0.503s305.21x
💻 LocalNext.js (Turbopack)2.539s3.009s0.470s305.28x
💻 LocalExpress2.567s (+4.7%)3.009s (~)0.442s305.34x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.344s (+11.3% 🔺)5.095s (+14.5% 🔺)1.751s181.00x
▲ VercelNext.js (Turbopack)4.913s (+51.5% 🔺)6.649s (+40.7% 🔺)1.736s141.47x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)0.760s1.008s0.247s1201.00x
🐘 PostgresNitro0.767s (-20.7% 🟢)1.008s (-27.9% 🟢)0.241s1201.01x
🐘 PostgresExpress0.774s (-15.8% 🟢)1.008s (-18.7% 🟢)0.234s1201.02x
🌐 RedisNext.js (Turbopack)2.757s3.084s0.327s393.63x
💻 LocalNext.js (Turbopack)10.553s11.027s0.473s1113.88x
💻 LocalExpress11.228s (+1.9%)11.937s (+2.3%)0.708s1114.76x
💻 LocalNitro11.257s (+13.4% 🔺)12.029s (+16.1% 🔺)0.771s1014.80x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)7.133s (-77.8% 🟢)8.902s (-73.6% 🟢)1.769s141.00x
▲ VercelNitro7.220s (~)8.773s (+1.1%)1.554s141.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.175s1.001s0.011s1.017s0.842s101.00x
🌐 RedisNext.js (Turbopack)0.183s1.000s0.001s1.008s0.825s101.05x
🐘 PostgresExpress0.195s (-12.5% 🟢)0.996s (~)0.002s (+15.4% 🔺)1.011s (~)0.816s101.11x
🐘 PostgresNext.js (Turbopack)0.199s1.000s0.004s1.014s0.814s101.14x
🐘 PostgresNitro0.204s (-15.0% 🟢)0.995s (~)0.001s (-27.8% 🟢)1.011s (~)0.807s101.17x
💻 LocalNitro0.208s (+49.5% 🔺)1.003s (~)0.012s (+19.6% 🔺)1.018s (~)0.810s101.19x
💻 LocalExpress0.211s (+2.1%)1.003s (~)0.011s (-5.9% 🟢)1.017s (~)0.806s101.20x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro1.779s (+15.1% 🔺)2.497s (-4.5%)0.810s (+76.9% 🔺)3.996s (+11.2% 🔺)2.217s101.00x
▲ VercelNext.js (Turbopack)1.926s (+17.8% 🔺)2.945s (+0.9%)0.662s (+65.8% 🔺)4.322s (+11.6% 🔺)2.396s101.08x
▲ VercelExpress⚠️missing-----

🔍 Observability: Nitro | Next.js (Turbopack)

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)0.487s1.000s0.003s1.011s0.524s601.00x
🐘 PostgresExpress0.593s (-15.7% 🟢)1.003s (~)0.013s (+103.1% 🔺)1.033s (~)0.440s591.22x
🐘 PostgresNitro0.602s (-17.0% 🟢)1.004s (~)0.004s (-37.7% 🟢)1.023s (-0.7%)0.421s591.24x
🐘 PostgresNext.js (Turbopack)0.611s1.007s0.004s1.025s0.414s591.25x
💻 LocalNext.js (Turbopack)0.666s1.008s0.009s1.023s0.357s591.37x
💻 LocalNitro0.724s (+27.6% 🔺)1.009s (~)0.010s (+4.6%)1.024s (~)0.299s591.49x
💻 LocalExpress0.734s (+2.3%)1.009s (~)0.009s (-5.3% 🟢)1.022s (~)0.288s591.51x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)5.132s (+12.4% 🔺)6.256s (+11.9% 🔺)0.242s (+7.6% 🔺)13.240s (+104.9% 🔺)8.108s51.00x
▲ VercelNitro5.363s (~)6.372s (+3.7%)0.403s (-35.9% 🟢)7.632s (+1.2%)2.269s81.05x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)0.897s1.034s0.000s1.039s0.141s581.00x
🐘 PostgresNext.js (Turbopack)0.948s1.132s0.000s1.141s0.193s531.06x
🐘 PostgresExpress0.956s (-14.8% 🟢)1.271s (-28.6% 🟢)0.000s (-63.8% 🟢)1.286s (-28.9% 🟢)0.331s471.06x
🐘 PostgresNitro0.965s (-17.5% 🟢)1.298s (-35.0% 🟢)0.000s (-2.2%)1.315s (-35.0% 🟢)0.350s461.08x
💻 LocalNitro1.224s (+8.0% 🔺)2.020s (~)0.000s (+50.0% 🔺)2.023s (~)0.799s301.36x
💻 LocalExpress1.246s (+3.6%)2.020s (~)0.000s (-42.9% 🟢)2.023s (~)0.777s301.39x
💻 LocalNext.js (Turbopack)1.270s2.020s0.000s2.024s0.754s301.42x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.626s (+3.0%)4.889s (+5.1% 🔺)0.004s (+Infinity% 🔺)5.632s (+8.2% 🔺)2.006s111.00x
▲ VercelNitro3.756s (+24.1% 🔺)4.924s (+25.5% 🔺)0.002s (+1045.5% 🔺)5.654s (+27.8% 🔺)1.898s111.04x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

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

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.603s2.002s0.000s2.007s0.403s301.00x
🐘 PostgresNext.js (Turbopack)1.831s2.106s0.000s2.115s0.284s291.14x
🐘 PostgresExpress1.840s (-10.6% 🟢)2.258s (-8.8% 🟢)0.000s (NaN%)2.271s (-9.1% 🟢)0.431s271.15x
🐘 PostgresNitro1.853s (-18.2% 🟢)2.223s (-24.7% 🟢)0.000s (-100.0% 🟢)2.235s (-24.7% 🟢)0.382s271.16x
💻 LocalNitro3.585s (+3.6%)4.300s (+6.7% 🔺)0.000s (-61.5% 🟢)4.304s (+6.7% 🔺)0.720s152.24x
💻 LocalNext.js (Turbopack)3.632s4.230s0.001s4.235s0.604s152.27x
💻 LocalExpress3.670s (+8.3% 🔺)4.032s (-1.6%)0.000s (+250.0% 🔺)4.036s (-1.6%)0.366s152.29x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro4.200s (+21.3% 🔺)5.030s (+20.0% 🔺)0.000s (+Infinity% 🔺)5.900s (+24.3% 🔺)1.700s111.00x
▲ VercelNext.js (Turbopack)5.104s (+14.0% 🔺)6.017s (+11.1% 🔺)0.000s (+10.0% 🔺)6.656s (+11.9% 🔺)1.552s101.22x
▲ VercelExpress⚠️missing-----

🔍 Observability: Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)15/21
🐘 PostgresNext.js (Turbopack)13/21
▲ VercelNitro16/21
Fastest World by Framework

Winner determined by most benchmark wins

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

📋 View full workflow run


Some benchmark jobs failed:

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

Check the workflow run for details.

@github-actions

github-actionsBot commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production813067880
✅ 💻 Local Development7820178960
✅ 📦 Local Production7820178960
✅ 🐘 Local Postgres7820178960
✅ 🪟 Windows720880
❌ 🌍 Community Worlds1185630204
✅ 📋 Other198042240
Total3547566814284

❌ Failed Tests

🌍 Community Worlds (56 failed)

mongodb (3 failed):

  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KMEKZQ811N46GD5AK5RW1HD9
  • webhookWorkflow | wrun_01KMEKZZZNPHAFY93GWGMA33E4
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KMEM7DRXD1RYA1E11HJTJFPW

redis (2 failed):

  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KMEKZQ811N46GD5AK5RW1HD9
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KMEM7DRXD1RYA1E11HJTJFPW

turso (51 failed):

  • addTenWorkflow | wrun_01KMEKYFN28CV699JDRY8EVENQ
  • addTenWorkflow | wrun_01KMEKYFN28CV699JDRY8EVENQ
  • wellKnownAgentWorkflow (.well-known/agent) | wrun_01KMEKZV5Y7H35GS496CWE2PWY
  • should work with react rendering in step
  • promiseAllWorkflow | wrun_01KMEKYP9VT3R0SKBHBMF46WF1
  • promiseRaceWorkflow | wrun_01KMEKYV2NHKMK1PT16PHGC0TE
  • promiseAnyWorkflow | wrun_01KMEKYYRJ4XWER78HC3BC88RH
  • importedStepOnlyWorkflow | wrun_01KMEM07SDMHGNED3K1Z8822HA
  • hookWorkflow | wrun_01KMEKZB3KDGNSRDK1ZP1ZWCS2
  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KMEKZQ811N46GD5AK5RW1HD9
  • webhookWorkflow | wrun_01KMEKZZZNPHAFY93GWGMA33E4
  • sleepingWorkflow | wrun_01KMEM07AP1ZHHP0B1Y28XPTPF
  • parallelSleepWorkflow | wrun_01KMEM0M62BA11GZH9N30PA01V
  • nullByteWorkflow | wrun_01KMEM0RNC851K91RM7QKG9J0A
  • workflowAndStepMetadataWorkflow | wrun_01KMEM0TS1NDEE94P4ZK643V6C
  • fetchWorkflow | wrun_01KMEM3MRRD40CZ6RZHVTBN0QE
  • promiseRaceStressTestWorkflow | wrun_01KMEM3RJJYY05KEDAWHC7YZCF
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion | wrun_01KMEM6SVSD36537C7RMSNZ7K2
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KMEM7DRXD1RYA1E11HJTJFPW
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running | wrun_01KMEM84D71052EEDK1NTW09QR
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars) | wrun_01KMEM8QRVX2ZN0ZY8KSFXYRFW
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument | wrun_01KMEM90F9YAZG8G7CZZR9KQWQ
  • closureVariableWorkflow - nested step functions with closure variables | wrun_01KMEM96EMKN1H5C673QHQM0RJ
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step | wrun_01KMEM9A0HFZKBNE055EZ9P5H4
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly | wrun_01KMEM9RJS5TNK0QPDKP86M45K
  • Calculator.calculate - static workflow method using static step methods from another class | wrun_01KMEM9XZANZTCTYJ5TEE55MRZ
  • AllInOneService.processNumber - static workflow method using sibling static step methods | wrun_01KMEMA4B58C28JE56JM1YFVQV
  • ChainableService.processWithThis - static step methods using this to reference the class | wrun_01KMEMAATKT6G9NX72T548BXF1
  • thisSerializationWorkflow - step function invoked with .call() and .apply() | wrun_01KMEMAH46HQ91CQS2CS66CDC6
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE | wrun_01KMEMAQHGV2M8JHE762DZMKC6
  • instanceMethodStepWorkflow - instance methods with "use step" directive | wrun_01KMEMAYB4Y9GXM4F81Y6CNJ6B
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context | wrun_01KMEMB8XA6D9RX061RHB81PKZ
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument | wrun_01KMEMBGE2CX800GQHW1H2HAJF
  • cancelRun - cancelling a running workflow | wrun_01KMEMBQWVXW1JB5P0HV743Q4P
  • cancelRun via CLI - cancelling a running workflow | wrun_01KMEMC0XTPK7JCE38TSAYE9N8
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep | wrun_01KMEMCCDFRYCKTFTV8TJ03YRG
  • sleepInLoopWorkflow - sleep inside loop with steps actually delays each iteration | wrun_01KMEMD0561BCM0VA669B3A6CS
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control) | wrun_01KMEMDAE0C841QKT27361H8BN

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro7307
✅ example7307
✅ express7307
✅ fastify7307
✅ hono7307
✅ nextjs-turbopack7802
✅ nextjs-webpack7802
✅ nitro7307
✅ nuxt7307
✅ sveltekit7307
✅ vite7307
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable66014
✅ express-stable66014
✅ fastify-stable66014
✅ hono-stable66014
✅ nextjs-turbopack-canary55025
✅ nextjs-turbopack-stable7208
✅ nextjs-webpack-canary55025
✅ nextjs-webpack-stable7208
✅ nitro-stable66014
✅ nuxt-stable66014
✅ sveltekit-stable66014
✅ vite-stable66014
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable66014
✅ express-stable66014
✅ fastify-stable66014
✅ hono-stable66014
✅ nextjs-turbopack-canary55025
✅ nextjs-turbopack-stable7208
✅ nextjs-webpack-canary55025
✅ nextjs-webpack-stable7208
✅ nitro-stable66014
✅ nuxt-stable66014
✅ sveltekit-stable66014
✅ vite-stable66014
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable66014
✅ express-stable66014
✅ fastify-stable66014
✅ hono-stable66014
✅ nextjs-turbopack-canary55025
✅ nextjs-turbopack-stable7208
✅ nextjs-webpack-canary55025
✅ nextjs-webpack-stable7208
✅ nitro-stable66014
✅ nuxt-stable66014
✅ sveltekit-stable66014
✅ vite-stable66014
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack7208
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev302
❌ mongodb5238
✅ redis-dev302
❌ redis5328
✅ turso-dev302
❌ turso4518
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable66014
✅ e2e-local-postgres-nest-stable66014
✅ e2e-local-prod-nest-stable66014

📋 View full workflow run

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Comment threadpackages/core/e2e/e2e.test.ts Outdated

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Here's my review of PR #1470:

Summary

This is a feature PR by @VaguelySerious that adds two new Streamer interface methods (getStreamChunks and getStreamInfo) across all world implementations, extends Run.getReadable() with a getTailIndex() helper, and adds initialStartIndex support to WorkflowChatTransport for resuming streams from the end.

No Blockers

CI is in excellent shape. The code changes are well-structured, well-documented, and backward-compatible.

CI Status

Passing:

  • All Vercel Production E2E tests: 11/11 frameworks pass (802 passed, 0 failed)
  • All Local Dev E2E tests: 12/12 pass (782 passed)
  • All Local Prod E2E tests: 12/12 pass
  • All Local Postgres E2E tests: 12/12 pass
  • Windows E2E tests pass
  • Unit tests pass (ubuntu + windows)
  • Vitest plugin tests pass
  • All Vercel deployments succeed (except workbench-nestjs-workflow - discussed below)
  • Benchmarks pass for local/postgres; no performance regressions
  • Docs code samples and links pass

Failing (non-blocking / pre-existing):

  • workbench-nestjs-workflow Vercel deployment fails - this is likely a pre-existing issue unrelated to streaming changes (the nest workbench has no streaming code changes in this PR)
  • Benchmark Vercel (nitro-v3) fails at ~30min - likely infra timeout, not PR-related
  • Community World (Turso) - 51 failures, pre-existing community world issue (same pattern as other PRs)
  • Community World (MongoDB/Redis) - 3/2 failures on hook-related tests, pre-existing

Code Review

1. Streamer interface additions (packages/world/src/interfaces.ts) - Clean

Two new methods added to the Streamer interface:

  • getStreamChunks(name, runId, options?) - cursor-based pagination for stream chunks
  • getStreamInfo(name, runId) - lightweight metadata (tailIndex, done flag)

New types (StreamChunk, StreamChunksResponse, GetChunksOptions, StreamInfoResponse) are well-documented in packages/world/src/shared.ts. The interface is additive and non-breaking.

2. world-vercel implementation (packages/world-vercel/src/streamer.ts) - Clean

Uses makeRequest + Zod schemas to call two new server endpoints:

  • GET /v2/runs/:runId/streams/:name/chunks?limit=&cursor=
  • GET /v2/runs/:runId/streams/:name/info

The StreamChunksResponseSchema correctly expects z.instanceof(Uint8Array) since CBOR handles binary natively. Clean and minimal.

3. world-local implementation (packages/world-local/src/streamer.ts) - Works but verbose

The implementation reads chunk files from disk, builds a file extension map, sorts by ULID, and paginates. One observation:

  • getStreamInfo reads every chunk file to count data chunks and detect EOF. For large streams this could be expensive. The getStreamChunks implementation has the same pattern. Both could potentially be optimized with a count-based approach, but for local dev this is fine.
  • The cursor encoding (base64-encoded JSON { i: index }) is a reasonable pattern.

4. world-postgres implementation (packages/world-postgres/src/streamer.ts) - Solid

Uses Drizzle ORM with proper query patterns:

  • Fetches limit + 1 rows to detect hasMore efficiently
  • Correctly handles the EOF row being mixed into results
  • Has a follow-up EOF check query when !streamDone && !hasMore to correctly report done status
  • Cursor encodes both chunk ID and running index ({ c: chunkId, i: baseIndex })

One minor cleanup: void fn() on line 407 (changing cleanups.forEach((fn) => fn()) to cleanups.forEach((fn) => void fn())) is a good lint fix to suppress floating promise warnings.

5. WorkflowReadableStream type and getTailIndex() (packages/core/src/runtime/run.ts) - Clean

Uses Object.assign to add getTailIndex() to the ReadableStream. This is a clean pattern that avoids subclassing. The type is properly exported through packages/workflow/src/api.ts.

6. WorkflowChatTransport changes (packages/ai/src/workflow-chat-transport.ts) - Well thought out

The negative initialStartIndex feature is carefully designed:

  • First request uses the explicit (potentially negative) startIndex
  • On retry, resolves to an absolute position using x-workflow-stream-tail-index header
  • Falls back to startIndex=0 with a warning if the header is missing
  • useExplicitStartIndex flag ensures the negative value is only used once, then subsequent retries use the running chunkIndex

The replayFromStart flag handles the edge case where the header is absent gracefully.

7. Tests - Good coverage

  • Two new e2e tests for getTailIndex and getStreamChunks pagination (skipped on local deployments since they require server-side endpoints)
  • Two new unit tests for WorkflowChatTransport covering negative initialStartIndex with and without the tail-index header

8. Docs - Thorough

Updated resumable-streams.mdx with:

  • New "Resuming from the end of the stream" section
  • Updated stream reconnection endpoint to include getTailIndex() and the x-workflow-stream-tail-index header
  • Callout about header requirement for negative initialStartIndex
  • API reference docs updated

9. Changeset - Present and correct

Includes @workflow/world, world-local, world-vercel, world-postgres, core, and ai as patch changes.

Minor Notes

  • The PR description is empty ("No description provided"). Worth adding a summary for posterity.
  • The getStreamChunks method uses _runId (unused) in local/postgres implementations since the stream name is already unique there. The vercel implementation passes it to the server endpoint. This is fine - the interface is designed for the vercel world's needs.

Verdict

No blockers or regressions. This is a well-implemented, additive feature with good test coverage and documentation. The only CI failures are pre-existing (nest deployment, community world Turso/MongoDB/Redis, nitro benchmark timeout). Ready to merge once it gets a review approval.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall this is a well-designed, additive feature. The negative initialStartIndex logic in the transport is carefully thought through, the interface additions are clean, and the docs/tests cover the new behavior well. A few issues worth addressing below.

GetChunksOptions,
StreamChunk,
StreamChunksResponse,
} from './shared.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The explicit export type { GetChunksOptions, StreamChunk, StreamChunksResponse } is redundant with the export type * from './shared.js' on line 34 — and it's also missing StreamInfoResponse. Since line 34 already re-exports all types from shared.js, you can just remove this block entirely (lines 39-43).

.select({ chunkId: streams.chunkId })
.from(streams)
.where(and(eq(streams.streamId, name), eq(streams.eof, false)))
.orderBy(asc(streams.chunkId));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

getStreamInfo fetches all data rows into memory just to count them. For streams with thousands of chunks this is wasteful — a SELECT COUNT(*) FROM streams WHERE streamId = $1 AND eof = false would be much cheaper. Consider using Drizzle's count() aggregate or a raw SQL count instead of selecting all chunk IDs.

hasMore,
done: streamDone,
};
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The file-listing + extension-mapping logic (~30 lines) is duplicated verbatim between getStreamChunks and getStreamInfo. Consider extracting a helper like listStreamDataFiles(name) that returns { dataFiles, fileExtMap, streamDone } to keep this DRY.

Comment threadpackages/world-local/src/streamer.ts Outdated
break;
}
dataFiles.push(file);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

getStreamChunks reads and deserializes every chunk file from disk on every call (to separate data files from the EOF marker), regardless of the cursor position. For a stream with 10k chunks, page 2 still reads all 10k files.

Two possible improvements:

  1. Only read files starting from the cursor position (the ULID sort gives chronological order, so you can skip earlier files)
  2. At minimum, stop reading once you have enough files past the cursor — you only need startIndex + limit data files plus potentially the EOF marker

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Deeper review: testing, docs, observability, edge cases, and performance

Testing gaps

world-local and world-postgres have zero unit tests for the new getStreamChunks and getStreamInfo methods. The only coverage comes from two e2e tests that are skipIf(isLocalDeployment()), so local dev never exercises these paths. Cursor pagination logic (invalid cursors, out-of-range cursors, single-item pages), empty stream behavior (tailIndex = -1), and mid-flight streams (calling before workflow completes) are all untested.

The transport unit tests cover the happy path for negative initialStartIndex but miss: NaN/malformed x-workflow-stream-tail-index header values (the !Number.isNaN guard is untested), positive explicit startIndex override, boundary clamping when magnitude exceeds stream length, and multi-retry chains where the header varies across attempts.

Documentation gaps

  1. building-a-world.mdx still shows the old Streamer interface without getStreamChunks or getStreamInfo. Custom world implementers won't know these methods are required.
  2. No API reference for WorkflowReadableStream or getTailIndex() — the get-run.mdx page documents WorkflowReadableStreamOptions but not the new return type.
  3. Changeset mentions getStreamInfo but omits getStreamChunks.

Observability

Existing storage methods are wrapped with instrumentObject() for automatic OTEL spans (e.g. world.runs.get, world.events.create). Streamer methods — both existing and new — are not instrumented at all. No spans, no semantic conventions for stream names/indices, and no structured logging in any world implementation's streaming code. The only observability signal is the console.warn in WorkflowChatTransport for missing headers. This is consistent with the pre-existing pattern (streaming was never instrumented), but it means production debugging of pagination issues, tail-index mismatches, or reconnection failures will be blind.

Edge cases and performance (see inline comments below)

Several specific issues are called out inline.

break;
}
dataRows.push(row);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Edge case: EOF row appearing mid-batch. The query fetches limit + 1 rows ordered by chunkId without filtering out EOF rows. If the EOF row sorts between data rows (e.g. due to concurrent writes or clock skew), the loop breaks early on row.eof, and subsequent data rows in the result set are silently dropped.

Then hasMore = !streamDone && dataRows.length > limit evaluates to false (because streamDone = true), so the client sees done: true, hasMore: false even though there were more data rows in the fetch.

This is probably fine in practice since the EOF ULID is generated after all data chunks, but it's worth noting the implicit ordering assumption. Adding eq(streams.eof, false) to the main query and doing a separate EOF check (like the lookahead on line 250) would make this robust.

.select({ chunkId: streams.chunkId })
.from(streams)
.where(and(eq(streams.streamId, name), eq(streams.eof, false)))
.orderBy(asc(streams.chunkId));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Performance: O(N) memory to count rows. This SELECT chunkId FROM streams WHERE ... loads every non-EOF row into Node.js memory just to take .length. For a stream with 100k chunks, that's 100k objects allocated and immediately discarded.

Use a COUNT(*) aggregate instead:

const[{ count }]=awaitdrizzle.select({count: sql<number>`count(*)`}).from(streams).where(and(eq(streams.streamId,name),eq(streams.eof,false)));return{tailIndex: (count??0)-1,done: !!eofRow};

Comment threadpackages/world-local/src/streamer.ts Outdated
break;
}
dataFiles.push(file);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Performance: every page reads ALL chunk files.getStreamChunks deserializes every chunk file from disk (lines 286-296) to separate data files from the EOF marker, even for chunks that precede the cursor. Page 50 of a 10k-chunk stream still reads all 10k files.

getStreamInfo (line 372) has the exact same pattern — reads all files just to count them.

For local dev this is tolerable, but if these methods get called frequently (e.g. polling for tail index), the cost adds up. A lighter approach: since ULID sort gives chronological order, you could skip files before the cursor index and stop after collecting limit data files + EOF check.

const readable = run.getReadable();
const tailIndex = await readable.getTailIndex();

// outputStreamWorkflow writes 2 chunks to the default stream

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Testing gap: no empty-stream or mid-flight test. These tests only run after await run.returnValue (stream fully complete) and outputStreamWorkflow always writes exactly 2 chunks.

Missing scenarios:

  • Empty stream: What does getTailIndex() return when 0 chunks have been written? (should be -1 per the type docs)
  • Mid-flight stream: What does getTailIndex() / getStreamChunks() return while the workflow is still running and the stream isn't closed yet?
  • getStreamInfo directly: Only tested indirectly via getTailIndex() — no direct assertion on the done flag


warnSpy.mockRestore();
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Testing gap: malformed header values. The implementation has a !Number.isNaN(tailIndex) guard (transport line 362) but no test covers it. Consider adding a case where the header is "not-a-number"parseInt returns NaN, the guard should skip resolution, and the code should fall through without setting chunkIndex.

Also missing: a test for initialStartIndex: -1000 on a 5-chunk stream (should clamp to 0 via Math.max), and a test where a positive startIndex is passed via reconnectToStream options to override initialStartIndex.

const tailIndexHeader = res.headers.get('x-workflow-stream-tail-index');
if (tailIndexHeader !== null) {
const tailIndex = parseInt(tailIndexHeader, 10);
if (!Number.isNaN(tailIndex)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Edge case: NaN guard doesn't fall through cleanly. When parseInt returns NaN, the if (!Number.isNaN(tailIndex)) block is skipped, but replayFromStart is NOT set (that only happens in the else branch for tailIndexHeader === null). So on the next iteration, useExplicitStartIndex is false and replayFromStart is false, meaning the code falls through to chunkIndex — which is still 0 from initialization.

This accidentally works (retries from 0), but the intent is unclear. If the header is present but malformed, should it warn and set replayFromStart = true like the missing-header case? As-is, a malformed header silently degrades to replaying from 0 without the console warning.

GetChunksOptions,
StreamChunk,
StreamChunksResponse,
} from './shared.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Redundant + incomplete export. Line 34 already has export type * from './shared.js' which re-exports all types. This explicit block is redundant and also missing StreamInfoResponse. Just delete lines 39-43.

VaguelySeriousand others added 2 commits March 23, 2026 17:22
- world-postgres: use COUNT(*) in getStreamInfo instead of fetching all
rows; filter EOF rows in getStreamChunks query to prevent mid-batch
data loss from clock-skewed EOF rows
- world-local: skip files before cursor in getStreamChunks, read only
EOF byte in getStreamInfo and pre-cursor files
- WorkflowChatTransport: treat unparseable tail-index header same as
missing (set replayFromStart); add unit test for NaN header fallback
- e2e: add test for getTailIndex returning -1 on nonexistent stream
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- building-a-world.mdx: add getStreamChunks and getStreamInfo to
Streamer interface definition
- get-run.mdx: document WorkflowReadableStream and getTailIndex()
- Changeset: mention getStreamChunks alongside getStreamInfo
- world-vercel, world-local: wrap streamer with instrumentObject for
OTEL spans (world.streams.* prefix), matching storage instrumentation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fresh review after update

This update addresses all the critical issues from the previous round. Nice work cleaning this up.

Previously flagged — now resolved

  • Postgres getStreamInfo: Now uses COUNT(*) aggregate instead of fetching all rows into memory
  • Postgres getStreamChunks EOF mid-batch: Main query now filters eq(streams.eof, false), with a separate EOF check — no more risk of EOF silently dropping data rows
  • Transport NaN guard: Missing and malformed headers now both flow through the same else branch — replayFromStart = true + console.warn in both cases
  • world-local getStreamChunks perf: No longer deserializes every chunk — pre-cursor files use isEofChunk() (byte check only), and the loop stops early after collecting enough data
  • world-local getStreamInfo: Also uses isEofChunk() instead of full deserialization
  • Changeset: Now mentions both getStreamChunks() and getStreamInfo()
  • Docs: building-a-world.mdx includes the new Streamer methods with explanatory text. get-run.mdx has a WorkflowReadableStream section with getTailIndex() reference.
  • E2e tests: Added tailIndex returns -1 test using a nonexistent namespace. Added NaN header unit test.
  • OTEL: Both world-vercel and world-local now wrap the streamer with instrumentObject('world.streams', ...)
  • Redundant export: StreamInfoResponse added to the explicit export list in world/src/index.ts

Minor remaining items (non-blocking)

  1. world-local file-listing boilerplate still duplicated — the ~20-line block that lists files by extension, builds the fileExtMap, and filters by stream name is copy-pasted between getStreamChunks (lines 266-286) and getStreamInfo (lines 367-389). Extracting a listChunkFilesForStream(name) helper would reduce maintenance surface.

  2. world-postgres streamer not instrumentedworld-vercel and world-local both wrap their streamer with instrumentObject('world.streams', ...), giving the new methods automatic OTEL spans. world-postgres spreads the streamer directly without instrumentation (packages/world-postgres/src/index.ts:49). Pre-existing inconsistency, but worth noting since this PR adds new methods that would benefit from tracing.

  3. No unit tests for world-local or world-postgres implementations of getStreamChunks/getStreamInfo. The cursor pagination logic (invalid cursors, boundary conditions, EOF detection) is only tested via e2e tests that are skipped in local dev. Consider adding unit tests in a follow-up.

dataCount++;
}

return { tailIndex: dataCount - 1, done: streamDone };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: The file-listing + extension-mapping block (~20 lines) is duplicated between getStreamChunks (lines 266-286) and getStreamInfo (lines 367-389). A shared listChunkFilesForStream(name) helper returning { chunkFiles, fileExtMap } would keep these two methods DRY.

@ghostghost mentioned this pull request Mar 23, 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.

3 participants

@VaguelySerious@pranaygp@karthikscale3
, '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" + ' [world] Add stream pagination and metadata endpoints by VaguelySerious · Pull Request #1470 · vercel/workflow · GitHub
Skip to content

[world] Add stream pagination and metadata endpoints - #1470

Merged
VaguelySerious merged 32 commits into
mainfrom
peter/stream-metadata-and-pages
Mar 24, 2026
Merged

[world] Add stream pagination and metadata endpoints#1470
VaguelySerious merged 32 commits into
mainfrom
peter/stream-metadata-and-pages

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

No description provided.

VaguelySeriousand others added 13 commits March 19, 2026 17:44
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Negative startIndex values (e.g. -3) resolve to n chunks before the
known end of the stream. All world implementations (local, postgres,
vercel) support this. Includes unit tests, e2e test, and doc updates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Replaces three separate tests (no startIndex, positive, negative) with a
single describe block that iterates over startIndex cases. Fixes the
negative startIndex test by waiting for workflow completion before
connecting the reader — the backend resolves negative indices at
connection time using knownChunkCount, which is 0 if the stream hasn't
been fully written yet.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Replaces three separate tests (no startIndex, positive, negative) with a
single describe block that iterates over startIndex cases. Fixes the
negative startIndex test by waiting for workflow completion before
connecting the reader — the backend resolves negative indices at
connection time using knownChunkCount, which is 0 if the stream hasn't
been fully written yet.
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
- Clarify that negative startIndex is for custom consumers, not WorkflowChatTransport
- Add live-stream caveat for negative startIndex resolution timing
- Add pagination limitation callout for live streams
- Use explicit typeof guards for negative startIndex checks (world-local, world-postgres)
- Add cost comment for EOF marker disk read in world-local
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel

vercelBot commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Mar 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a82fbe7

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

This PR includes changesets to release 20 packages
NameType
@workflow/worldPatch
@workflow/world-localPatch
@workflow/world-vercelPatch
@workflow/world-postgresPatch
@workflow/corePatch
@workflow/aiPatch
@workflow/cliPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
workflowPatch
@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 Mar 20, 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🥇 Express0.043s (-5.4% 🟢)1.006s (~)0.962s101.00x
💻 LocalNitro0.044s (+14.4% 🔺)1.005s (~)0.962s101.00x
💻 LocalNext.js (Turbopack)0.049s1.005s0.956s101.13x
🌐 RedisNext.js (Turbopack)0.055s1.005s0.950s101.26x
🐘 PostgresExpress0.062s (+10.8% 🔺)1.011s (~)0.949s101.42x
🐘 PostgresNitro0.064s (-7.9% 🟢)1.011s (~)0.947s101.47x
🐘 PostgresNext.js (Turbopack)0.065s1.012s0.947s101.50x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro0.458s (-21.6% 🟢)2.677s (+25.7% 🔺)2.219s101.00x
▲ VercelNext.js (Turbopack)0.542s (-17.9% 🟢)2.725s (+14.0% 🔺)2.183s101.18x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.123s2.006s0.883s101.00x
🌐 RedisNext.js (Turbopack)1.125s2.006s0.882s101.00x
💻 LocalExpress1.128s (~)2.006s (~)0.878s101.00x
💻 LocalNitro1.131s (+3.5%)2.006s (~)0.875s101.01x
🐘 PostgresNext.js (Turbopack)1.142s2.013s0.871s101.02x
🐘 PostgresNitro1.149s (-1.2%)2.012s (~)0.863s101.02x
🐘 PostgresExpress1.162s (+0.7%)2.012s (~)0.851s101.03x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.139s (+2.5%)4.147s (+23.1% 🔺)2.008s101.00x
▲ VercelNext.js (Turbopack)2.152s (+4.3%)4.007s (+9.3% 🔺)1.855s101.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)10.776s11.022s0.246s31.00x
💻 LocalNext.js (Turbopack)10.792s11.024s0.232s31.00x
🐘 PostgresExpress10.871s (-0.6%)11.020s (~)0.148s31.01x
🐘 PostgresNext.js (Turbopack)10.875s11.024s0.149s31.01x
💻 LocalExpress10.906s (-0.5%)11.023s (~)0.118s31.01x
💻 LocalNitro10.923s (+2.9%)11.023s (~)0.100s31.01x
🐘 PostgresNitro10.938s (-1.8%)11.026s (-8.5% 🟢)0.088s31.02x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)17.601s (+2.3%)19.403s (+1.9%)1.803s21.00x
▲ VercelNitro17.930s (~)20.036s (+2.1%)2.106s21.02x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)14.213s15.028s0.815s41.00x
🐘 PostgresNext.js (Turbopack)14.424s15.026s0.602s41.01x
💻 LocalNext.js (Turbopack)14.540s15.029s0.489s41.02x
🐘 PostgresExpress14.580s (-1.6%)15.024s (~)0.444s41.03x
🐘 PostgresNitro14.643s (-1.7%)15.033s (~)0.390s41.03x
💻 LocalExpress14.944s (-0.6%)15.029s (-4.8%)0.084s41.05x
💻 LocalNitro14.984s (+5.4% 🔺)15.033s (~)0.049s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro34.179s (+4.6%)35.468s (+4.2%)1.289s21.00x
▲ VercelNext.js (Turbopack)35.203s (+4.7%)37.010s (+6.3% 🔺)1.807s21.03x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)13.441s14.026s0.585s71.00x
🐘 PostgresNext.js (Turbopack)13.969s14.167s0.198s71.04x
🐘 PostgresExpress13.969s (-3.2%)14.452s (-3.9%)0.482s71.04x
🐘 PostgresNitro14.112s (-5.0% 🟢)14.741s (-3.1%)0.629s71.05x
💻 LocalNext.js (Turbopack)16.013s16.362s0.348s61.19x
💻 LocalExpress16.580s (-2.7%)17.030s (-1.9%)0.450s61.23x
💻 LocalNitro16.653s (+11.9% 🔺)17.031s (+13.3% 🔺)0.378s61.24x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro58.422s (-5.3% 🟢)61.010s (-3.0%)2.587s21.00x
▲ VercelNext.js (Turbopack)63.237s (+3.3%)64.613s (+3.3%)1.376s21.08x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)1.235s2.010s0.775s151.00x
🐘 PostgresNitro1.257s (-3.4%)2.012s (~)0.756s151.02x
🐘 PostgresExpress1.261s (-1.7%)2.012s (~)0.751s151.02x
🌐 RedisNext.js (Turbopack)1.321s2.007s0.685s151.07x
💻 LocalExpress1.527s (~)2.005s (~)0.478s151.24x
💻 LocalNitro1.527s (+4.8%)2.005s (~)0.478s151.24x
💻 LocalNext.js (Turbopack)1.572s2.006s0.434s151.27x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.597s (+2.8%)4.602s (+19.8% 🔺)2.005s71.00x
▲ VercelNext.js (Turbopack)2.630s (+1.5%)4.054s (-1.1%)1.423s81.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express2.354s (-3.6%)3.010s (~)0.657s101.00x
🐘 PostgresNitro2.354s (-5.6% 🟢)3.010s (~)0.656s101.00x
🐘 PostgresNext.js (Turbopack)2.412s3.010s0.599s101.02x
🌐 RedisNext.js (Turbopack)2.599s3.008s0.409s101.10x
💻 LocalNext.js (Turbopack)2.975s3.565s0.590s91.26x
💻 LocalNitro3.009s (+19.1% 🔺)3.760s (+25.0% 🔺)0.751s81.28x
💻 LocalExpress3.030s (-2.3%)3.760s (-6.2% 🟢)0.730s81.29x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.782s (+6.8% 🔺)5.013s (+36.6% 🔺)2.230s61.00x
▲ VercelNext.js (Turbopack)3.142s (+7.0% 🔺)4.966s (+15.8% 🔺)1.824s71.13x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro3.464s (-5.5% 🟢)4.015s (~)0.551s81.00x
🐘 PostgresExpress3.475s (-2.7%)4.014s (~)0.539s81.00x
🐘 PostgresNext.js (Turbopack)3.663s4.010s0.347s81.06x
🌐 RedisNext.js (Turbopack)4.261s5.011s0.750s61.23x
💻 LocalNext.js (Turbopack)7.418s8.016s0.597s42.14x
💻 LocalExpress8.564s (-1.7%)9.021s (-2.7%)0.457s42.47x
💻 LocalNitro9.200s (+37.7% 🔺)9.772s (+39.3% 🔺)0.571s42.66x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.261s (-3.7%)5.062s (+5.6% 🔺)1.801s71.00x
▲ VercelNext.js (Turbopack)3.526s (-15.3% 🟢)6.284s (+13.1% 🔺)2.758s51.08x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)1.219s2.009s0.790s151.00x
🐘 PostgresNitro1.252s (-3.0%)2.009s (~)0.757s151.03x
🐘 PostgresExpress1.255s (-2.8%)2.009s (~)0.754s151.03x
🌐 RedisNext.js (Turbopack)1.305s2.006s0.701s151.07x
💻 LocalNext.js (Turbopack)1.508s2.005s0.497s151.24x
💻 LocalNitro1.573s (+7.4% 🔺)2.007s (~)0.434s151.29x
💻 LocalExpress1.598s (+3.5%)2.072s (+3.3%)0.474s151.31x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.243s (-13.3% 🟢)3.995s (+5.7% 🔺)1.752s81.00x
▲ VercelNitro2.260s (-2.0%)4.048s (+7.7% 🔺)1.787s81.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.334s (-6.4% 🟢)3.011s (~)0.677s101.00x
🐘 PostgresExpress2.339s (-4.7%)3.010s (~)0.672s101.00x
🐘 PostgresNext.js (Turbopack)2.402s3.010s0.609s101.03x
🌐 RedisNext.js (Turbopack)2.544s3.008s0.464s101.09x
💻 LocalNext.js (Turbopack)2.868s3.453s0.585s91.23x
💻 LocalExpress2.992s (-5.7% 🟢)3.762s (-6.2% 🟢)0.770s81.28x
💻 LocalNitro3.086s (+7.6% 🔺)3.760s (+21.0% 🔺)0.674s81.32x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.905s (+1.0%)5.039s (+17.1% 🔺)2.134s71.00x
▲ VercelNext.js (Turbopack)3.300s (+10.1% 🔺)4.771s (+11.2% 🔺)1.471s71.14x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro3.469s (-4.9%)4.013s (~)0.544s81.00x
🐘 PostgresExpress3.473s (-3.4%)4.014s (~)0.541s81.00x
🐘 PostgresNext.js (Turbopack)3.670s4.012s0.342s81.06x
🌐 RedisNext.js (Turbopack)4.275s4.868s0.593s71.23x
💻 LocalExpress8.786s (-4.0%)9.022s (-10.0% 🟢)0.237s42.53x
💻 LocalNext.js (Turbopack)8.883s9.772s0.889s42.56x
💻 LocalNitro9.326s (+26.1% 🔺)10.024s (+25.0% 🔺)0.698s32.69x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.294s (+6.2% 🔺)5.002s (+6.1% 🔺)1.707s61.00x
▲ VercelNext.js (Turbopack)4.267s (+19.0% 🔺)6.135s (+24.6% 🔺)1.869s51.30x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)0.699s1.005s0.305s601.00x
🐘 PostgresNext.js (Turbopack)0.769s1.007s0.238s601.10x
🐘 PostgresExpress0.801s (-12.5% 🟢)1.007s (-6.7% 🟢)0.206s601.15x
🐘 PostgresNitro0.829s (-14.4% 🟢)1.007s (-18.5% 🟢)0.178s601.19x
💻 LocalNext.js (Turbopack)0.841s1.021s0.180s591.20x
💻 LocalExpress0.977s (-3.4%)1.076s (-30.3% 🟢)0.098s561.40x
💻 LocalNitro1.015s (+50.6% 🔺)1.628s (+62.1% 🔺)0.613s371.45x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro10.274s (+2.1%)12.099s (+5.9% 🔺)1.825s51.00x
▲ VercelNext.js (Turbopack)10.675s (+7.4% 🔺)12.483s (+1.0%)1.808s51.04x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.674s2.006s0.332s451.00x
🐘 PostgresNext.js (Turbopack)1.861s2.030s0.169s451.11x
🐘 PostgresExpress1.920s (-13.7% 🟢)2.077s (-31.0% 🟢)0.157s441.15x
🐘 PostgresNitro1.957s (-15.4% 🟢)2.176s (-27.8% 🟢)0.219s421.17x
💻 LocalNext.js (Turbopack)2.613s3.007s0.395s301.56x
💻 LocalExpress2.984s (-1.6%)3.341s (-9.4% 🟢)0.357s271.78x
💻 LocalNitro3.007s (+37.1% 🔺)3.547s (+18.0% 🔺)0.541s261.80x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro32.892s (+9.6% 🔺)34.652s (+8.7% 🔺)1.761s31.00x
▲ VercelNext.js (Turbopack)34.160s (+5.3% 🔺)51.070s (+51.8% 🔺)16.910s21.04x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)3.329s4.009s0.680s301.00x
🐘 PostgresNext.js (Turbopack)3.777s4.011s0.234s301.13x
🐘 PostgresExpress3.894s (-13.3% 🟢)4.184s (-16.6% 🟢)0.289s291.17x
🐘 PostgresNitro3.982s (-16.9% 🟢)4.404s (-13.6% 🟢)0.422s281.20x
💻 LocalNext.js (Turbopack)8.432s9.017s0.585s142.53x
💻 LocalExpress9.097s (+3.3%)9.555s (+4.3%)0.459s132.73x
💻 LocalNitro9.155s (+26.4% 🔺)9.864s (+23.1% 🔺)0.710s132.75x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro87.838s (+1.9%)104.355s (+19.6% 🔺)16.517s21.00x
▲ VercelNext.js (Turbopack)88.131s (+2.3%)90.390s (+2.2%)2.259s21.00x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)0.258s1.008s0.750s601.00x
🐘 PostgresNitro0.270s (-14.9% 🟢)1.008s (~)0.738s601.05x
🐘 PostgresExpress0.270s (-8.7% 🟢)1.008s (~)0.738s601.05x
🌐 RedisNext.js (Turbopack)0.365s1.004s0.640s601.42x
💻 LocalNext.js (Turbopack)0.551s1.004s0.453s602.14x
💻 LocalExpress0.595s (+4.7%)1.004s (~)0.410s602.31x
💻 LocalNitro0.598s (+6.7% 🔺)1.022s (+1.7%)0.423s592.32x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro1.834s (+9.2% 🔺)3.825s (+15.9% 🔺)1.992s161.00x
▲ VercelNext.js (Turbopack)2.568s (+35.7% 🔺)9.530s (+154.0% 🔺)6.962s121.40x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)0.481s1.019s0.538s891.00x
🐘 PostgresExpress0.493s (-10.0% 🟢)1.008s (~)0.515s901.03x
🐘 PostgresNitro0.498s (-14.7% 🟢)1.008s (~)0.511s901.04x
🌐 RedisNext.js (Turbopack)1.207s2.006s0.799s452.51x
💻 LocalNitro2.505s (+6.9% 🔺)3.009s (~)0.503s305.21x
💻 LocalNext.js (Turbopack)2.539s3.009s0.470s305.28x
💻 LocalExpress2.567s (+4.7%)3.009s (~)0.442s305.34x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.344s (+11.3% 🔺)5.095s (+14.5% 🔺)1.751s181.00x
▲ VercelNext.js (Turbopack)4.913s (+51.5% 🔺)6.649s (+40.7% 🔺)1.736s141.47x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)0.760s1.008s0.247s1201.00x
🐘 PostgresNitro0.767s (-20.7% 🟢)1.008s (-27.9% 🟢)0.241s1201.01x
🐘 PostgresExpress0.774s (-15.8% 🟢)1.008s (-18.7% 🟢)0.234s1201.02x
🌐 RedisNext.js (Turbopack)2.757s3.084s0.327s393.63x
💻 LocalNext.js (Turbopack)10.553s11.027s0.473s1113.88x
💻 LocalExpress11.228s (+1.9%)11.937s (+2.3%)0.708s1114.76x
💻 LocalNitro11.257s (+13.4% 🔺)12.029s (+16.1% 🔺)0.771s1014.80x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)7.133s (-77.8% 🟢)8.902s (-73.6% 🟢)1.769s141.00x
▲ VercelNitro7.220s (~)8.773s (+1.1%)1.554s141.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.175s1.001s0.011s1.017s0.842s101.00x
🌐 RedisNext.js (Turbopack)0.183s1.000s0.001s1.008s0.825s101.05x
🐘 PostgresExpress0.195s (-12.5% 🟢)0.996s (~)0.002s (+15.4% 🔺)1.011s (~)0.816s101.11x
🐘 PostgresNext.js (Turbopack)0.199s1.000s0.004s1.014s0.814s101.14x
🐘 PostgresNitro0.204s (-15.0% 🟢)0.995s (~)0.001s (-27.8% 🟢)1.011s (~)0.807s101.17x
💻 LocalNitro0.208s (+49.5% 🔺)1.003s (~)0.012s (+19.6% 🔺)1.018s (~)0.810s101.19x
💻 LocalExpress0.211s (+2.1%)1.003s (~)0.011s (-5.9% 🟢)1.017s (~)0.806s101.20x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro1.779s (+15.1% 🔺)2.497s (-4.5%)0.810s (+76.9% 🔺)3.996s (+11.2% 🔺)2.217s101.00x
▲ VercelNext.js (Turbopack)1.926s (+17.8% 🔺)2.945s (+0.9%)0.662s (+65.8% 🔺)4.322s (+11.6% 🔺)2.396s101.08x
▲ VercelExpress⚠️missing-----

🔍 Observability: Nitro | Next.js (Turbopack)

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)0.487s1.000s0.003s1.011s0.524s601.00x
🐘 PostgresExpress0.593s (-15.7% 🟢)1.003s (~)0.013s (+103.1% 🔺)1.033s (~)0.440s591.22x
🐘 PostgresNitro0.602s (-17.0% 🟢)1.004s (~)0.004s (-37.7% 🟢)1.023s (-0.7%)0.421s591.24x
🐘 PostgresNext.js (Turbopack)0.611s1.007s0.004s1.025s0.414s591.25x
💻 LocalNext.js (Turbopack)0.666s1.008s0.009s1.023s0.357s591.37x
💻 LocalNitro0.724s (+27.6% 🔺)1.009s (~)0.010s (+4.6%)1.024s (~)0.299s591.49x
💻 LocalExpress0.734s (+2.3%)1.009s (~)0.009s (-5.3% 🟢)1.022s (~)0.288s591.51x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)5.132s (+12.4% 🔺)6.256s (+11.9% 🔺)0.242s (+7.6% 🔺)13.240s (+104.9% 🔺)8.108s51.00x
▲ VercelNitro5.363s (~)6.372s (+3.7%)0.403s (-35.9% 🟢)7.632s (+1.2%)2.269s81.05x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)0.897s1.034s0.000s1.039s0.141s581.00x
🐘 PostgresNext.js (Turbopack)0.948s1.132s0.000s1.141s0.193s531.06x
🐘 PostgresExpress0.956s (-14.8% 🟢)1.271s (-28.6% 🟢)0.000s (-63.8% 🟢)1.286s (-28.9% 🟢)0.331s471.06x
🐘 PostgresNitro0.965s (-17.5% 🟢)1.298s (-35.0% 🟢)0.000s (-2.2%)1.315s (-35.0% 🟢)0.350s461.08x
💻 LocalNitro1.224s (+8.0% 🔺)2.020s (~)0.000s (+50.0% 🔺)2.023s (~)0.799s301.36x
💻 LocalExpress1.246s (+3.6%)2.020s (~)0.000s (-42.9% 🟢)2.023s (~)0.777s301.39x
💻 LocalNext.js (Turbopack)1.270s2.020s0.000s2.024s0.754s301.42x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.626s (+3.0%)4.889s (+5.1% 🔺)0.004s (+Infinity% 🔺)5.632s (+8.2% 🔺)2.006s111.00x
▲ VercelNitro3.756s (+24.1% 🔺)4.924s (+25.5% 🔺)0.002s (+1045.5% 🔺)5.654s (+27.8% 🔺)1.898s111.04x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

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

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.603s2.002s0.000s2.007s0.403s301.00x
🐘 PostgresNext.js (Turbopack)1.831s2.106s0.000s2.115s0.284s291.14x
🐘 PostgresExpress1.840s (-10.6% 🟢)2.258s (-8.8% 🟢)0.000s (NaN%)2.271s (-9.1% 🟢)0.431s271.15x
🐘 PostgresNitro1.853s (-18.2% 🟢)2.223s (-24.7% 🟢)0.000s (-100.0% 🟢)2.235s (-24.7% 🟢)0.382s271.16x
💻 LocalNitro3.585s (+3.6%)4.300s (+6.7% 🔺)0.000s (-61.5% 🟢)4.304s (+6.7% 🔺)0.720s152.24x
💻 LocalNext.js (Turbopack)3.632s4.230s0.001s4.235s0.604s152.27x
💻 LocalExpress3.670s (+8.3% 🔺)4.032s (-1.6%)0.000s (+250.0% 🔺)4.036s (-1.6%)0.366s152.29x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro4.200s (+21.3% 🔺)5.030s (+20.0% 🔺)0.000s (+Infinity% 🔺)5.900s (+24.3% 🔺)1.700s111.00x
▲ VercelNext.js (Turbopack)5.104s (+14.0% 🔺)6.017s (+11.1% 🔺)0.000s (+10.0% 🔺)6.656s (+11.9% 🔺)1.552s101.22x
▲ VercelExpress⚠️missing-----

🔍 Observability: Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)15/21
🐘 PostgresNext.js (Turbopack)13/21
▲ VercelNitro16/21
Fastest World by Framework

Winner determined by most benchmark wins

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

📋 View full workflow run


Some benchmark jobs failed:

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

Check the workflow run for details.

@github-actions

github-actionsBot commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production813067880
✅ 💻 Local Development7820178960
✅ 📦 Local Production7820178960
✅ 🐘 Local Postgres7820178960
✅ 🪟 Windows720880
❌ 🌍 Community Worlds1185630204
✅ 📋 Other198042240
Total3547566814284

❌ Failed Tests

🌍 Community Worlds (56 failed)

mongodb (3 failed):

  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KMEKZQ811N46GD5AK5RW1HD9
  • webhookWorkflow | wrun_01KMEKZZZNPHAFY93GWGMA33E4
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KMEM7DRXD1RYA1E11HJTJFPW

redis (2 failed):

  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KMEKZQ811N46GD5AK5RW1HD9
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KMEM7DRXD1RYA1E11HJTJFPW

turso (51 failed):

  • addTenWorkflow | wrun_01KMEKYFN28CV699JDRY8EVENQ
  • addTenWorkflow | wrun_01KMEKYFN28CV699JDRY8EVENQ
  • wellKnownAgentWorkflow (.well-known/agent) | wrun_01KMEKZV5Y7H35GS496CWE2PWY
  • should work with react rendering in step
  • promiseAllWorkflow | wrun_01KMEKYP9VT3R0SKBHBMF46WF1
  • promiseRaceWorkflow | wrun_01KMEKYV2NHKMK1PT16PHGC0TE
  • promiseAnyWorkflow | wrun_01KMEKYYRJ4XWER78HC3BC88RH
  • importedStepOnlyWorkflow | wrun_01KMEM07SDMHGNED3K1Z8822HA
  • hookWorkflow | wrun_01KMEKZB3KDGNSRDK1ZP1ZWCS2
  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KMEKZQ811N46GD5AK5RW1HD9
  • webhookWorkflow | wrun_01KMEKZZZNPHAFY93GWGMA33E4
  • sleepingWorkflow | wrun_01KMEM07AP1ZHHP0B1Y28XPTPF
  • parallelSleepWorkflow | wrun_01KMEM0M62BA11GZH9N30PA01V
  • nullByteWorkflow | wrun_01KMEM0RNC851K91RM7QKG9J0A
  • workflowAndStepMetadataWorkflow | wrun_01KMEM0TS1NDEE94P4ZK643V6C
  • fetchWorkflow | wrun_01KMEM3MRRD40CZ6RZHVTBN0QE
  • promiseRaceStressTestWorkflow | wrun_01KMEM3RJJYY05KEDAWHC7YZCF
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion | wrun_01KMEM6SVSD36537C7RMSNZ7K2
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KMEM7DRXD1RYA1E11HJTJFPW
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running | wrun_01KMEM84D71052EEDK1NTW09QR
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars) | wrun_01KMEM8QRVX2ZN0ZY8KSFXYRFW
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument | wrun_01KMEM90F9YAZG8G7CZZR9KQWQ
  • closureVariableWorkflow - nested step functions with closure variables | wrun_01KMEM96EMKN1H5C673QHQM0RJ
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step | wrun_01KMEM9A0HFZKBNE055EZ9P5H4
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly | wrun_01KMEM9RJS5TNK0QPDKP86M45K
  • Calculator.calculate - static workflow method using static step methods from another class | wrun_01KMEM9XZANZTCTYJ5TEE55MRZ
  • AllInOneService.processNumber - static workflow method using sibling static step methods | wrun_01KMEMA4B58C28JE56JM1YFVQV
  • ChainableService.processWithThis - static step methods using this to reference the class | wrun_01KMEMAATKT6G9NX72T548BXF1
  • thisSerializationWorkflow - step function invoked with .call() and .apply() | wrun_01KMEMAH46HQ91CQS2CS66CDC6
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE | wrun_01KMEMAQHGV2M8JHE762DZMKC6
  • instanceMethodStepWorkflow - instance methods with "use step" directive | wrun_01KMEMAYB4Y9GXM4F81Y6CNJ6B
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context | wrun_01KMEMB8XA6D9RX061RHB81PKZ
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument | wrun_01KMEMBGE2CX800GQHW1H2HAJF
  • cancelRun - cancelling a running workflow | wrun_01KMEMBQWVXW1JB5P0HV743Q4P
  • cancelRun via CLI - cancelling a running workflow | wrun_01KMEMC0XTPK7JCE38TSAYE9N8
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep | wrun_01KMEMCCDFRYCKTFTV8TJ03YRG
  • sleepInLoopWorkflow - sleep inside loop with steps actually delays each iteration | wrun_01KMEMD0561BCM0VA669B3A6CS
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control) | wrun_01KMEMDAE0C841QKT27361H8BN

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro7307
✅ example7307
✅ express7307
✅ fastify7307
✅ hono7307
✅ nextjs-turbopack7802
✅ nextjs-webpack7802
✅ nitro7307
✅ nuxt7307
✅ sveltekit7307
✅ vite7307
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable66014
✅ express-stable66014
✅ fastify-stable66014
✅ hono-stable66014
✅ nextjs-turbopack-canary55025
✅ nextjs-turbopack-stable7208
✅ nextjs-webpack-canary55025
✅ nextjs-webpack-stable7208
✅ nitro-stable66014
✅ nuxt-stable66014
✅ sveltekit-stable66014
✅ vite-stable66014
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable66014
✅ express-stable66014
✅ fastify-stable66014
✅ hono-stable66014
✅ nextjs-turbopack-canary55025
✅ nextjs-turbopack-stable7208
✅ nextjs-webpack-canary55025
✅ nextjs-webpack-stable7208
✅ nitro-stable66014
✅ nuxt-stable66014
✅ sveltekit-stable66014
✅ vite-stable66014
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable66014
✅ express-stable66014
✅ fastify-stable66014
✅ hono-stable66014
✅ nextjs-turbopack-canary55025
✅ nextjs-turbopack-stable7208
✅ nextjs-webpack-canary55025
✅ nextjs-webpack-stable7208
✅ nitro-stable66014
✅ nuxt-stable66014
✅ sveltekit-stable66014
✅ vite-stable66014
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack7208
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev302
❌ mongodb5238
✅ redis-dev302
❌ redis5328
✅ turso-dev302
❌ turso4518
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable66014
✅ e2e-local-postgres-nest-stable66014
✅ e2e-local-prod-nest-stable66014

📋 View full workflow run

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Comment threadpackages/core/e2e/e2e.test.ts Outdated

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Here's my review of PR #1470:

Summary

This is a feature PR by @VaguelySerious that adds two new Streamer interface methods (getStreamChunks and getStreamInfo) across all world implementations, extends Run.getReadable() with a getTailIndex() helper, and adds initialStartIndex support to WorkflowChatTransport for resuming streams from the end.

No Blockers

CI is in excellent shape. The code changes are well-structured, well-documented, and backward-compatible.

CI Status

Passing:

  • All Vercel Production E2E tests: 11/11 frameworks pass (802 passed, 0 failed)
  • All Local Dev E2E tests: 12/12 pass (782 passed)
  • All Local Prod E2E tests: 12/12 pass
  • All Local Postgres E2E tests: 12/12 pass
  • Windows E2E tests pass
  • Unit tests pass (ubuntu + windows)
  • Vitest plugin tests pass
  • All Vercel deployments succeed (except workbench-nestjs-workflow - discussed below)
  • Benchmarks pass for local/postgres; no performance regressions
  • Docs code samples and links pass

Failing (non-blocking / pre-existing):

  • workbench-nestjs-workflow Vercel deployment fails - this is likely a pre-existing issue unrelated to streaming changes (the nest workbench has no streaming code changes in this PR)
  • Benchmark Vercel (nitro-v3) fails at ~30min - likely infra timeout, not PR-related
  • Community World (Turso) - 51 failures, pre-existing community world issue (same pattern as other PRs)
  • Community World (MongoDB/Redis) - 3/2 failures on hook-related tests, pre-existing

Code Review

1. Streamer interface additions (packages/world/src/interfaces.ts) - Clean

Two new methods added to the Streamer interface:

  • getStreamChunks(name, runId, options?) - cursor-based pagination for stream chunks
  • getStreamInfo(name, runId) - lightweight metadata (tailIndex, done flag)

New types (StreamChunk, StreamChunksResponse, GetChunksOptions, StreamInfoResponse) are well-documented in packages/world/src/shared.ts. The interface is additive and non-breaking.

2. world-vercel implementation (packages/world-vercel/src/streamer.ts) - Clean

Uses makeRequest + Zod schemas to call two new server endpoints:

  • GET /v2/runs/:runId/streams/:name/chunks?limit=&cursor=
  • GET /v2/runs/:runId/streams/:name/info

The StreamChunksResponseSchema correctly expects z.instanceof(Uint8Array) since CBOR handles binary natively. Clean and minimal.

3. world-local implementation (packages/world-local/src/streamer.ts) - Works but verbose

The implementation reads chunk files from disk, builds a file extension map, sorts by ULID, and paginates. One observation:

  • getStreamInfo reads every chunk file to count data chunks and detect EOF. For large streams this could be expensive. The getStreamChunks implementation has the same pattern. Both could potentially be optimized with a count-based approach, but for local dev this is fine.
  • The cursor encoding (base64-encoded JSON { i: index }) is a reasonable pattern.

4. world-postgres implementation (packages/world-postgres/src/streamer.ts) - Solid

Uses Drizzle ORM with proper query patterns:

  • Fetches limit + 1 rows to detect hasMore efficiently
  • Correctly handles the EOF row being mixed into results
  • Has a follow-up EOF check query when !streamDone && !hasMore to correctly report done status
  • Cursor encodes both chunk ID and running index ({ c: chunkId, i: baseIndex })

One minor cleanup: void fn() on line 407 (changing cleanups.forEach((fn) => fn()) to cleanups.forEach((fn) => void fn())) is a good lint fix to suppress floating promise warnings.

5. WorkflowReadableStream type and getTailIndex() (packages/core/src/runtime/run.ts) - Clean

Uses Object.assign to add getTailIndex() to the ReadableStream. This is a clean pattern that avoids subclassing. The type is properly exported through packages/workflow/src/api.ts.

6. WorkflowChatTransport changes (packages/ai/src/workflow-chat-transport.ts) - Well thought out

The negative initialStartIndex feature is carefully designed:

  • First request uses the explicit (potentially negative) startIndex
  • On retry, resolves to an absolute position using x-workflow-stream-tail-index header
  • Falls back to startIndex=0 with a warning if the header is missing
  • useExplicitStartIndex flag ensures the negative value is only used once, then subsequent retries use the running chunkIndex

The replayFromStart flag handles the edge case where the header is absent gracefully.

7. Tests - Good coverage

  • Two new e2e tests for getTailIndex and getStreamChunks pagination (skipped on local deployments since they require server-side endpoints)
  • Two new unit tests for WorkflowChatTransport covering negative initialStartIndex with and without the tail-index header

8. Docs - Thorough

Updated resumable-streams.mdx with:

  • New "Resuming from the end of the stream" section
  • Updated stream reconnection endpoint to include getTailIndex() and the x-workflow-stream-tail-index header
  • Callout about header requirement for negative initialStartIndex
  • API reference docs updated

9. Changeset - Present and correct

Includes @workflow/world, world-local, world-vercel, world-postgres, core, and ai as patch changes.

Minor Notes

  • The PR description is empty ("No description provided"). Worth adding a summary for posterity.
  • The getStreamChunks method uses _runId (unused) in local/postgres implementations since the stream name is already unique there. The vercel implementation passes it to the server endpoint. This is fine - the interface is designed for the vercel world's needs.

Verdict

No blockers or regressions. This is a well-implemented, additive feature with good test coverage and documentation. The only CI failures are pre-existing (nest deployment, community world Turso/MongoDB/Redis, nitro benchmark timeout). Ready to merge once it gets a review approval.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall this is a well-designed, additive feature. The negative initialStartIndex logic in the transport is carefully thought through, the interface additions are clean, and the docs/tests cover the new behavior well. A few issues worth addressing below.

GetChunksOptions,
StreamChunk,
StreamChunksResponse,
} from './shared.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The explicit export type { GetChunksOptions, StreamChunk, StreamChunksResponse } is redundant with the export type * from './shared.js' on line 34 — and it's also missing StreamInfoResponse. Since line 34 already re-exports all types from shared.js, you can just remove this block entirely (lines 39-43).

.select({ chunkId: streams.chunkId })
.from(streams)
.where(and(eq(streams.streamId, name), eq(streams.eof, false)))
.orderBy(asc(streams.chunkId));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

getStreamInfo fetches all data rows into memory just to count them. For streams with thousands of chunks this is wasteful — a SELECT COUNT(*) FROM streams WHERE streamId = $1 AND eof = false would be much cheaper. Consider using Drizzle's count() aggregate or a raw SQL count instead of selecting all chunk IDs.

hasMore,
done: streamDone,
};
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The file-listing + extension-mapping logic (~30 lines) is duplicated verbatim between getStreamChunks and getStreamInfo. Consider extracting a helper like listStreamDataFiles(name) that returns { dataFiles, fileExtMap, streamDone } to keep this DRY.

Comment threadpackages/world-local/src/streamer.ts Outdated
break;
}
dataFiles.push(file);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

getStreamChunks reads and deserializes every chunk file from disk on every call (to separate data files from the EOF marker), regardless of the cursor position. For a stream with 10k chunks, page 2 still reads all 10k files.

Two possible improvements:

  1. Only read files starting from the cursor position (the ULID sort gives chronological order, so you can skip earlier files)
  2. At minimum, stop reading once you have enough files past the cursor — you only need startIndex + limit data files plus potentially the EOF marker

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Deeper review: testing, docs, observability, edge cases, and performance

Testing gaps

world-local and world-postgres have zero unit tests for the new getStreamChunks and getStreamInfo methods. The only coverage comes from two e2e tests that are skipIf(isLocalDeployment()), so local dev never exercises these paths. Cursor pagination logic (invalid cursors, out-of-range cursors, single-item pages), empty stream behavior (tailIndex = -1), and mid-flight streams (calling before workflow completes) are all untested.

The transport unit tests cover the happy path for negative initialStartIndex but miss: NaN/malformed x-workflow-stream-tail-index header values (the !Number.isNaN guard is untested), positive explicit startIndex override, boundary clamping when magnitude exceeds stream length, and multi-retry chains where the header varies across attempts.

Documentation gaps

  1. building-a-world.mdx still shows the old Streamer interface without getStreamChunks or getStreamInfo. Custom world implementers won't know these methods are required.
  2. No API reference for WorkflowReadableStream or getTailIndex() — the get-run.mdx page documents WorkflowReadableStreamOptions but not the new return type.
  3. Changeset mentions getStreamInfo but omits getStreamChunks.

Observability

Existing storage methods are wrapped with instrumentObject() for automatic OTEL spans (e.g. world.runs.get, world.events.create). Streamer methods — both existing and new — are not instrumented at all. No spans, no semantic conventions for stream names/indices, and no structured logging in any world implementation's streaming code. The only observability signal is the console.warn in WorkflowChatTransport for missing headers. This is consistent with the pre-existing pattern (streaming was never instrumented), but it means production debugging of pagination issues, tail-index mismatches, or reconnection failures will be blind.

Edge cases and performance (see inline comments below)

Several specific issues are called out inline.

break;
}
dataRows.push(row);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Edge case: EOF row appearing mid-batch. The query fetches limit + 1 rows ordered by chunkId without filtering out EOF rows. If the EOF row sorts between data rows (e.g. due to concurrent writes or clock skew), the loop breaks early on row.eof, and subsequent data rows in the result set are silently dropped.

Then hasMore = !streamDone && dataRows.length > limit evaluates to false (because streamDone = true), so the client sees done: true, hasMore: false even though there were more data rows in the fetch.

This is probably fine in practice since the EOF ULID is generated after all data chunks, but it's worth noting the implicit ordering assumption. Adding eq(streams.eof, false) to the main query and doing a separate EOF check (like the lookahead on line 250) would make this robust.

.select({ chunkId: streams.chunkId })
.from(streams)
.where(and(eq(streams.streamId, name), eq(streams.eof, false)))
.orderBy(asc(streams.chunkId));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Performance: O(N) memory to count rows. This SELECT chunkId FROM streams WHERE ... loads every non-EOF row into Node.js memory just to take .length. For a stream with 100k chunks, that's 100k objects allocated and immediately discarded.

Use a COUNT(*) aggregate instead:

const[{ count }]=awaitdrizzle.select({count: sql<number>`count(*)`}).from(streams).where(and(eq(streams.streamId,name),eq(streams.eof,false)));return{tailIndex: (count??0)-1,done: !!eofRow};

Comment threadpackages/world-local/src/streamer.ts Outdated
break;
}
dataFiles.push(file);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Performance: every page reads ALL chunk files.getStreamChunks deserializes every chunk file from disk (lines 286-296) to separate data files from the EOF marker, even for chunks that precede the cursor. Page 50 of a 10k-chunk stream still reads all 10k files.

getStreamInfo (line 372) has the exact same pattern — reads all files just to count them.

For local dev this is tolerable, but if these methods get called frequently (e.g. polling for tail index), the cost adds up. A lighter approach: since ULID sort gives chronological order, you could skip files before the cursor index and stop after collecting limit data files + EOF check.

const readable = run.getReadable();
const tailIndex = await readable.getTailIndex();

// outputStreamWorkflow writes 2 chunks to the default stream

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Testing gap: no empty-stream or mid-flight test. These tests only run after await run.returnValue (stream fully complete) and outputStreamWorkflow always writes exactly 2 chunks.

Missing scenarios:

  • Empty stream: What does getTailIndex() return when 0 chunks have been written? (should be -1 per the type docs)
  • Mid-flight stream: What does getTailIndex() / getStreamChunks() return while the workflow is still running and the stream isn't closed yet?
  • getStreamInfo directly: Only tested indirectly via getTailIndex() — no direct assertion on the done flag


warnSpy.mockRestore();
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Testing gap: malformed header values. The implementation has a !Number.isNaN(tailIndex) guard (transport line 362) but no test covers it. Consider adding a case where the header is "not-a-number"parseInt returns NaN, the guard should skip resolution, and the code should fall through without setting chunkIndex.

Also missing: a test for initialStartIndex: -1000 on a 5-chunk stream (should clamp to 0 via Math.max), and a test where a positive startIndex is passed via reconnectToStream options to override initialStartIndex.

const tailIndexHeader = res.headers.get('x-workflow-stream-tail-index');
if (tailIndexHeader !== null) {
const tailIndex = parseInt(tailIndexHeader, 10);
if (!Number.isNaN(tailIndex)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Edge case: NaN guard doesn't fall through cleanly. When parseInt returns NaN, the if (!Number.isNaN(tailIndex)) block is skipped, but replayFromStart is NOT set (that only happens in the else branch for tailIndexHeader === null). So on the next iteration, useExplicitStartIndex is false and replayFromStart is false, meaning the code falls through to chunkIndex — which is still 0 from initialization.

This accidentally works (retries from 0), but the intent is unclear. If the header is present but malformed, should it warn and set replayFromStart = true like the missing-header case? As-is, a malformed header silently degrades to replaying from 0 without the console warning.

GetChunksOptions,
StreamChunk,
StreamChunksResponse,
} from './shared.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Redundant + incomplete export. Line 34 already has export type * from './shared.js' which re-exports all types. This explicit block is redundant and also missing StreamInfoResponse. Just delete lines 39-43.

VaguelySeriousand others added 2 commits March 23, 2026 17:22
- world-postgres: use COUNT(*) in getStreamInfo instead of fetching all
rows; filter EOF rows in getStreamChunks query to prevent mid-batch
data loss from clock-skewed EOF rows
- world-local: skip files before cursor in getStreamChunks, read only
EOF byte in getStreamInfo and pre-cursor files
- WorkflowChatTransport: treat unparseable tail-index header same as
missing (set replayFromStart); add unit test for NaN header fallback
- e2e: add test for getTailIndex returning -1 on nonexistent stream
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- building-a-world.mdx: add getStreamChunks and getStreamInfo to
Streamer interface definition
- get-run.mdx: document WorkflowReadableStream and getTailIndex()
- Changeset: mention getStreamChunks alongside getStreamInfo
- world-vercel, world-local: wrap streamer with instrumentObject for
OTEL spans (world.streams.* prefix), matching storage instrumentation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fresh review after update

This update addresses all the critical issues from the previous round. Nice work cleaning this up.

Previously flagged — now resolved

  • Postgres getStreamInfo: Now uses COUNT(*) aggregate instead of fetching all rows into memory
  • Postgres getStreamChunks EOF mid-batch: Main query now filters eq(streams.eof, false), with a separate EOF check — no more risk of EOF silently dropping data rows
  • Transport NaN guard: Missing and malformed headers now both flow through the same else branch — replayFromStart = true + console.warn in both cases
  • world-local getStreamChunks perf: No longer deserializes every chunk — pre-cursor files use isEofChunk() (byte check only), and the loop stops early after collecting enough data
  • world-local getStreamInfo: Also uses isEofChunk() instead of full deserialization
  • Changeset: Now mentions both getStreamChunks() and getStreamInfo()
  • Docs: building-a-world.mdx includes the new Streamer methods with explanatory text. get-run.mdx has a WorkflowReadableStream section with getTailIndex() reference.
  • E2e tests: Added tailIndex returns -1 test using a nonexistent namespace. Added NaN header unit test.
  • OTEL: Both world-vercel and world-local now wrap the streamer with instrumentObject('world.streams', ...)
  • Redundant export: StreamInfoResponse added to the explicit export list in world/src/index.ts

Minor remaining items (non-blocking)

  1. world-local file-listing boilerplate still duplicated — the ~20-line block that lists files by extension, builds the fileExtMap, and filters by stream name is copy-pasted between getStreamChunks (lines 266-286) and getStreamInfo (lines 367-389). Extracting a listChunkFilesForStream(name) helper would reduce maintenance surface.

  2. world-postgres streamer not instrumentedworld-vercel and world-local both wrap their streamer with instrumentObject('world.streams', ...), giving the new methods automatic OTEL spans. world-postgres spreads the streamer directly without instrumentation (packages/world-postgres/src/index.ts:49). Pre-existing inconsistency, but worth noting since this PR adds new methods that would benefit from tracing.

  3. No unit tests for world-local or world-postgres implementations of getStreamChunks/getStreamInfo. The cursor pagination logic (invalid cursors, boundary conditions, EOF detection) is only tested via e2e tests that are skipped in local dev. Consider adding unit tests in a follow-up.

dataCount++;
}

return { tailIndex: dataCount - 1, done: streamDone };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: The file-listing + extension-mapping block (~20 lines) is duplicated between getStreamChunks (lines 266-286) and getStreamInfo (lines 367-389). A shared listChunkFilesForStream(name) helper returning { chunkFiles, fileExtMap } would keep these two methods DRY.

@ghostghost mentioned this pull request Mar 23, 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.

3 participants

@VaguelySerious@pranaygp@karthikscale3
, '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('^' + ".*" + ' [world] Add stream pagination and metadata endpoints by VaguelySerious · Pull Request #1470 · vercel/workflow · GitHub
Skip to content

[world] Add stream pagination and metadata endpoints - #1470

Merged
VaguelySerious merged 32 commits into
mainfrom
peter/stream-metadata-and-pages
Mar 24, 2026
Merged

[world] Add stream pagination and metadata endpoints#1470
VaguelySerious merged 32 commits into
mainfrom
peter/stream-metadata-and-pages

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

No description provided.

VaguelySeriousand others added 13 commits March 19, 2026 17:44
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Negative startIndex values (e.g. -3) resolve to n chunks before the
known end of the stream. All world implementations (local, postgres,
vercel) support this. Includes unit tests, e2e test, and doc updates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Replaces three separate tests (no startIndex, positive, negative) with a
single describe block that iterates over startIndex cases. Fixes the
negative startIndex test by waiting for workflow completion before
connecting the reader — the backend resolves negative indices at
connection time using knownChunkCount, which is 0 if the stream hasn't
been fully written yet.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Replaces three separate tests (no startIndex, positive, negative) with a
single describe block that iterates over startIndex cases. Fixes the
negative startIndex test by waiting for workflow completion before
connecting the reader — the backend resolves negative indices at
connection time using knownChunkCount, which is 0 if the stream hasn't
been fully written yet.
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
- Clarify that negative startIndex is for custom consumers, not WorkflowChatTransport
- Add live-stream caveat for negative startIndex resolution timing
- Add pagination limitation callout for live streams
- Use explicit typeof guards for negative startIndex checks (world-local, world-postgres)
- Add cost comment for EOF marker disk read in world-local
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel

vercelBot commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Mar 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a82fbe7

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

This PR includes changesets to release 20 packages
NameType
@workflow/worldPatch
@workflow/world-localPatch
@workflow/world-vercelPatch
@workflow/world-postgresPatch
@workflow/corePatch
@workflow/aiPatch
@workflow/cliPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
workflowPatch
@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 Mar 20, 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🥇 Express0.043s (-5.4% 🟢)1.006s (~)0.962s101.00x
💻 LocalNitro0.044s (+14.4% 🔺)1.005s (~)0.962s101.00x
💻 LocalNext.js (Turbopack)0.049s1.005s0.956s101.13x
🌐 RedisNext.js (Turbopack)0.055s1.005s0.950s101.26x
🐘 PostgresExpress0.062s (+10.8% 🔺)1.011s (~)0.949s101.42x
🐘 PostgresNitro0.064s (-7.9% 🟢)1.011s (~)0.947s101.47x
🐘 PostgresNext.js (Turbopack)0.065s1.012s0.947s101.50x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro0.458s (-21.6% 🟢)2.677s (+25.7% 🔺)2.219s101.00x
▲ VercelNext.js (Turbopack)0.542s (-17.9% 🟢)2.725s (+14.0% 🔺)2.183s101.18x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.123s2.006s0.883s101.00x
🌐 RedisNext.js (Turbopack)1.125s2.006s0.882s101.00x
💻 LocalExpress1.128s (~)2.006s (~)0.878s101.00x
💻 LocalNitro1.131s (+3.5%)2.006s (~)0.875s101.01x
🐘 PostgresNext.js (Turbopack)1.142s2.013s0.871s101.02x
🐘 PostgresNitro1.149s (-1.2%)2.012s (~)0.863s101.02x
🐘 PostgresExpress1.162s (+0.7%)2.012s (~)0.851s101.03x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.139s (+2.5%)4.147s (+23.1% 🔺)2.008s101.00x
▲ VercelNext.js (Turbopack)2.152s (+4.3%)4.007s (+9.3% 🔺)1.855s101.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)10.776s11.022s0.246s31.00x
💻 LocalNext.js (Turbopack)10.792s11.024s0.232s31.00x
🐘 PostgresExpress10.871s (-0.6%)11.020s (~)0.148s31.01x
🐘 PostgresNext.js (Turbopack)10.875s11.024s0.149s31.01x
💻 LocalExpress10.906s (-0.5%)11.023s (~)0.118s31.01x
💻 LocalNitro10.923s (+2.9%)11.023s (~)0.100s31.01x
🐘 PostgresNitro10.938s (-1.8%)11.026s (-8.5% 🟢)0.088s31.02x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)17.601s (+2.3%)19.403s (+1.9%)1.803s21.00x
▲ VercelNitro17.930s (~)20.036s (+2.1%)2.106s21.02x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)14.213s15.028s0.815s41.00x
🐘 PostgresNext.js (Turbopack)14.424s15.026s0.602s41.01x
💻 LocalNext.js (Turbopack)14.540s15.029s0.489s41.02x
🐘 PostgresExpress14.580s (-1.6%)15.024s (~)0.444s41.03x
🐘 PostgresNitro14.643s (-1.7%)15.033s (~)0.390s41.03x
💻 LocalExpress14.944s (-0.6%)15.029s (-4.8%)0.084s41.05x
💻 LocalNitro14.984s (+5.4% 🔺)15.033s (~)0.049s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro34.179s (+4.6%)35.468s (+4.2%)1.289s21.00x
▲ VercelNext.js (Turbopack)35.203s (+4.7%)37.010s (+6.3% 🔺)1.807s21.03x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)13.441s14.026s0.585s71.00x
🐘 PostgresNext.js (Turbopack)13.969s14.167s0.198s71.04x
🐘 PostgresExpress13.969s (-3.2%)14.452s (-3.9%)0.482s71.04x
🐘 PostgresNitro14.112s (-5.0% 🟢)14.741s (-3.1%)0.629s71.05x
💻 LocalNext.js (Turbopack)16.013s16.362s0.348s61.19x
💻 LocalExpress16.580s (-2.7%)17.030s (-1.9%)0.450s61.23x
💻 LocalNitro16.653s (+11.9% 🔺)17.031s (+13.3% 🔺)0.378s61.24x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro58.422s (-5.3% 🟢)61.010s (-3.0%)2.587s21.00x
▲ VercelNext.js (Turbopack)63.237s (+3.3%)64.613s (+3.3%)1.376s21.08x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)1.235s2.010s0.775s151.00x
🐘 PostgresNitro1.257s (-3.4%)2.012s (~)0.756s151.02x
🐘 PostgresExpress1.261s (-1.7%)2.012s (~)0.751s151.02x
🌐 RedisNext.js (Turbopack)1.321s2.007s0.685s151.07x
💻 LocalExpress1.527s (~)2.005s (~)0.478s151.24x
💻 LocalNitro1.527s (+4.8%)2.005s (~)0.478s151.24x
💻 LocalNext.js (Turbopack)1.572s2.006s0.434s151.27x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.597s (+2.8%)4.602s (+19.8% 🔺)2.005s71.00x
▲ VercelNext.js (Turbopack)2.630s (+1.5%)4.054s (-1.1%)1.423s81.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express2.354s (-3.6%)3.010s (~)0.657s101.00x
🐘 PostgresNitro2.354s (-5.6% 🟢)3.010s (~)0.656s101.00x
🐘 PostgresNext.js (Turbopack)2.412s3.010s0.599s101.02x
🌐 RedisNext.js (Turbopack)2.599s3.008s0.409s101.10x
💻 LocalNext.js (Turbopack)2.975s3.565s0.590s91.26x
💻 LocalNitro3.009s (+19.1% 🔺)3.760s (+25.0% 🔺)0.751s81.28x
💻 LocalExpress3.030s (-2.3%)3.760s (-6.2% 🟢)0.730s81.29x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.782s (+6.8% 🔺)5.013s (+36.6% 🔺)2.230s61.00x
▲ VercelNext.js (Turbopack)3.142s (+7.0% 🔺)4.966s (+15.8% 🔺)1.824s71.13x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro3.464s (-5.5% 🟢)4.015s (~)0.551s81.00x
🐘 PostgresExpress3.475s (-2.7%)4.014s (~)0.539s81.00x
🐘 PostgresNext.js (Turbopack)3.663s4.010s0.347s81.06x
🌐 RedisNext.js (Turbopack)4.261s5.011s0.750s61.23x
💻 LocalNext.js (Turbopack)7.418s8.016s0.597s42.14x
💻 LocalExpress8.564s (-1.7%)9.021s (-2.7%)0.457s42.47x
💻 LocalNitro9.200s (+37.7% 🔺)9.772s (+39.3% 🔺)0.571s42.66x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.261s (-3.7%)5.062s (+5.6% 🔺)1.801s71.00x
▲ VercelNext.js (Turbopack)3.526s (-15.3% 🟢)6.284s (+13.1% 🔺)2.758s51.08x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)1.219s2.009s0.790s151.00x
🐘 PostgresNitro1.252s (-3.0%)2.009s (~)0.757s151.03x
🐘 PostgresExpress1.255s (-2.8%)2.009s (~)0.754s151.03x
🌐 RedisNext.js (Turbopack)1.305s2.006s0.701s151.07x
💻 LocalNext.js (Turbopack)1.508s2.005s0.497s151.24x
💻 LocalNitro1.573s (+7.4% 🔺)2.007s (~)0.434s151.29x
💻 LocalExpress1.598s (+3.5%)2.072s (+3.3%)0.474s151.31x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.243s (-13.3% 🟢)3.995s (+5.7% 🔺)1.752s81.00x
▲ VercelNitro2.260s (-2.0%)4.048s (+7.7% 🔺)1.787s81.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.334s (-6.4% 🟢)3.011s (~)0.677s101.00x
🐘 PostgresExpress2.339s (-4.7%)3.010s (~)0.672s101.00x
🐘 PostgresNext.js (Turbopack)2.402s3.010s0.609s101.03x
🌐 RedisNext.js (Turbopack)2.544s3.008s0.464s101.09x
💻 LocalNext.js (Turbopack)2.868s3.453s0.585s91.23x
💻 LocalExpress2.992s (-5.7% 🟢)3.762s (-6.2% 🟢)0.770s81.28x
💻 LocalNitro3.086s (+7.6% 🔺)3.760s (+21.0% 🔺)0.674s81.32x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.905s (+1.0%)5.039s (+17.1% 🔺)2.134s71.00x
▲ VercelNext.js (Turbopack)3.300s (+10.1% 🔺)4.771s (+11.2% 🔺)1.471s71.14x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro3.469s (-4.9%)4.013s (~)0.544s81.00x
🐘 PostgresExpress3.473s (-3.4%)4.014s (~)0.541s81.00x
🐘 PostgresNext.js (Turbopack)3.670s4.012s0.342s81.06x
🌐 RedisNext.js (Turbopack)4.275s4.868s0.593s71.23x
💻 LocalExpress8.786s (-4.0%)9.022s (-10.0% 🟢)0.237s42.53x
💻 LocalNext.js (Turbopack)8.883s9.772s0.889s42.56x
💻 LocalNitro9.326s (+26.1% 🔺)10.024s (+25.0% 🔺)0.698s32.69x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.294s (+6.2% 🔺)5.002s (+6.1% 🔺)1.707s61.00x
▲ VercelNext.js (Turbopack)4.267s (+19.0% 🔺)6.135s (+24.6% 🔺)1.869s51.30x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)0.699s1.005s0.305s601.00x
🐘 PostgresNext.js (Turbopack)0.769s1.007s0.238s601.10x
🐘 PostgresExpress0.801s (-12.5% 🟢)1.007s (-6.7% 🟢)0.206s601.15x
🐘 PostgresNitro0.829s (-14.4% 🟢)1.007s (-18.5% 🟢)0.178s601.19x
💻 LocalNext.js (Turbopack)0.841s1.021s0.180s591.20x
💻 LocalExpress0.977s (-3.4%)1.076s (-30.3% 🟢)0.098s561.40x
💻 LocalNitro1.015s (+50.6% 🔺)1.628s (+62.1% 🔺)0.613s371.45x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro10.274s (+2.1%)12.099s (+5.9% 🔺)1.825s51.00x
▲ VercelNext.js (Turbopack)10.675s (+7.4% 🔺)12.483s (+1.0%)1.808s51.04x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.674s2.006s0.332s451.00x
🐘 PostgresNext.js (Turbopack)1.861s2.030s0.169s451.11x
🐘 PostgresExpress1.920s (-13.7% 🟢)2.077s (-31.0% 🟢)0.157s441.15x
🐘 PostgresNitro1.957s (-15.4% 🟢)2.176s (-27.8% 🟢)0.219s421.17x
💻 LocalNext.js (Turbopack)2.613s3.007s0.395s301.56x
💻 LocalExpress2.984s (-1.6%)3.341s (-9.4% 🟢)0.357s271.78x
💻 LocalNitro3.007s (+37.1% 🔺)3.547s (+18.0% 🔺)0.541s261.80x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro32.892s (+9.6% 🔺)34.652s (+8.7% 🔺)1.761s31.00x
▲ VercelNext.js (Turbopack)34.160s (+5.3% 🔺)51.070s (+51.8% 🔺)16.910s21.04x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)3.329s4.009s0.680s301.00x
🐘 PostgresNext.js (Turbopack)3.777s4.011s0.234s301.13x
🐘 PostgresExpress3.894s (-13.3% 🟢)4.184s (-16.6% 🟢)0.289s291.17x
🐘 PostgresNitro3.982s (-16.9% 🟢)4.404s (-13.6% 🟢)0.422s281.20x
💻 LocalNext.js (Turbopack)8.432s9.017s0.585s142.53x
💻 LocalExpress9.097s (+3.3%)9.555s (+4.3%)0.459s132.73x
💻 LocalNitro9.155s (+26.4% 🔺)9.864s (+23.1% 🔺)0.710s132.75x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro87.838s (+1.9%)104.355s (+19.6% 🔺)16.517s21.00x
▲ VercelNext.js (Turbopack)88.131s (+2.3%)90.390s (+2.2%)2.259s21.00x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)0.258s1.008s0.750s601.00x
🐘 PostgresNitro0.270s (-14.9% 🟢)1.008s (~)0.738s601.05x
🐘 PostgresExpress0.270s (-8.7% 🟢)1.008s (~)0.738s601.05x
🌐 RedisNext.js (Turbopack)0.365s1.004s0.640s601.42x
💻 LocalNext.js (Turbopack)0.551s1.004s0.453s602.14x
💻 LocalExpress0.595s (+4.7%)1.004s (~)0.410s602.31x
💻 LocalNitro0.598s (+6.7% 🔺)1.022s (+1.7%)0.423s592.32x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro1.834s (+9.2% 🔺)3.825s (+15.9% 🔺)1.992s161.00x
▲ VercelNext.js (Turbopack)2.568s (+35.7% 🔺)9.530s (+154.0% 🔺)6.962s121.40x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)0.481s1.019s0.538s891.00x
🐘 PostgresExpress0.493s (-10.0% 🟢)1.008s (~)0.515s901.03x
🐘 PostgresNitro0.498s (-14.7% 🟢)1.008s (~)0.511s901.04x
🌐 RedisNext.js (Turbopack)1.207s2.006s0.799s452.51x
💻 LocalNitro2.505s (+6.9% 🔺)3.009s (~)0.503s305.21x
💻 LocalNext.js (Turbopack)2.539s3.009s0.470s305.28x
💻 LocalExpress2.567s (+4.7%)3.009s (~)0.442s305.34x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.344s (+11.3% 🔺)5.095s (+14.5% 🔺)1.751s181.00x
▲ VercelNext.js (Turbopack)4.913s (+51.5% 🔺)6.649s (+40.7% 🔺)1.736s141.47x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)0.760s1.008s0.247s1201.00x
🐘 PostgresNitro0.767s (-20.7% 🟢)1.008s (-27.9% 🟢)0.241s1201.01x
🐘 PostgresExpress0.774s (-15.8% 🟢)1.008s (-18.7% 🟢)0.234s1201.02x
🌐 RedisNext.js (Turbopack)2.757s3.084s0.327s393.63x
💻 LocalNext.js (Turbopack)10.553s11.027s0.473s1113.88x
💻 LocalExpress11.228s (+1.9%)11.937s (+2.3%)0.708s1114.76x
💻 LocalNitro11.257s (+13.4% 🔺)12.029s (+16.1% 🔺)0.771s1014.80x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)7.133s (-77.8% 🟢)8.902s (-73.6% 🟢)1.769s141.00x
▲ VercelNitro7.220s (~)8.773s (+1.1%)1.554s141.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.175s1.001s0.011s1.017s0.842s101.00x
🌐 RedisNext.js (Turbopack)0.183s1.000s0.001s1.008s0.825s101.05x
🐘 PostgresExpress0.195s (-12.5% 🟢)0.996s (~)0.002s (+15.4% 🔺)1.011s (~)0.816s101.11x
🐘 PostgresNext.js (Turbopack)0.199s1.000s0.004s1.014s0.814s101.14x
🐘 PostgresNitro0.204s (-15.0% 🟢)0.995s (~)0.001s (-27.8% 🟢)1.011s (~)0.807s101.17x
💻 LocalNitro0.208s (+49.5% 🔺)1.003s (~)0.012s (+19.6% 🔺)1.018s (~)0.810s101.19x
💻 LocalExpress0.211s (+2.1%)1.003s (~)0.011s (-5.9% 🟢)1.017s (~)0.806s101.20x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro1.779s (+15.1% 🔺)2.497s (-4.5%)0.810s (+76.9% 🔺)3.996s (+11.2% 🔺)2.217s101.00x
▲ VercelNext.js (Turbopack)1.926s (+17.8% 🔺)2.945s (+0.9%)0.662s (+65.8% 🔺)4.322s (+11.6% 🔺)2.396s101.08x
▲ VercelExpress⚠️missing-----

🔍 Observability: Nitro | Next.js (Turbopack)

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)0.487s1.000s0.003s1.011s0.524s601.00x
🐘 PostgresExpress0.593s (-15.7% 🟢)1.003s (~)0.013s (+103.1% 🔺)1.033s (~)0.440s591.22x
🐘 PostgresNitro0.602s (-17.0% 🟢)1.004s (~)0.004s (-37.7% 🟢)1.023s (-0.7%)0.421s591.24x
🐘 PostgresNext.js (Turbopack)0.611s1.007s0.004s1.025s0.414s591.25x
💻 LocalNext.js (Turbopack)0.666s1.008s0.009s1.023s0.357s591.37x
💻 LocalNitro0.724s (+27.6% 🔺)1.009s (~)0.010s (+4.6%)1.024s (~)0.299s591.49x
💻 LocalExpress0.734s (+2.3%)1.009s (~)0.009s (-5.3% 🟢)1.022s (~)0.288s591.51x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)5.132s (+12.4% 🔺)6.256s (+11.9% 🔺)0.242s (+7.6% 🔺)13.240s (+104.9% 🔺)8.108s51.00x
▲ VercelNitro5.363s (~)6.372s (+3.7%)0.403s (-35.9% 🟢)7.632s (+1.2%)2.269s81.05x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)0.897s1.034s0.000s1.039s0.141s581.00x
🐘 PostgresNext.js (Turbopack)0.948s1.132s0.000s1.141s0.193s531.06x
🐘 PostgresExpress0.956s (-14.8% 🟢)1.271s (-28.6% 🟢)0.000s (-63.8% 🟢)1.286s (-28.9% 🟢)0.331s471.06x
🐘 PostgresNitro0.965s (-17.5% 🟢)1.298s (-35.0% 🟢)0.000s (-2.2%)1.315s (-35.0% 🟢)0.350s461.08x
💻 LocalNitro1.224s (+8.0% 🔺)2.020s (~)0.000s (+50.0% 🔺)2.023s (~)0.799s301.36x
💻 LocalExpress1.246s (+3.6%)2.020s (~)0.000s (-42.9% 🟢)2.023s (~)0.777s301.39x
💻 LocalNext.js (Turbopack)1.270s2.020s0.000s2.024s0.754s301.42x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.626s (+3.0%)4.889s (+5.1% 🔺)0.004s (+Infinity% 🔺)5.632s (+8.2% 🔺)2.006s111.00x
▲ VercelNitro3.756s (+24.1% 🔺)4.924s (+25.5% 🔺)0.002s (+1045.5% 🔺)5.654s (+27.8% 🔺)1.898s111.04x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

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

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.603s2.002s0.000s2.007s0.403s301.00x
🐘 PostgresNext.js (Turbopack)1.831s2.106s0.000s2.115s0.284s291.14x
🐘 PostgresExpress1.840s (-10.6% 🟢)2.258s (-8.8% 🟢)0.000s (NaN%)2.271s (-9.1% 🟢)0.431s271.15x
🐘 PostgresNitro1.853s (-18.2% 🟢)2.223s (-24.7% 🟢)0.000s (-100.0% 🟢)2.235s (-24.7% 🟢)0.382s271.16x
💻 LocalNitro3.585s (+3.6%)4.300s (+6.7% 🔺)0.000s (-61.5% 🟢)4.304s (+6.7% 🔺)0.720s152.24x
💻 LocalNext.js (Turbopack)3.632s4.230s0.001s4.235s0.604s152.27x
💻 LocalExpress3.670s (+8.3% 🔺)4.032s (-1.6%)0.000s (+250.0% 🔺)4.036s (-1.6%)0.366s152.29x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro4.200s (+21.3% 🔺)5.030s (+20.0% 🔺)0.000s (+Infinity% 🔺)5.900s (+24.3% 🔺)1.700s111.00x
▲ VercelNext.js (Turbopack)5.104s (+14.0% 🔺)6.017s (+11.1% 🔺)0.000s (+10.0% 🔺)6.656s (+11.9% 🔺)1.552s101.22x
▲ VercelExpress⚠️missing-----

🔍 Observability: Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)15/21
🐘 PostgresNext.js (Turbopack)13/21
▲ VercelNitro16/21
Fastest World by Framework

Winner determined by most benchmark wins

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

📋 View full workflow run


Some benchmark jobs failed:

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

Check the workflow run for details.

@github-actions

github-actionsBot commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production813067880
✅ 💻 Local Development7820178960
✅ 📦 Local Production7820178960
✅ 🐘 Local Postgres7820178960
✅ 🪟 Windows720880
❌ 🌍 Community Worlds1185630204
✅ 📋 Other198042240
Total3547566814284

❌ Failed Tests

🌍 Community Worlds (56 failed)

mongodb (3 failed):

  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KMEKZQ811N46GD5AK5RW1HD9
  • webhookWorkflow | wrun_01KMEKZZZNPHAFY93GWGMA33E4
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KMEM7DRXD1RYA1E11HJTJFPW

redis (2 failed):

  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KMEKZQ811N46GD5AK5RW1HD9
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KMEM7DRXD1RYA1E11HJTJFPW

turso (51 failed):

  • addTenWorkflow | wrun_01KMEKYFN28CV699JDRY8EVENQ
  • addTenWorkflow | wrun_01KMEKYFN28CV699JDRY8EVENQ
  • wellKnownAgentWorkflow (.well-known/agent) | wrun_01KMEKZV5Y7H35GS496CWE2PWY
  • should work with react rendering in step
  • promiseAllWorkflow | wrun_01KMEKYP9VT3R0SKBHBMF46WF1
  • promiseRaceWorkflow | wrun_01KMEKYV2NHKMK1PT16PHGC0TE
  • promiseAnyWorkflow | wrun_01KMEKYYRJ4XWER78HC3BC88RH
  • importedStepOnlyWorkflow | wrun_01KMEM07SDMHGNED3K1Z8822HA
  • hookWorkflow | wrun_01KMEKZB3KDGNSRDK1ZP1ZWCS2
  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KMEKZQ811N46GD5AK5RW1HD9
  • webhookWorkflow | wrun_01KMEKZZZNPHAFY93GWGMA33E4
  • sleepingWorkflow | wrun_01KMEM07AP1ZHHP0B1Y28XPTPF
  • parallelSleepWorkflow | wrun_01KMEM0M62BA11GZH9N30PA01V
  • nullByteWorkflow | wrun_01KMEM0RNC851K91RM7QKG9J0A
  • workflowAndStepMetadataWorkflow | wrun_01KMEM0TS1NDEE94P4ZK643V6C
  • fetchWorkflow | wrun_01KMEM3MRRD40CZ6RZHVTBN0QE
  • promiseRaceStressTestWorkflow | wrun_01KMEM3RJJYY05KEDAWHC7YZCF
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion | wrun_01KMEM6SVSD36537C7RMSNZ7K2
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KMEM7DRXD1RYA1E11HJTJFPW
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running | wrun_01KMEM84D71052EEDK1NTW09QR
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars) | wrun_01KMEM8QRVX2ZN0ZY8KSFXYRFW
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument | wrun_01KMEM90F9YAZG8G7CZZR9KQWQ
  • closureVariableWorkflow - nested step functions with closure variables | wrun_01KMEM96EMKN1H5C673QHQM0RJ
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step | wrun_01KMEM9A0HFZKBNE055EZ9P5H4
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly | wrun_01KMEM9RJS5TNK0QPDKP86M45K
  • Calculator.calculate - static workflow method using static step methods from another class | wrun_01KMEM9XZANZTCTYJ5TEE55MRZ
  • AllInOneService.processNumber - static workflow method using sibling static step methods | wrun_01KMEMA4B58C28JE56JM1YFVQV
  • ChainableService.processWithThis - static step methods using this to reference the class | wrun_01KMEMAATKT6G9NX72T548BXF1
  • thisSerializationWorkflow - step function invoked with .call() and .apply() | wrun_01KMEMAH46HQ91CQS2CS66CDC6
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE | wrun_01KMEMAQHGV2M8JHE762DZMKC6
  • instanceMethodStepWorkflow - instance methods with "use step" directive | wrun_01KMEMAYB4Y9GXM4F81Y6CNJ6B
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context | wrun_01KMEMB8XA6D9RX061RHB81PKZ
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument | wrun_01KMEMBGE2CX800GQHW1H2HAJF
  • cancelRun - cancelling a running workflow | wrun_01KMEMBQWVXW1JB5P0HV743Q4P
  • cancelRun via CLI - cancelling a running workflow | wrun_01KMEMC0XTPK7JCE38TSAYE9N8
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep | wrun_01KMEMCCDFRYCKTFTV8TJ03YRG
  • sleepInLoopWorkflow - sleep inside loop with steps actually delays each iteration | wrun_01KMEMD0561BCM0VA669B3A6CS
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control) | wrun_01KMEMDAE0C841QKT27361H8BN

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro7307
✅ example7307
✅ express7307
✅ fastify7307
✅ hono7307
✅ nextjs-turbopack7802
✅ nextjs-webpack7802
✅ nitro7307
✅ nuxt7307
✅ sveltekit7307
✅ vite7307
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable66014
✅ express-stable66014
✅ fastify-stable66014
✅ hono-stable66014
✅ nextjs-turbopack-canary55025
✅ nextjs-turbopack-stable7208
✅ nextjs-webpack-canary55025
✅ nextjs-webpack-stable7208
✅ nitro-stable66014
✅ nuxt-stable66014
✅ sveltekit-stable66014
✅ vite-stable66014
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable66014
✅ express-stable66014
✅ fastify-stable66014
✅ hono-stable66014
✅ nextjs-turbopack-canary55025
✅ nextjs-turbopack-stable7208
✅ nextjs-webpack-canary55025
✅ nextjs-webpack-stable7208
✅ nitro-stable66014
✅ nuxt-stable66014
✅ sveltekit-stable66014
✅ vite-stable66014
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable66014
✅ express-stable66014
✅ fastify-stable66014
✅ hono-stable66014
✅ nextjs-turbopack-canary55025
✅ nextjs-turbopack-stable7208
✅ nextjs-webpack-canary55025
✅ nextjs-webpack-stable7208
✅ nitro-stable66014
✅ nuxt-stable66014
✅ sveltekit-stable66014
✅ vite-stable66014
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack7208
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev302
❌ mongodb5238
✅ redis-dev302
❌ redis5328
✅ turso-dev302
❌ turso4518
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable66014
✅ e2e-local-postgres-nest-stable66014
✅ e2e-local-prod-nest-stable66014

📋 View full workflow run

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Comment threadpackages/core/e2e/e2e.test.ts Outdated

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Here's my review of PR #1470:

Summary

This is a feature PR by @VaguelySerious that adds two new Streamer interface methods (getStreamChunks and getStreamInfo) across all world implementations, extends Run.getReadable() with a getTailIndex() helper, and adds initialStartIndex support to WorkflowChatTransport for resuming streams from the end.

No Blockers

CI is in excellent shape. The code changes are well-structured, well-documented, and backward-compatible.

CI Status

Passing:

  • All Vercel Production E2E tests: 11/11 frameworks pass (802 passed, 0 failed)
  • All Local Dev E2E tests: 12/12 pass (782 passed)
  • All Local Prod E2E tests: 12/12 pass
  • All Local Postgres E2E tests: 12/12 pass
  • Windows E2E tests pass
  • Unit tests pass (ubuntu + windows)
  • Vitest plugin tests pass
  • All Vercel deployments succeed (except workbench-nestjs-workflow - discussed below)
  • Benchmarks pass for local/postgres; no performance regressions
  • Docs code samples and links pass

Failing (non-blocking / pre-existing):

  • workbench-nestjs-workflow Vercel deployment fails - this is likely a pre-existing issue unrelated to streaming changes (the nest workbench has no streaming code changes in this PR)
  • Benchmark Vercel (nitro-v3) fails at ~30min - likely infra timeout, not PR-related
  • Community World (Turso) - 51 failures, pre-existing community world issue (same pattern as other PRs)
  • Community World (MongoDB/Redis) - 3/2 failures on hook-related tests, pre-existing

Code Review

1. Streamer interface additions (packages/world/src/interfaces.ts) - Clean

Two new methods added to the Streamer interface:

  • getStreamChunks(name, runId, options?) - cursor-based pagination for stream chunks
  • getStreamInfo(name, runId) - lightweight metadata (tailIndex, done flag)

New types (StreamChunk, StreamChunksResponse, GetChunksOptions, StreamInfoResponse) are well-documented in packages/world/src/shared.ts. The interface is additive and non-breaking.

2. world-vercel implementation (packages/world-vercel/src/streamer.ts) - Clean

Uses makeRequest + Zod schemas to call two new server endpoints:

  • GET /v2/runs/:runId/streams/:name/chunks?limit=&cursor=
  • GET /v2/runs/:runId/streams/:name/info

The StreamChunksResponseSchema correctly expects z.instanceof(Uint8Array) since CBOR handles binary natively. Clean and minimal.

3. world-local implementation (packages/world-local/src/streamer.ts) - Works but verbose

The implementation reads chunk files from disk, builds a file extension map, sorts by ULID, and paginates. One observation:

  • getStreamInfo reads every chunk file to count data chunks and detect EOF. For large streams this could be expensive. The getStreamChunks implementation has the same pattern. Both could potentially be optimized with a count-based approach, but for local dev this is fine.
  • The cursor encoding (base64-encoded JSON { i: index }) is a reasonable pattern.

4. world-postgres implementation (packages/world-postgres/src/streamer.ts) - Solid

Uses Drizzle ORM with proper query patterns:

  • Fetches limit + 1 rows to detect hasMore efficiently
  • Correctly handles the EOF row being mixed into results
  • Has a follow-up EOF check query when !streamDone && !hasMore to correctly report done status
  • Cursor encodes both chunk ID and running index ({ c: chunkId, i: baseIndex })

One minor cleanup: void fn() on line 407 (changing cleanups.forEach((fn) => fn()) to cleanups.forEach((fn) => void fn())) is a good lint fix to suppress floating promise warnings.

5. WorkflowReadableStream type and getTailIndex() (packages/core/src/runtime/run.ts) - Clean

Uses Object.assign to add getTailIndex() to the ReadableStream. This is a clean pattern that avoids subclassing. The type is properly exported through packages/workflow/src/api.ts.

6. WorkflowChatTransport changes (packages/ai/src/workflow-chat-transport.ts) - Well thought out

The negative initialStartIndex feature is carefully designed:

  • First request uses the explicit (potentially negative) startIndex
  • On retry, resolves to an absolute position using x-workflow-stream-tail-index header
  • Falls back to startIndex=0 with a warning if the header is missing
  • useExplicitStartIndex flag ensures the negative value is only used once, then subsequent retries use the running chunkIndex

The replayFromStart flag handles the edge case where the header is absent gracefully.

7. Tests - Good coverage

  • Two new e2e tests for getTailIndex and getStreamChunks pagination (skipped on local deployments since they require server-side endpoints)
  • Two new unit tests for WorkflowChatTransport covering negative initialStartIndex with and without the tail-index header

8. Docs - Thorough

Updated resumable-streams.mdx with:

  • New "Resuming from the end of the stream" section
  • Updated stream reconnection endpoint to include getTailIndex() and the x-workflow-stream-tail-index header
  • Callout about header requirement for negative initialStartIndex
  • API reference docs updated

9. Changeset - Present and correct

Includes @workflow/world, world-local, world-vercel, world-postgres, core, and ai as patch changes.

Minor Notes

  • The PR description is empty ("No description provided"). Worth adding a summary for posterity.
  • The getStreamChunks method uses _runId (unused) in local/postgres implementations since the stream name is already unique there. The vercel implementation passes it to the server endpoint. This is fine - the interface is designed for the vercel world's needs.

Verdict

No blockers or regressions. This is a well-implemented, additive feature with good test coverage and documentation. The only CI failures are pre-existing (nest deployment, community world Turso/MongoDB/Redis, nitro benchmark timeout). Ready to merge once it gets a review approval.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall this is a well-designed, additive feature. The negative initialStartIndex logic in the transport is carefully thought through, the interface additions are clean, and the docs/tests cover the new behavior well. A few issues worth addressing below.

GetChunksOptions,
StreamChunk,
StreamChunksResponse,
} from './shared.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The explicit export type { GetChunksOptions, StreamChunk, StreamChunksResponse } is redundant with the export type * from './shared.js' on line 34 — and it's also missing StreamInfoResponse. Since line 34 already re-exports all types from shared.js, you can just remove this block entirely (lines 39-43).

.select({ chunkId: streams.chunkId })
.from(streams)
.where(and(eq(streams.streamId, name), eq(streams.eof, false)))
.orderBy(asc(streams.chunkId));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

getStreamInfo fetches all data rows into memory just to count them. For streams with thousands of chunks this is wasteful — a SELECT COUNT(*) FROM streams WHERE streamId = $1 AND eof = false would be much cheaper. Consider using Drizzle's count() aggregate or a raw SQL count instead of selecting all chunk IDs.

hasMore,
done: streamDone,
};
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The file-listing + extension-mapping logic (~30 lines) is duplicated verbatim between getStreamChunks and getStreamInfo. Consider extracting a helper like listStreamDataFiles(name) that returns { dataFiles, fileExtMap, streamDone } to keep this DRY.

Comment threadpackages/world-local/src/streamer.ts Outdated
break;
}
dataFiles.push(file);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

getStreamChunks reads and deserializes every chunk file from disk on every call (to separate data files from the EOF marker), regardless of the cursor position. For a stream with 10k chunks, page 2 still reads all 10k files.

Two possible improvements:

  1. Only read files starting from the cursor position (the ULID sort gives chronological order, so you can skip earlier files)
  2. At minimum, stop reading once you have enough files past the cursor — you only need startIndex + limit data files plus potentially the EOF marker

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Deeper review: testing, docs, observability, edge cases, and performance

Testing gaps

world-local and world-postgres have zero unit tests for the new getStreamChunks and getStreamInfo methods. The only coverage comes from two e2e tests that are skipIf(isLocalDeployment()), so local dev never exercises these paths. Cursor pagination logic (invalid cursors, out-of-range cursors, single-item pages), empty stream behavior (tailIndex = -1), and mid-flight streams (calling before workflow completes) are all untested.

The transport unit tests cover the happy path for negative initialStartIndex but miss: NaN/malformed x-workflow-stream-tail-index header values (the !Number.isNaN guard is untested), positive explicit startIndex override, boundary clamping when magnitude exceeds stream length, and multi-retry chains where the header varies across attempts.

Documentation gaps

  1. building-a-world.mdx still shows the old Streamer interface without getStreamChunks or getStreamInfo. Custom world implementers won't know these methods are required.
  2. No API reference for WorkflowReadableStream or getTailIndex() — the get-run.mdx page documents WorkflowReadableStreamOptions but not the new return type.
  3. Changeset mentions getStreamInfo but omits getStreamChunks.

Observability

Existing storage methods are wrapped with instrumentObject() for automatic OTEL spans (e.g. world.runs.get, world.events.create). Streamer methods — both existing and new — are not instrumented at all. No spans, no semantic conventions for stream names/indices, and no structured logging in any world implementation's streaming code. The only observability signal is the console.warn in WorkflowChatTransport for missing headers. This is consistent with the pre-existing pattern (streaming was never instrumented), but it means production debugging of pagination issues, tail-index mismatches, or reconnection failures will be blind.

Edge cases and performance (see inline comments below)

Several specific issues are called out inline.

break;
}
dataRows.push(row);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Edge case: EOF row appearing mid-batch. The query fetches limit + 1 rows ordered by chunkId without filtering out EOF rows. If the EOF row sorts between data rows (e.g. due to concurrent writes or clock skew), the loop breaks early on row.eof, and subsequent data rows in the result set are silently dropped.

Then hasMore = !streamDone && dataRows.length > limit evaluates to false (because streamDone = true), so the client sees done: true, hasMore: false even though there were more data rows in the fetch.

This is probably fine in practice since the EOF ULID is generated after all data chunks, but it's worth noting the implicit ordering assumption. Adding eq(streams.eof, false) to the main query and doing a separate EOF check (like the lookahead on line 250) would make this robust.

.select({ chunkId: streams.chunkId })
.from(streams)
.where(and(eq(streams.streamId, name), eq(streams.eof, false)))
.orderBy(asc(streams.chunkId));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Performance: O(N) memory to count rows. This SELECT chunkId FROM streams WHERE ... loads every non-EOF row into Node.js memory just to take .length. For a stream with 100k chunks, that's 100k objects allocated and immediately discarded.

Use a COUNT(*) aggregate instead:

const[{ count }]=awaitdrizzle.select({count: sql<number>`count(*)`}).from(streams).where(and(eq(streams.streamId,name),eq(streams.eof,false)));return{tailIndex: (count??0)-1,done: !!eofRow};

Comment threadpackages/world-local/src/streamer.ts Outdated
break;
}
dataFiles.push(file);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Performance: every page reads ALL chunk files.getStreamChunks deserializes every chunk file from disk (lines 286-296) to separate data files from the EOF marker, even for chunks that precede the cursor. Page 50 of a 10k-chunk stream still reads all 10k files.

getStreamInfo (line 372) has the exact same pattern — reads all files just to count them.

For local dev this is tolerable, but if these methods get called frequently (e.g. polling for tail index), the cost adds up. A lighter approach: since ULID sort gives chronological order, you could skip files before the cursor index and stop after collecting limit data files + EOF check.

const readable = run.getReadable();
const tailIndex = await readable.getTailIndex();

// outputStreamWorkflow writes 2 chunks to the default stream

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Testing gap: no empty-stream or mid-flight test. These tests only run after await run.returnValue (stream fully complete) and outputStreamWorkflow always writes exactly 2 chunks.

Missing scenarios:

  • Empty stream: What does getTailIndex() return when 0 chunks have been written? (should be -1 per the type docs)
  • Mid-flight stream: What does getTailIndex() / getStreamChunks() return while the workflow is still running and the stream isn't closed yet?
  • getStreamInfo directly: Only tested indirectly via getTailIndex() — no direct assertion on the done flag


warnSpy.mockRestore();
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Testing gap: malformed header values. The implementation has a !Number.isNaN(tailIndex) guard (transport line 362) but no test covers it. Consider adding a case where the header is "not-a-number"parseInt returns NaN, the guard should skip resolution, and the code should fall through without setting chunkIndex.

Also missing: a test for initialStartIndex: -1000 on a 5-chunk stream (should clamp to 0 via Math.max), and a test where a positive startIndex is passed via reconnectToStream options to override initialStartIndex.

const tailIndexHeader = res.headers.get('x-workflow-stream-tail-index');
if (tailIndexHeader !== null) {
const tailIndex = parseInt(tailIndexHeader, 10);
if (!Number.isNaN(tailIndex)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Edge case: NaN guard doesn't fall through cleanly. When parseInt returns NaN, the if (!Number.isNaN(tailIndex)) block is skipped, but replayFromStart is NOT set (that only happens in the else branch for tailIndexHeader === null). So on the next iteration, useExplicitStartIndex is false and replayFromStart is false, meaning the code falls through to chunkIndex — which is still 0 from initialization.

This accidentally works (retries from 0), but the intent is unclear. If the header is present but malformed, should it warn and set replayFromStart = true like the missing-header case? As-is, a malformed header silently degrades to replaying from 0 without the console warning.

GetChunksOptions,
StreamChunk,
StreamChunksResponse,
} from './shared.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Redundant + incomplete export. Line 34 already has export type * from './shared.js' which re-exports all types. This explicit block is redundant and also missing StreamInfoResponse. Just delete lines 39-43.

VaguelySeriousand others added 2 commits March 23, 2026 17:22
- world-postgres: use COUNT(*) in getStreamInfo instead of fetching all
rows; filter EOF rows in getStreamChunks query to prevent mid-batch
data loss from clock-skewed EOF rows
- world-local: skip files before cursor in getStreamChunks, read only
EOF byte in getStreamInfo and pre-cursor files
- WorkflowChatTransport: treat unparseable tail-index header same as
missing (set replayFromStart); add unit test for NaN header fallback
- e2e: add test for getTailIndex returning -1 on nonexistent stream
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- building-a-world.mdx: add getStreamChunks and getStreamInfo to
Streamer interface definition
- get-run.mdx: document WorkflowReadableStream and getTailIndex()
- Changeset: mention getStreamChunks alongside getStreamInfo
- world-vercel, world-local: wrap streamer with instrumentObject for
OTEL spans (world.streams.* prefix), matching storage instrumentation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fresh review after update

This update addresses all the critical issues from the previous round. Nice work cleaning this up.

Previously flagged — now resolved

  • Postgres getStreamInfo: Now uses COUNT(*) aggregate instead of fetching all rows into memory
  • Postgres getStreamChunks EOF mid-batch: Main query now filters eq(streams.eof, false), with a separate EOF check — no more risk of EOF silently dropping data rows
  • Transport NaN guard: Missing and malformed headers now both flow through the same else branch — replayFromStart = true + console.warn in both cases
  • world-local getStreamChunks perf: No longer deserializes every chunk — pre-cursor files use isEofChunk() (byte check only), and the loop stops early after collecting enough data
  • world-local getStreamInfo: Also uses isEofChunk() instead of full deserialization
  • Changeset: Now mentions both getStreamChunks() and getStreamInfo()
  • Docs: building-a-world.mdx includes the new Streamer methods with explanatory text. get-run.mdx has a WorkflowReadableStream section with getTailIndex() reference.
  • E2e tests: Added tailIndex returns -1 test using a nonexistent namespace. Added NaN header unit test.
  • OTEL: Both world-vercel and world-local now wrap the streamer with instrumentObject('world.streams', ...)
  • Redundant export: StreamInfoResponse added to the explicit export list in world/src/index.ts

Minor remaining items (non-blocking)

  1. world-local file-listing boilerplate still duplicated — the ~20-line block that lists files by extension, builds the fileExtMap, and filters by stream name is copy-pasted between getStreamChunks (lines 266-286) and getStreamInfo (lines 367-389). Extracting a listChunkFilesForStream(name) helper would reduce maintenance surface.

  2. world-postgres streamer not instrumentedworld-vercel and world-local both wrap their streamer with instrumentObject('world.streams', ...), giving the new methods automatic OTEL spans. world-postgres spreads the streamer directly without instrumentation (packages/world-postgres/src/index.ts:49). Pre-existing inconsistency, but worth noting since this PR adds new methods that would benefit from tracing.

  3. No unit tests for world-local or world-postgres implementations of getStreamChunks/getStreamInfo. The cursor pagination logic (invalid cursors, boundary conditions, EOF detection) is only tested via e2e tests that are skipped in local dev. Consider adding unit tests in a follow-up.

dataCount++;
}

return { tailIndex: dataCount - 1, done: streamDone };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: The file-listing + extension-mapping block (~20 lines) is duplicated between getStreamChunks (lines 266-286) and getStreamInfo (lines 367-389). A shared listChunkFilesForStream(name) helper returning { chunkFiles, fileExtMap } would keep these two methods DRY.

@ghostghost mentioned this pull request Mar 23, 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.

3 participants

@VaguelySerious@pranaygp@karthikscale3
, '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('^' + ".*" + ' [world] Add stream pagination and metadata endpoints by VaguelySerious · Pull Request #1470 · vercel/workflow · GitHub
Skip to content

[world] Add stream pagination and metadata endpoints - #1470

Merged
VaguelySerious merged 32 commits into
mainfrom
peter/stream-metadata-and-pages
Mar 24, 2026
Merged

[world] Add stream pagination and metadata endpoints#1470
VaguelySerious merged 32 commits into
mainfrom
peter/stream-metadata-and-pages

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

No description provided.

VaguelySeriousand others added 13 commits March 19, 2026 17:44
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Negative startIndex values (e.g. -3) resolve to n chunks before the
known end of the stream. All world implementations (local, postgres,
vercel) support this. Includes unit tests, e2e test, and doc updates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Replaces three separate tests (no startIndex, positive, negative) with a
single describe block that iterates over startIndex cases. Fixes the
negative startIndex test by waiting for workflow completion before
connecting the reader — the backend resolves negative indices at
connection time using knownChunkCount, which is 0 if the stream hasn't
been fully written yet.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Replaces three separate tests (no startIndex, positive, negative) with a
single describe block that iterates over startIndex cases. Fixes the
negative startIndex test by waiting for workflow completion before
connecting the reader — the backend resolves negative indices at
connection time using knownChunkCount, which is 0 if the stream hasn't
been fully written yet.
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
- Clarify that negative startIndex is for custom consumers, not WorkflowChatTransport
- Add live-stream caveat for negative startIndex resolution timing
- Add pagination limitation callout for live streams
- Use explicit typeof guards for negative startIndex checks (world-local, world-postgres)
- Add cost comment for EOF marker disk read in world-local
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel

vercelBot commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Mar 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a82fbe7

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

This PR includes changesets to release 20 packages
NameType
@workflow/worldPatch
@workflow/world-localPatch
@workflow/world-vercelPatch
@workflow/world-postgresPatch
@workflow/corePatch
@workflow/aiPatch
@workflow/cliPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
workflowPatch
@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 Mar 20, 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🥇 Express0.043s (-5.4% 🟢)1.006s (~)0.962s101.00x
💻 LocalNitro0.044s (+14.4% 🔺)1.005s (~)0.962s101.00x
💻 LocalNext.js (Turbopack)0.049s1.005s0.956s101.13x
🌐 RedisNext.js (Turbopack)0.055s1.005s0.950s101.26x
🐘 PostgresExpress0.062s (+10.8% 🔺)1.011s (~)0.949s101.42x
🐘 PostgresNitro0.064s (-7.9% 🟢)1.011s (~)0.947s101.47x
🐘 PostgresNext.js (Turbopack)0.065s1.012s0.947s101.50x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro0.458s (-21.6% 🟢)2.677s (+25.7% 🔺)2.219s101.00x
▲ VercelNext.js (Turbopack)0.542s (-17.9% 🟢)2.725s (+14.0% 🔺)2.183s101.18x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.123s2.006s0.883s101.00x
🌐 RedisNext.js (Turbopack)1.125s2.006s0.882s101.00x
💻 LocalExpress1.128s (~)2.006s (~)0.878s101.00x
💻 LocalNitro1.131s (+3.5%)2.006s (~)0.875s101.01x
🐘 PostgresNext.js (Turbopack)1.142s2.013s0.871s101.02x
🐘 PostgresNitro1.149s (-1.2%)2.012s (~)0.863s101.02x
🐘 PostgresExpress1.162s (+0.7%)2.012s (~)0.851s101.03x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.139s (+2.5%)4.147s (+23.1% 🔺)2.008s101.00x
▲ VercelNext.js (Turbopack)2.152s (+4.3%)4.007s (+9.3% 🔺)1.855s101.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)10.776s11.022s0.246s31.00x
💻 LocalNext.js (Turbopack)10.792s11.024s0.232s31.00x
🐘 PostgresExpress10.871s (-0.6%)11.020s (~)0.148s31.01x
🐘 PostgresNext.js (Turbopack)10.875s11.024s0.149s31.01x
💻 LocalExpress10.906s (-0.5%)11.023s (~)0.118s31.01x
💻 LocalNitro10.923s (+2.9%)11.023s (~)0.100s31.01x
🐘 PostgresNitro10.938s (-1.8%)11.026s (-8.5% 🟢)0.088s31.02x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)17.601s (+2.3%)19.403s (+1.9%)1.803s21.00x
▲ VercelNitro17.930s (~)20.036s (+2.1%)2.106s21.02x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)14.213s15.028s0.815s41.00x
🐘 PostgresNext.js (Turbopack)14.424s15.026s0.602s41.01x
💻 LocalNext.js (Turbopack)14.540s15.029s0.489s41.02x
🐘 PostgresExpress14.580s (-1.6%)15.024s (~)0.444s41.03x
🐘 PostgresNitro14.643s (-1.7%)15.033s (~)0.390s41.03x
💻 LocalExpress14.944s (-0.6%)15.029s (-4.8%)0.084s41.05x
💻 LocalNitro14.984s (+5.4% 🔺)15.033s (~)0.049s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro34.179s (+4.6%)35.468s (+4.2%)1.289s21.00x
▲ VercelNext.js (Turbopack)35.203s (+4.7%)37.010s (+6.3% 🔺)1.807s21.03x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)13.441s14.026s0.585s71.00x
🐘 PostgresNext.js (Turbopack)13.969s14.167s0.198s71.04x
🐘 PostgresExpress13.969s (-3.2%)14.452s (-3.9%)0.482s71.04x
🐘 PostgresNitro14.112s (-5.0% 🟢)14.741s (-3.1%)0.629s71.05x
💻 LocalNext.js (Turbopack)16.013s16.362s0.348s61.19x
💻 LocalExpress16.580s (-2.7%)17.030s (-1.9%)0.450s61.23x
💻 LocalNitro16.653s (+11.9% 🔺)17.031s (+13.3% 🔺)0.378s61.24x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro58.422s (-5.3% 🟢)61.010s (-3.0%)2.587s21.00x
▲ VercelNext.js (Turbopack)63.237s (+3.3%)64.613s (+3.3%)1.376s21.08x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)1.235s2.010s0.775s151.00x
🐘 PostgresNitro1.257s (-3.4%)2.012s (~)0.756s151.02x
🐘 PostgresExpress1.261s (-1.7%)2.012s (~)0.751s151.02x
🌐 RedisNext.js (Turbopack)1.321s2.007s0.685s151.07x
💻 LocalExpress1.527s (~)2.005s (~)0.478s151.24x
💻 LocalNitro1.527s (+4.8%)2.005s (~)0.478s151.24x
💻 LocalNext.js (Turbopack)1.572s2.006s0.434s151.27x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.597s (+2.8%)4.602s (+19.8% 🔺)2.005s71.00x
▲ VercelNext.js (Turbopack)2.630s (+1.5%)4.054s (-1.1%)1.423s81.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express2.354s (-3.6%)3.010s (~)0.657s101.00x
🐘 PostgresNitro2.354s (-5.6% 🟢)3.010s (~)0.656s101.00x
🐘 PostgresNext.js (Turbopack)2.412s3.010s0.599s101.02x
🌐 RedisNext.js (Turbopack)2.599s3.008s0.409s101.10x
💻 LocalNext.js (Turbopack)2.975s3.565s0.590s91.26x
💻 LocalNitro3.009s (+19.1% 🔺)3.760s (+25.0% 🔺)0.751s81.28x
💻 LocalExpress3.030s (-2.3%)3.760s (-6.2% 🟢)0.730s81.29x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.782s (+6.8% 🔺)5.013s (+36.6% 🔺)2.230s61.00x
▲ VercelNext.js (Turbopack)3.142s (+7.0% 🔺)4.966s (+15.8% 🔺)1.824s71.13x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro3.464s (-5.5% 🟢)4.015s (~)0.551s81.00x
🐘 PostgresExpress3.475s (-2.7%)4.014s (~)0.539s81.00x
🐘 PostgresNext.js (Turbopack)3.663s4.010s0.347s81.06x
🌐 RedisNext.js (Turbopack)4.261s5.011s0.750s61.23x
💻 LocalNext.js (Turbopack)7.418s8.016s0.597s42.14x
💻 LocalExpress8.564s (-1.7%)9.021s (-2.7%)0.457s42.47x
💻 LocalNitro9.200s (+37.7% 🔺)9.772s (+39.3% 🔺)0.571s42.66x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.261s (-3.7%)5.062s (+5.6% 🔺)1.801s71.00x
▲ VercelNext.js (Turbopack)3.526s (-15.3% 🟢)6.284s (+13.1% 🔺)2.758s51.08x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)1.219s2.009s0.790s151.00x
🐘 PostgresNitro1.252s (-3.0%)2.009s (~)0.757s151.03x
🐘 PostgresExpress1.255s (-2.8%)2.009s (~)0.754s151.03x
🌐 RedisNext.js (Turbopack)1.305s2.006s0.701s151.07x
💻 LocalNext.js (Turbopack)1.508s2.005s0.497s151.24x
💻 LocalNitro1.573s (+7.4% 🔺)2.007s (~)0.434s151.29x
💻 LocalExpress1.598s (+3.5%)2.072s (+3.3%)0.474s151.31x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.243s (-13.3% 🟢)3.995s (+5.7% 🔺)1.752s81.00x
▲ VercelNitro2.260s (-2.0%)4.048s (+7.7% 🔺)1.787s81.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.334s (-6.4% 🟢)3.011s (~)0.677s101.00x
🐘 PostgresExpress2.339s (-4.7%)3.010s (~)0.672s101.00x
🐘 PostgresNext.js (Turbopack)2.402s3.010s0.609s101.03x
🌐 RedisNext.js (Turbopack)2.544s3.008s0.464s101.09x
💻 LocalNext.js (Turbopack)2.868s3.453s0.585s91.23x
💻 LocalExpress2.992s (-5.7% 🟢)3.762s (-6.2% 🟢)0.770s81.28x
💻 LocalNitro3.086s (+7.6% 🔺)3.760s (+21.0% 🔺)0.674s81.32x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.905s (+1.0%)5.039s (+17.1% 🔺)2.134s71.00x
▲ VercelNext.js (Turbopack)3.300s (+10.1% 🔺)4.771s (+11.2% 🔺)1.471s71.14x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro3.469s (-4.9%)4.013s (~)0.544s81.00x
🐘 PostgresExpress3.473s (-3.4%)4.014s (~)0.541s81.00x
🐘 PostgresNext.js (Turbopack)3.670s4.012s0.342s81.06x
🌐 RedisNext.js (Turbopack)4.275s4.868s0.593s71.23x
💻 LocalExpress8.786s (-4.0%)9.022s (-10.0% 🟢)0.237s42.53x
💻 LocalNext.js (Turbopack)8.883s9.772s0.889s42.56x
💻 LocalNitro9.326s (+26.1% 🔺)10.024s (+25.0% 🔺)0.698s32.69x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.294s (+6.2% 🔺)5.002s (+6.1% 🔺)1.707s61.00x
▲ VercelNext.js (Turbopack)4.267s (+19.0% 🔺)6.135s (+24.6% 🔺)1.869s51.30x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)0.699s1.005s0.305s601.00x
🐘 PostgresNext.js (Turbopack)0.769s1.007s0.238s601.10x
🐘 PostgresExpress0.801s (-12.5% 🟢)1.007s (-6.7% 🟢)0.206s601.15x
🐘 PostgresNitro0.829s (-14.4% 🟢)1.007s (-18.5% 🟢)0.178s601.19x
💻 LocalNext.js (Turbopack)0.841s1.021s0.180s591.20x
💻 LocalExpress0.977s (-3.4%)1.076s (-30.3% 🟢)0.098s561.40x
💻 LocalNitro1.015s (+50.6% 🔺)1.628s (+62.1% 🔺)0.613s371.45x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro10.274s (+2.1%)12.099s (+5.9% 🔺)1.825s51.00x
▲ VercelNext.js (Turbopack)10.675s (+7.4% 🔺)12.483s (+1.0%)1.808s51.04x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.674s2.006s0.332s451.00x
🐘 PostgresNext.js (Turbopack)1.861s2.030s0.169s451.11x
🐘 PostgresExpress1.920s (-13.7% 🟢)2.077s (-31.0% 🟢)0.157s441.15x
🐘 PostgresNitro1.957s (-15.4% 🟢)2.176s (-27.8% 🟢)0.219s421.17x
💻 LocalNext.js (Turbopack)2.613s3.007s0.395s301.56x
💻 LocalExpress2.984s (-1.6%)3.341s (-9.4% 🟢)0.357s271.78x
💻 LocalNitro3.007s (+37.1% 🔺)3.547s (+18.0% 🔺)0.541s261.80x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro32.892s (+9.6% 🔺)34.652s (+8.7% 🔺)1.761s31.00x
▲ VercelNext.js (Turbopack)34.160s (+5.3% 🔺)51.070s (+51.8% 🔺)16.910s21.04x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)3.329s4.009s0.680s301.00x
🐘 PostgresNext.js (Turbopack)3.777s4.011s0.234s301.13x
🐘 PostgresExpress3.894s (-13.3% 🟢)4.184s (-16.6% 🟢)0.289s291.17x
🐘 PostgresNitro3.982s (-16.9% 🟢)4.404s (-13.6% 🟢)0.422s281.20x
💻 LocalNext.js (Turbopack)8.432s9.017s0.585s142.53x
💻 LocalExpress9.097s (+3.3%)9.555s (+4.3%)0.459s132.73x
💻 LocalNitro9.155s (+26.4% 🔺)9.864s (+23.1% 🔺)0.710s132.75x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro87.838s (+1.9%)104.355s (+19.6% 🔺)16.517s21.00x
▲ VercelNext.js (Turbopack)88.131s (+2.3%)90.390s (+2.2%)2.259s21.00x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)0.258s1.008s0.750s601.00x
🐘 PostgresNitro0.270s (-14.9% 🟢)1.008s (~)0.738s601.05x
🐘 PostgresExpress0.270s (-8.7% 🟢)1.008s (~)0.738s601.05x
🌐 RedisNext.js (Turbopack)0.365s1.004s0.640s601.42x
💻 LocalNext.js (Turbopack)0.551s1.004s0.453s602.14x
💻 LocalExpress0.595s (+4.7%)1.004s (~)0.410s602.31x
💻 LocalNitro0.598s (+6.7% 🔺)1.022s (+1.7%)0.423s592.32x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro1.834s (+9.2% 🔺)3.825s (+15.9% 🔺)1.992s161.00x
▲ VercelNext.js (Turbopack)2.568s (+35.7% 🔺)9.530s (+154.0% 🔺)6.962s121.40x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)0.481s1.019s0.538s891.00x
🐘 PostgresExpress0.493s (-10.0% 🟢)1.008s (~)0.515s901.03x
🐘 PostgresNitro0.498s (-14.7% 🟢)1.008s (~)0.511s901.04x
🌐 RedisNext.js (Turbopack)1.207s2.006s0.799s452.51x
💻 LocalNitro2.505s (+6.9% 🔺)3.009s (~)0.503s305.21x
💻 LocalNext.js (Turbopack)2.539s3.009s0.470s305.28x
💻 LocalExpress2.567s (+4.7%)3.009s (~)0.442s305.34x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.344s (+11.3% 🔺)5.095s (+14.5% 🔺)1.751s181.00x
▲ VercelNext.js (Turbopack)4.913s (+51.5% 🔺)6.649s (+40.7% 🔺)1.736s141.47x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)0.760s1.008s0.247s1201.00x
🐘 PostgresNitro0.767s (-20.7% 🟢)1.008s (-27.9% 🟢)0.241s1201.01x
🐘 PostgresExpress0.774s (-15.8% 🟢)1.008s (-18.7% 🟢)0.234s1201.02x
🌐 RedisNext.js (Turbopack)2.757s3.084s0.327s393.63x
💻 LocalNext.js (Turbopack)10.553s11.027s0.473s1113.88x
💻 LocalExpress11.228s (+1.9%)11.937s (+2.3%)0.708s1114.76x
💻 LocalNitro11.257s (+13.4% 🔺)12.029s (+16.1% 🔺)0.771s1014.80x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)7.133s (-77.8% 🟢)8.902s (-73.6% 🟢)1.769s141.00x
▲ VercelNitro7.220s (~)8.773s (+1.1%)1.554s141.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.175s1.001s0.011s1.017s0.842s101.00x
🌐 RedisNext.js (Turbopack)0.183s1.000s0.001s1.008s0.825s101.05x
🐘 PostgresExpress0.195s (-12.5% 🟢)0.996s (~)0.002s (+15.4% 🔺)1.011s (~)0.816s101.11x
🐘 PostgresNext.js (Turbopack)0.199s1.000s0.004s1.014s0.814s101.14x
🐘 PostgresNitro0.204s (-15.0% 🟢)0.995s (~)0.001s (-27.8% 🟢)1.011s (~)0.807s101.17x
💻 LocalNitro0.208s (+49.5% 🔺)1.003s (~)0.012s (+19.6% 🔺)1.018s (~)0.810s101.19x
💻 LocalExpress0.211s (+2.1%)1.003s (~)0.011s (-5.9% 🟢)1.017s (~)0.806s101.20x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro1.779s (+15.1% 🔺)2.497s (-4.5%)0.810s (+76.9% 🔺)3.996s (+11.2% 🔺)2.217s101.00x
▲ VercelNext.js (Turbopack)1.926s (+17.8% 🔺)2.945s (+0.9%)0.662s (+65.8% 🔺)4.322s (+11.6% 🔺)2.396s101.08x
▲ VercelExpress⚠️missing-----

🔍 Observability: Nitro | Next.js (Turbopack)

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)0.487s1.000s0.003s1.011s0.524s601.00x
🐘 PostgresExpress0.593s (-15.7% 🟢)1.003s (~)0.013s (+103.1% 🔺)1.033s (~)0.440s591.22x
🐘 PostgresNitro0.602s (-17.0% 🟢)1.004s (~)0.004s (-37.7% 🟢)1.023s (-0.7%)0.421s591.24x
🐘 PostgresNext.js (Turbopack)0.611s1.007s0.004s1.025s0.414s591.25x
💻 LocalNext.js (Turbopack)0.666s1.008s0.009s1.023s0.357s591.37x
💻 LocalNitro0.724s (+27.6% 🔺)1.009s (~)0.010s (+4.6%)1.024s (~)0.299s591.49x
💻 LocalExpress0.734s (+2.3%)1.009s (~)0.009s (-5.3% 🟢)1.022s (~)0.288s591.51x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)5.132s (+12.4% 🔺)6.256s (+11.9% 🔺)0.242s (+7.6% 🔺)13.240s (+104.9% 🔺)8.108s51.00x
▲ VercelNitro5.363s (~)6.372s (+3.7%)0.403s (-35.9% 🟢)7.632s (+1.2%)2.269s81.05x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)0.897s1.034s0.000s1.039s0.141s581.00x
🐘 PostgresNext.js (Turbopack)0.948s1.132s0.000s1.141s0.193s531.06x
🐘 PostgresExpress0.956s (-14.8% 🟢)1.271s (-28.6% 🟢)0.000s (-63.8% 🟢)1.286s (-28.9% 🟢)0.331s471.06x
🐘 PostgresNitro0.965s (-17.5% 🟢)1.298s (-35.0% 🟢)0.000s (-2.2%)1.315s (-35.0% 🟢)0.350s461.08x
💻 LocalNitro1.224s (+8.0% 🔺)2.020s (~)0.000s (+50.0% 🔺)2.023s (~)0.799s301.36x
💻 LocalExpress1.246s (+3.6%)2.020s (~)0.000s (-42.9% 🟢)2.023s (~)0.777s301.39x
💻 LocalNext.js (Turbopack)1.270s2.020s0.000s2.024s0.754s301.42x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.626s (+3.0%)4.889s (+5.1% 🔺)0.004s (+Infinity% 🔺)5.632s (+8.2% 🔺)2.006s111.00x
▲ VercelNitro3.756s (+24.1% 🔺)4.924s (+25.5% 🔺)0.002s (+1045.5% 🔺)5.654s (+27.8% 🔺)1.898s111.04x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

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

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.603s2.002s0.000s2.007s0.403s301.00x
🐘 PostgresNext.js (Turbopack)1.831s2.106s0.000s2.115s0.284s291.14x
🐘 PostgresExpress1.840s (-10.6% 🟢)2.258s (-8.8% 🟢)0.000s (NaN%)2.271s (-9.1% 🟢)0.431s271.15x
🐘 PostgresNitro1.853s (-18.2% 🟢)2.223s (-24.7% 🟢)0.000s (-100.0% 🟢)2.235s (-24.7% 🟢)0.382s271.16x
💻 LocalNitro3.585s (+3.6%)4.300s (+6.7% 🔺)0.000s (-61.5% 🟢)4.304s (+6.7% 🔺)0.720s152.24x
💻 LocalNext.js (Turbopack)3.632s4.230s0.001s4.235s0.604s152.27x
💻 LocalExpress3.670s (+8.3% 🔺)4.032s (-1.6%)0.000s (+250.0% 🔺)4.036s (-1.6%)0.366s152.29x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro4.200s (+21.3% 🔺)5.030s (+20.0% 🔺)0.000s (+Infinity% 🔺)5.900s (+24.3% 🔺)1.700s111.00x
▲ VercelNext.js (Turbopack)5.104s (+14.0% 🔺)6.017s (+11.1% 🔺)0.000s (+10.0% 🔺)6.656s (+11.9% 🔺)1.552s101.22x
▲ VercelExpress⚠️missing-----

🔍 Observability: Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)15/21
🐘 PostgresNext.js (Turbopack)13/21
▲ VercelNitro16/21
Fastest World by Framework

Winner determined by most benchmark wins

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

📋 View full workflow run


Some benchmark jobs failed:

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

Check the workflow run for details.

@github-actions

github-actionsBot commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production813067880
✅ 💻 Local Development7820178960
✅ 📦 Local Production7820178960
✅ 🐘 Local Postgres7820178960
✅ 🪟 Windows720880
❌ 🌍 Community Worlds1185630204
✅ 📋 Other198042240
Total3547566814284

❌ Failed Tests

🌍 Community Worlds (56 failed)

mongodb (3 failed):

  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KMEKZQ811N46GD5AK5RW1HD9
  • webhookWorkflow | wrun_01KMEKZZZNPHAFY93GWGMA33E4
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KMEM7DRXD1RYA1E11HJTJFPW

redis (2 failed):

  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KMEKZQ811N46GD5AK5RW1HD9
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KMEM7DRXD1RYA1E11HJTJFPW

turso (51 failed):

  • addTenWorkflow | wrun_01KMEKYFN28CV699JDRY8EVENQ
  • addTenWorkflow | wrun_01KMEKYFN28CV699JDRY8EVENQ
  • wellKnownAgentWorkflow (.well-known/agent) | wrun_01KMEKZV5Y7H35GS496CWE2PWY
  • should work with react rendering in step
  • promiseAllWorkflow | wrun_01KMEKYP9VT3R0SKBHBMF46WF1
  • promiseRaceWorkflow | wrun_01KMEKYV2NHKMK1PT16PHGC0TE
  • promiseAnyWorkflow | wrun_01KMEKYYRJ4XWER78HC3BC88RH
  • importedStepOnlyWorkflow | wrun_01KMEM07SDMHGNED3K1Z8822HA
  • hookWorkflow | wrun_01KMEKZB3KDGNSRDK1ZP1ZWCS2
  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KMEKZQ811N46GD5AK5RW1HD9
  • webhookWorkflow | wrun_01KMEKZZZNPHAFY93GWGMA33E4
  • sleepingWorkflow | wrun_01KMEM07AP1ZHHP0B1Y28XPTPF
  • parallelSleepWorkflow | wrun_01KMEM0M62BA11GZH9N30PA01V
  • nullByteWorkflow | wrun_01KMEM0RNC851K91RM7QKG9J0A
  • workflowAndStepMetadataWorkflow | wrun_01KMEM0TS1NDEE94P4ZK643V6C
  • fetchWorkflow | wrun_01KMEM3MRRD40CZ6RZHVTBN0QE
  • promiseRaceStressTestWorkflow | wrun_01KMEM3RJJYY05KEDAWHC7YZCF
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion | wrun_01KMEM6SVSD36537C7RMSNZ7K2
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KMEM7DRXD1RYA1E11HJTJFPW
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running | wrun_01KMEM84D71052EEDK1NTW09QR
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars) | wrun_01KMEM8QRVX2ZN0ZY8KSFXYRFW
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument | wrun_01KMEM90F9YAZG8G7CZZR9KQWQ
  • closureVariableWorkflow - nested step functions with closure variables | wrun_01KMEM96EMKN1H5C673QHQM0RJ
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step | wrun_01KMEM9A0HFZKBNE055EZ9P5H4
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly | wrun_01KMEM9RJS5TNK0QPDKP86M45K
  • Calculator.calculate - static workflow method using static step methods from another class | wrun_01KMEM9XZANZTCTYJ5TEE55MRZ
  • AllInOneService.processNumber - static workflow method using sibling static step methods | wrun_01KMEMA4B58C28JE56JM1YFVQV
  • ChainableService.processWithThis - static step methods using this to reference the class | wrun_01KMEMAATKT6G9NX72T548BXF1
  • thisSerializationWorkflow - step function invoked with .call() and .apply() | wrun_01KMEMAH46HQ91CQS2CS66CDC6
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE | wrun_01KMEMAQHGV2M8JHE762DZMKC6
  • instanceMethodStepWorkflow - instance methods with "use step" directive | wrun_01KMEMAYB4Y9GXM4F81Y6CNJ6B
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context | wrun_01KMEMB8XA6D9RX061RHB81PKZ
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument | wrun_01KMEMBGE2CX800GQHW1H2HAJF
  • cancelRun - cancelling a running workflow | wrun_01KMEMBQWVXW1JB5P0HV743Q4P
  • cancelRun via CLI - cancelling a running workflow | wrun_01KMEMC0XTPK7JCE38TSAYE9N8
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep | wrun_01KMEMCCDFRYCKTFTV8TJ03YRG
  • sleepInLoopWorkflow - sleep inside loop with steps actually delays each iteration | wrun_01KMEMD0561BCM0VA669B3A6CS
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control) | wrun_01KMEMDAE0C841QKT27361H8BN

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro7307
✅ example7307
✅ express7307
✅ fastify7307
✅ hono7307
✅ nextjs-turbopack7802
✅ nextjs-webpack7802
✅ nitro7307
✅ nuxt7307
✅ sveltekit7307
✅ vite7307
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable66014
✅ express-stable66014
✅ fastify-stable66014
✅ hono-stable66014
✅ nextjs-turbopack-canary55025
✅ nextjs-turbopack-stable7208
✅ nextjs-webpack-canary55025
✅ nextjs-webpack-stable7208
✅ nitro-stable66014
✅ nuxt-stable66014
✅ sveltekit-stable66014
✅ vite-stable66014
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable66014
✅ express-stable66014
✅ fastify-stable66014
✅ hono-stable66014
✅ nextjs-turbopack-canary55025
✅ nextjs-turbopack-stable7208
✅ nextjs-webpack-canary55025
✅ nextjs-webpack-stable7208
✅ nitro-stable66014
✅ nuxt-stable66014
✅ sveltekit-stable66014
✅ vite-stable66014
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable66014
✅ express-stable66014
✅ fastify-stable66014
✅ hono-stable66014
✅ nextjs-turbopack-canary55025
✅ nextjs-turbopack-stable7208
✅ nextjs-webpack-canary55025
✅ nextjs-webpack-stable7208
✅ nitro-stable66014
✅ nuxt-stable66014
✅ sveltekit-stable66014
✅ vite-stable66014
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack7208
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev302
❌ mongodb5238
✅ redis-dev302
❌ redis5328
✅ turso-dev302
❌ turso4518
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable66014
✅ e2e-local-postgres-nest-stable66014
✅ e2e-local-prod-nest-stable66014

📋 View full workflow run

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Comment threadpackages/core/e2e/e2e.test.ts Outdated

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Here's my review of PR #1470:

Summary

This is a feature PR by @VaguelySerious that adds two new Streamer interface methods (getStreamChunks and getStreamInfo) across all world implementations, extends Run.getReadable() with a getTailIndex() helper, and adds initialStartIndex support to WorkflowChatTransport for resuming streams from the end.

No Blockers

CI is in excellent shape. The code changes are well-structured, well-documented, and backward-compatible.

CI Status

Passing:

  • All Vercel Production E2E tests: 11/11 frameworks pass (802 passed, 0 failed)
  • All Local Dev E2E tests: 12/12 pass (782 passed)
  • All Local Prod E2E tests: 12/12 pass
  • All Local Postgres E2E tests: 12/12 pass
  • Windows E2E tests pass
  • Unit tests pass (ubuntu + windows)
  • Vitest plugin tests pass
  • All Vercel deployments succeed (except workbench-nestjs-workflow - discussed below)
  • Benchmarks pass for local/postgres; no performance regressions
  • Docs code samples and links pass

Failing (non-blocking / pre-existing):

  • workbench-nestjs-workflow Vercel deployment fails - this is likely a pre-existing issue unrelated to streaming changes (the nest workbench has no streaming code changes in this PR)
  • Benchmark Vercel (nitro-v3) fails at ~30min - likely infra timeout, not PR-related
  • Community World (Turso) - 51 failures, pre-existing community world issue (same pattern as other PRs)
  • Community World (MongoDB/Redis) - 3/2 failures on hook-related tests, pre-existing

Code Review

1. Streamer interface additions (packages/world/src/interfaces.ts) - Clean

Two new methods added to the Streamer interface:

  • getStreamChunks(name, runId, options?) - cursor-based pagination for stream chunks
  • getStreamInfo(name, runId) - lightweight metadata (tailIndex, done flag)

New types (StreamChunk, StreamChunksResponse, GetChunksOptions, StreamInfoResponse) are well-documented in packages/world/src/shared.ts. The interface is additive and non-breaking.

2. world-vercel implementation (packages/world-vercel/src/streamer.ts) - Clean

Uses makeRequest + Zod schemas to call two new server endpoints:

  • GET /v2/runs/:runId/streams/:name/chunks?limit=&cursor=
  • GET /v2/runs/:runId/streams/:name/info

The StreamChunksResponseSchema correctly expects z.instanceof(Uint8Array) since CBOR handles binary natively. Clean and minimal.

3. world-local implementation (packages/world-local/src/streamer.ts) - Works but verbose

The implementation reads chunk files from disk, builds a file extension map, sorts by ULID, and paginates. One observation:

  • getStreamInfo reads every chunk file to count data chunks and detect EOF. For large streams this could be expensive. The getStreamChunks implementation has the same pattern. Both could potentially be optimized with a count-based approach, but for local dev this is fine.
  • The cursor encoding (base64-encoded JSON { i: index }) is a reasonable pattern.

4. world-postgres implementation (packages/world-postgres/src/streamer.ts) - Solid

Uses Drizzle ORM with proper query patterns:

  • Fetches limit + 1 rows to detect hasMore efficiently
  • Correctly handles the EOF row being mixed into results
  • Has a follow-up EOF check query when !streamDone && !hasMore to correctly report done status
  • Cursor encodes both chunk ID and running index ({ c: chunkId, i: baseIndex })

One minor cleanup: void fn() on line 407 (changing cleanups.forEach((fn) => fn()) to cleanups.forEach((fn) => void fn())) is a good lint fix to suppress floating promise warnings.

5. WorkflowReadableStream type and getTailIndex() (packages/core/src/runtime/run.ts) - Clean

Uses Object.assign to add getTailIndex() to the ReadableStream. This is a clean pattern that avoids subclassing. The type is properly exported through packages/workflow/src/api.ts.

6. WorkflowChatTransport changes (packages/ai/src/workflow-chat-transport.ts) - Well thought out

The negative initialStartIndex feature is carefully designed:

  • First request uses the explicit (potentially negative) startIndex
  • On retry, resolves to an absolute position using x-workflow-stream-tail-index header
  • Falls back to startIndex=0 with a warning if the header is missing
  • useExplicitStartIndex flag ensures the negative value is only used once, then subsequent retries use the running chunkIndex

The replayFromStart flag handles the edge case where the header is absent gracefully.

7. Tests - Good coverage

  • Two new e2e tests for getTailIndex and getStreamChunks pagination (skipped on local deployments since they require server-side endpoints)
  • Two new unit tests for WorkflowChatTransport covering negative initialStartIndex with and without the tail-index header

8. Docs - Thorough

Updated resumable-streams.mdx with:

  • New "Resuming from the end of the stream" section
  • Updated stream reconnection endpoint to include getTailIndex() and the x-workflow-stream-tail-index header
  • Callout about header requirement for negative initialStartIndex
  • API reference docs updated

9. Changeset - Present and correct

Includes @workflow/world, world-local, world-vercel, world-postgres, core, and ai as patch changes.

Minor Notes

  • The PR description is empty ("No description provided"). Worth adding a summary for posterity.
  • The getStreamChunks method uses _runId (unused) in local/postgres implementations since the stream name is already unique there. The vercel implementation passes it to the server endpoint. This is fine - the interface is designed for the vercel world's needs.

Verdict

No blockers or regressions. This is a well-implemented, additive feature with good test coverage and documentation. The only CI failures are pre-existing (nest deployment, community world Turso/MongoDB/Redis, nitro benchmark timeout). Ready to merge once it gets a review approval.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall this is a well-designed, additive feature. The negative initialStartIndex logic in the transport is carefully thought through, the interface additions are clean, and the docs/tests cover the new behavior well. A few issues worth addressing below.

GetChunksOptions,
StreamChunk,
StreamChunksResponse,
} from './shared.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The explicit export type { GetChunksOptions, StreamChunk, StreamChunksResponse } is redundant with the export type * from './shared.js' on line 34 — and it's also missing StreamInfoResponse. Since line 34 already re-exports all types from shared.js, you can just remove this block entirely (lines 39-43).

.select({ chunkId: streams.chunkId })
.from(streams)
.where(and(eq(streams.streamId, name), eq(streams.eof, false)))
.orderBy(asc(streams.chunkId));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

getStreamInfo fetches all data rows into memory just to count them. For streams with thousands of chunks this is wasteful — a SELECT COUNT(*) FROM streams WHERE streamId = $1 AND eof = false would be much cheaper. Consider using Drizzle's count() aggregate or a raw SQL count instead of selecting all chunk IDs.

hasMore,
done: streamDone,
};
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The file-listing + extension-mapping logic (~30 lines) is duplicated verbatim between getStreamChunks and getStreamInfo. Consider extracting a helper like listStreamDataFiles(name) that returns { dataFiles, fileExtMap, streamDone } to keep this DRY.

Comment threadpackages/world-local/src/streamer.ts Outdated
break;
}
dataFiles.push(file);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

getStreamChunks reads and deserializes every chunk file from disk on every call (to separate data files from the EOF marker), regardless of the cursor position. For a stream with 10k chunks, page 2 still reads all 10k files.

Two possible improvements:

  1. Only read files starting from the cursor position (the ULID sort gives chronological order, so you can skip earlier files)
  2. At minimum, stop reading once you have enough files past the cursor — you only need startIndex + limit data files plus potentially the EOF marker

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Deeper review: testing, docs, observability, edge cases, and performance

Testing gaps

world-local and world-postgres have zero unit tests for the new getStreamChunks and getStreamInfo methods. The only coverage comes from two e2e tests that are skipIf(isLocalDeployment()), so local dev never exercises these paths. Cursor pagination logic (invalid cursors, out-of-range cursors, single-item pages), empty stream behavior (tailIndex = -1), and mid-flight streams (calling before workflow completes) are all untested.

The transport unit tests cover the happy path for negative initialStartIndex but miss: NaN/malformed x-workflow-stream-tail-index header values (the !Number.isNaN guard is untested), positive explicit startIndex override, boundary clamping when magnitude exceeds stream length, and multi-retry chains where the header varies across attempts.

Documentation gaps

  1. building-a-world.mdx still shows the old Streamer interface without getStreamChunks or getStreamInfo. Custom world implementers won't know these methods are required.
  2. No API reference for WorkflowReadableStream or getTailIndex() — the get-run.mdx page documents WorkflowReadableStreamOptions but not the new return type.
  3. Changeset mentions getStreamInfo but omits getStreamChunks.

Observability

Existing storage methods are wrapped with instrumentObject() for automatic OTEL spans (e.g. world.runs.get, world.events.create). Streamer methods — both existing and new — are not instrumented at all. No spans, no semantic conventions for stream names/indices, and no structured logging in any world implementation's streaming code. The only observability signal is the console.warn in WorkflowChatTransport for missing headers. This is consistent with the pre-existing pattern (streaming was never instrumented), but it means production debugging of pagination issues, tail-index mismatches, or reconnection failures will be blind.

Edge cases and performance (see inline comments below)

Several specific issues are called out inline.

break;
}
dataRows.push(row);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Edge case: EOF row appearing mid-batch. The query fetches limit + 1 rows ordered by chunkId without filtering out EOF rows. If the EOF row sorts between data rows (e.g. due to concurrent writes or clock skew), the loop breaks early on row.eof, and subsequent data rows in the result set are silently dropped.

Then hasMore = !streamDone && dataRows.length > limit evaluates to false (because streamDone = true), so the client sees done: true, hasMore: false even though there were more data rows in the fetch.

This is probably fine in practice since the EOF ULID is generated after all data chunks, but it's worth noting the implicit ordering assumption. Adding eq(streams.eof, false) to the main query and doing a separate EOF check (like the lookahead on line 250) would make this robust.

.select({ chunkId: streams.chunkId })
.from(streams)
.where(and(eq(streams.streamId, name), eq(streams.eof, false)))
.orderBy(asc(streams.chunkId));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Performance: O(N) memory to count rows. This SELECT chunkId FROM streams WHERE ... loads every non-EOF row into Node.js memory just to take .length. For a stream with 100k chunks, that's 100k objects allocated and immediately discarded.

Use a COUNT(*) aggregate instead:

const[{ count }]=awaitdrizzle.select({count: sql<number>`count(*)`}).from(streams).where(and(eq(streams.streamId,name),eq(streams.eof,false)));return{tailIndex: (count??0)-1,done: !!eofRow};

Comment threadpackages/world-local/src/streamer.ts Outdated
break;
}
dataFiles.push(file);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Performance: every page reads ALL chunk files.getStreamChunks deserializes every chunk file from disk (lines 286-296) to separate data files from the EOF marker, even for chunks that precede the cursor. Page 50 of a 10k-chunk stream still reads all 10k files.

getStreamInfo (line 372) has the exact same pattern — reads all files just to count them.

For local dev this is tolerable, but if these methods get called frequently (e.g. polling for tail index), the cost adds up. A lighter approach: since ULID sort gives chronological order, you could skip files before the cursor index and stop after collecting limit data files + EOF check.

const readable = run.getReadable();
const tailIndex = await readable.getTailIndex();

// outputStreamWorkflow writes 2 chunks to the default stream

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Testing gap: no empty-stream or mid-flight test. These tests only run after await run.returnValue (stream fully complete) and outputStreamWorkflow always writes exactly 2 chunks.

Missing scenarios:

  • Empty stream: What does getTailIndex() return when 0 chunks have been written? (should be -1 per the type docs)
  • Mid-flight stream: What does getTailIndex() / getStreamChunks() return while the workflow is still running and the stream isn't closed yet?
  • getStreamInfo directly: Only tested indirectly via getTailIndex() — no direct assertion on the done flag


warnSpy.mockRestore();
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Testing gap: malformed header values. The implementation has a !Number.isNaN(tailIndex) guard (transport line 362) but no test covers it. Consider adding a case where the header is "not-a-number"parseInt returns NaN, the guard should skip resolution, and the code should fall through without setting chunkIndex.

Also missing: a test for initialStartIndex: -1000 on a 5-chunk stream (should clamp to 0 via Math.max), and a test where a positive startIndex is passed via reconnectToStream options to override initialStartIndex.

const tailIndexHeader = res.headers.get('x-workflow-stream-tail-index');
if (tailIndexHeader !== null) {
const tailIndex = parseInt(tailIndexHeader, 10);
if (!Number.isNaN(tailIndex)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Edge case: NaN guard doesn't fall through cleanly. When parseInt returns NaN, the if (!Number.isNaN(tailIndex)) block is skipped, but replayFromStart is NOT set (that only happens in the else branch for tailIndexHeader === null). So on the next iteration, useExplicitStartIndex is false and replayFromStart is false, meaning the code falls through to chunkIndex — which is still 0 from initialization.

This accidentally works (retries from 0), but the intent is unclear. If the header is present but malformed, should it warn and set replayFromStart = true like the missing-header case? As-is, a malformed header silently degrades to replaying from 0 without the console warning.

GetChunksOptions,
StreamChunk,
StreamChunksResponse,
} from './shared.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Redundant + incomplete export. Line 34 already has export type * from './shared.js' which re-exports all types. This explicit block is redundant and also missing StreamInfoResponse. Just delete lines 39-43.

VaguelySeriousand others added 2 commits March 23, 2026 17:22
- world-postgres: use COUNT(*) in getStreamInfo instead of fetching all
rows; filter EOF rows in getStreamChunks query to prevent mid-batch
data loss from clock-skewed EOF rows
- world-local: skip files before cursor in getStreamChunks, read only
EOF byte in getStreamInfo and pre-cursor files
- WorkflowChatTransport: treat unparseable tail-index header same as
missing (set replayFromStart); add unit test for NaN header fallback
- e2e: add test for getTailIndex returning -1 on nonexistent stream
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- building-a-world.mdx: add getStreamChunks and getStreamInfo to
Streamer interface definition
- get-run.mdx: document WorkflowReadableStream and getTailIndex()
- Changeset: mention getStreamChunks alongside getStreamInfo
- world-vercel, world-local: wrap streamer with instrumentObject for
OTEL spans (world.streams.* prefix), matching storage instrumentation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fresh review after update

This update addresses all the critical issues from the previous round. Nice work cleaning this up.

Previously flagged — now resolved

  • Postgres getStreamInfo: Now uses COUNT(*) aggregate instead of fetching all rows into memory
  • Postgres getStreamChunks EOF mid-batch: Main query now filters eq(streams.eof, false), with a separate EOF check — no more risk of EOF silently dropping data rows
  • Transport NaN guard: Missing and malformed headers now both flow through the same else branch — replayFromStart = true + console.warn in both cases
  • world-local getStreamChunks perf: No longer deserializes every chunk — pre-cursor files use isEofChunk() (byte check only), and the loop stops early after collecting enough data
  • world-local getStreamInfo: Also uses isEofChunk() instead of full deserialization
  • Changeset: Now mentions both getStreamChunks() and getStreamInfo()
  • Docs: building-a-world.mdx includes the new Streamer methods with explanatory text. get-run.mdx has a WorkflowReadableStream section with getTailIndex() reference.
  • E2e tests: Added tailIndex returns -1 test using a nonexistent namespace. Added NaN header unit test.
  • OTEL: Both world-vercel and world-local now wrap the streamer with instrumentObject('world.streams', ...)
  • Redundant export: StreamInfoResponse added to the explicit export list in world/src/index.ts

Minor remaining items (non-blocking)

  1. world-local file-listing boilerplate still duplicated — the ~20-line block that lists files by extension, builds the fileExtMap, and filters by stream name is copy-pasted between getStreamChunks (lines 266-286) and getStreamInfo (lines 367-389). Extracting a listChunkFilesForStream(name) helper would reduce maintenance surface.

  2. world-postgres streamer not instrumentedworld-vercel and world-local both wrap their streamer with instrumentObject('world.streams', ...), giving the new methods automatic OTEL spans. world-postgres spreads the streamer directly without instrumentation (packages/world-postgres/src/index.ts:49). Pre-existing inconsistency, but worth noting since this PR adds new methods that would benefit from tracing.

  3. No unit tests for world-local or world-postgres implementations of getStreamChunks/getStreamInfo. The cursor pagination logic (invalid cursors, boundary conditions, EOF detection) is only tested via e2e tests that are skipped in local dev. Consider adding unit tests in a follow-up.

dataCount++;
}

return { tailIndex: dataCount - 1, done: streamDone };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: The file-listing + extension-mapping block (~20 lines) is duplicated between getStreamChunks (lines 266-286) and getStreamInfo (lines 367-389). A shared listChunkFilesForStream(name) helper returning { chunkFiles, fileExtMap } would keep these two methods DRY.

@ghostghost mentioned this pull request Mar 23, 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.

3 participants

@VaguelySerious@pranaygp@karthikscale3
, '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); } })(); })(); [world] Add stream pagination and metadata endpoints by VaguelySerious · Pull Request #1470 · vercel/workflow · GitHub
Skip to content

[world] Add stream pagination and metadata endpoints - #1470

Merged
VaguelySerious merged 32 commits into
mainfrom
peter/stream-metadata-and-pages
Mar 24, 2026
Merged

[world] Add stream pagination and metadata endpoints#1470
VaguelySerious merged 32 commits into
mainfrom
peter/stream-metadata-and-pages

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

No description provided.

VaguelySeriousand others added 13 commits March 19, 2026 17:44
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Negative startIndex values (e.g. -3) resolve to n chunks before the
known end of the stream. All world implementations (local, postgres,
vercel) support this. Includes unit tests, e2e test, and doc updates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Replaces three separate tests (no startIndex, positive, negative) with a
single describe block that iterates over startIndex cases. Fixes the
negative startIndex test by waiting for workflow completion before
connecting the reader — the backend resolves negative indices at
connection time using knownChunkCount, which is 0 if the stream hasn't
been fully written yet.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Replaces three separate tests (no startIndex, positive, negative) with a
single describe block that iterates over startIndex cases. Fixes the
negative startIndex test by waiting for workflow completion before
connecting the reader — the backend resolves negative indices at
connection time using knownChunkCount, which is 0 if the stream hasn't
been fully written yet.
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
- Clarify that negative startIndex is for custom consumers, not WorkflowChatTransport
- Add live-stream caveat for negative startIndex resolution timing
- Add pagination limitation callout for live streams
- Use explicit typeof guards for negative startIndex checks (world-local, world-postgres)
- Add cost comment for EOF marker disk read in world-local
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel

vercelBot commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Mar 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a82fbe7

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

This PR includes changesets to release 20 packages
NameType
@workflow/worldPatch
@workflow/world-localPatch
@workflow/world-vercelPatch
@workflow/world-postgresPatch
@workflow/corePatch
@workflow/aiPatch
@workflow/cliPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
workflowPatch
@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 Mar 20, 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🥇 Express0.043s (-5.4% 🟢)1.006s (~)0.962s101.00x
💻 LocalNitro0.044s (+14.4% 🔺)1.005s (~)0.962s101.00x
💻 LocalNext.js (Turbopack)0.049s1.005s0.956s101.13x
🌐 RedisNext.js (Turbopack)0.055s1.005s0.950s101.26x
🐘 PostgresExpress0.062s (+10.8% 🔺)1.011s (~)0.949s101.42x
🐘 PostgresNitro0.064s (-7.9% 🟢)1.011s (~)0.947s101.47x
🐘 PostgresNext.js (Turbopack)0.065s1.012s0.947s101.50x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro0.458s (-21.6% 🟢)2.677s (+25.7% 🔺)2.219s101.00x
▲ VercelNext.js (Turbopack)0.542s (-17.9% 🟢)2.725s (+14.0% 🔺)2.183s101.18x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.123s2.006s0.883s101.00x
🌐 RedisNext.js (Turbopack)1.125s2.006s0.882s101.00x
💻 LocalExpress1.128s (~)2.006s (~)0.878s101.00x
💻 LocalNitro1.131s (+3.5%)2.006s (~)0.875s101.01x
🐘 PostgresNext.js (Turbopack)1.142s2.013s0.871s101.02x
🐘 PostgresNitro1.149s (-1.2%)2.012s (~)0.863s101.02x
🐘 PostgresExpress1.162s (+0.7%)2.012s (~)0.851s101.03x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.139s (+2.5%)4.147s (+23.1% 🔺)2.008s101.00x
▲ VercelNext.js (Turbopack)2.152s (+4.3%)4.007s (+9.3% 🔺)1.855s101.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)10.776s11.022s0.246s31.00x
💻 LocalNext.js (Turbopack)10.792s11.024s0.232s31.00x
🐘 PostgresExpress10.871s (-0.6%)11.020s (~)0.148s31.01x
🐘 PostgresNext.js (Turbopack)10.875s11.024s0.149s31.01x
💻 LocalExpress10.906s (-0.5%)11.023s (~)0.118s31.01x
💻 LocalNitro10.923s (+2.9%)11.023s (~)0.100s31.01x
🐘 PostgresNitro10.938s (-1.8%)11.026s (-8.5% 🟢)0.088s31.02x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)17.601s (+2.3%)19.403s (+1.9%)1.803s21.00x
▲ VercelNitro17.930s (~)20.036s (+2.1%)2.106s21.02x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)14.213s15.028s0.815s41.00x
🐘 PostgresNext.js (Turbopack)14.424s15.026s0.602s41.01x
💻 LocalNext.js (Turbopack)14.540s15.029s0.489s41.02x
🐘 PostgresExpress14.580s (-1.6%)15.024s (~)0.444s41.03x
🐘 PostgresNitro14.643s (-1.7%)15.033s (~)0.390s41.03x
💻 LocalExpress14.944s (-0.6%)15.029s (-4.8%)0.084s41.05x
💻 LocalNitro14.984s (+5.4% 🔺)15.033s (~)0.049s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro34.179s (+4.6%)35.468s (+4.2%)1.289s21.00x
▲ VercelNext.js (Turbopack)35.203s (+4.7%)37.010s (+6.3% 🔺)1.807s21.03x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)13.441s14.026s0.585s71.00x
🐘 PostgresNext.js (Turbopack)13.969s14.167s0.198s71.04x
🐘 PostgresExpress13.969s (-3.2%)14.452s (-3.9%)0.482s71.04x
🐘 PostgresNitro14.112s (-5.0% 🟢)14.741s (-3.1%)0.629s71.05x
💻 LocalNext.js (Turbopack)16.013s16.362s0.348s61.19x
💻 LocalExpress16.580s (-2.7%)17.030s (-1.9%)0.450s61.23x
💻 LocalNitro16.653s (+11.9% 🔺)17.031s (+13.3% 🔺)0.378s61.24x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro58.422s (-5.3% 🟢)61.010s (-3.0%)2.587s21.00x
▲ VercelNext.js (Turbopack)63.237s (+3.3%)64.613s (+3.3%)1.376s21.08x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)1.235s2.010s0.775s151.00x
🐘 PostgresNitro1.257s (-3.4%)2.012s (~)0.756s151.02x
🐘 PostgresExpress1.261s (-1.7%)2.012s (~)0.751s151.02x
🌐 RedisNext.js (Turbopack)1.321s2.007s0.685s151.07x
💻 LocalExpress1.527s (~)2.005s (~)0.478s151.24x
💻 LocalNitro1.527s (+4.8%)2.005s (~)0.478s151.24x
💻 LocalNext.js (Turbopack)1.572s2.006s0.434s151.27x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.597s (+2.8%)4.602s (+19.8% 🔺)2.005s71.00x
▲ VercelNext.js (Turbopack)2.630s (+1.5%)4.054s (-1.1%)1.423s81.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express2.354s (-3.6%)3.010s (~)0.657s101.00x
🐘 PostgresNitro2.354s (-5.6% 🟢)3.010s (~)0.656s101.00x
🐘 PostgresNext.js (Turbopack)2.412s3.010s0.599s101.02x
🌐 RedisNext.js (Turbopack)2.599s3.008s0.409s101.10x
💻 LocalNext.js (Turbopack)2.975s3.565s0.590s91.26x
💻 LocalNitro3.009s (+19.1% 🔺)3.760s (+25.0% 🔺)0.751s81.28x
💻 LocalExpress3.030s (-2.3%)3.760s (-6.2% 🟢)0.730s81.29x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.782s (+6.8% 🔺)5.013s (+36.6% 🔺)2.230s61.00x
▲ VercelNext.js (Turbopack)3.142s (+7.0% 🔺)4.966s (+15.8% 🔺)1.824s71.13x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro3.464s (-5.5% 🟢)4.015s (~)0.551s81.00x
🐘 PostgresExpress3.475s (-2.7%)4.014s (~)0.539s81.00x
🐘 PostgresNext.js (Turbopack)3.663s4.010s0.347s81.06x
🌐 RedisNext.js (Turbopack)4.261s5.011s0.750s61.23x
💻 LocalNext.js (Turbopack)7.418s8.016s0.597s42.14x
💻 LocalExpress8.564s (-1.7%)9.021s (-2.7%)0.457s42.47x
💻 LocalNitro9.200s (+37.7% 🔺)9.772s (+39.3% 🔺)0.571s42.66x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.261s (-3.7%)5.062s (+5.6% 🔺)1.801s71.00x
▲ VercelNext.js (Turbopack)3.526s (-15.3% 🟢)6.284s (+13.1% 🔺)2.758s51.08x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)1.219s2.009s0.790s151.00x
🐘 PostgresNitro1.252s (-3.0%)2.009s (~)0.757s151.03x
🐘 PostgresExpress1.255s (-2.8%)2.009s (~)0.754s151.03x
🌐 RedisNext.js (Turbopack)1.305s2.006s0.701s151.07x
💻 LocalNext.js (Turbopack)1.508s2.005s0.497s151.24x
💻 LocalNitro1.573s (+7.4% 🔺)2.007s (~)0.434s151.29x
💻 LocalExpress1.598s (+3.5%)2.072s (+3.3%)0.474s151.31x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.243s (-13.3% 🟢)3.995s (+5.7% 🔺)1.752s81.00x
▲ VercelNitro2.260s (-2.0%)4.048s (+7.7% 🔺)1.787s81.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.334s (-6.4% 🟢)3.011s (~)0.677s101.00x
🐘 PostgresExpress2.339s (-4.7%)3.010s (~)0.672s101.00x
🐘 PostgresNext.js (Turbopack)2.402s3.010s0.609s101.03x
🌐 RedisNext.js (Turbopack)2.544s3.008s0.464s101.09x
💻 LocalNext.js (Turbopack)2.868s3.453s0.585s91.23x
💻 LocalExpress2.992s (-5.7% 🟢)3.762s (-6.2% 🟢)0.770s81.28x
💻 LocalNitro3.086s (+7.6% 🔺)3.760s (+21.0% 🔺)0.674s81.32x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.905s (+1.0%)5.039s (+17.1% 🔺)2.134s71.00x
▲ VercelNext.js (Turbopack)3.300s (+10.1% 🔺)4.771s (+11.2% 🔺)1.471s71.14x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro3.469s (-4.9%)4.013s (~)0.544s81.00x
🐘 PostgresExpress3.473s (-3.4%)4.014s (~)0.541s81.00x
🐘 PostgresNext.js (Turbopack)3.670s4.012s0.342s81.06x
🌐 RedisNext.js (Turbopack)4.275s4.868s0.593s71.23x
💻 LocalExpress8.786s (-4.0%)9.022s (-10.0% 🟢)0.237s42.53x
💻 LocalNext.js (Turbopack)8.883s9.772s0.889s42.56x
💻 LocalNitro9.326s (+26.1% 🔺)10.024s (+25.0% 🔺)0.698s32.69x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.294s (+6.2% 🔺)5.002s (+6.1% 🔺)1.707s61.00x
▲ VercelNext.js (Turbopack)4.267s (+19.0% 🔺)6.135s (+24.6% 🔺)1.869s51.30x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)0.699s1.005s0.305s601.00x
🐘 PostgresNext.js (Turbopack)0.769s1.007s0.238s601.10x
🐘 PostgresExpress0.801s (-12.5% 🟢)1.007s (-6.7% 🟢)0.206s601.15x
🐘 PostgresNitro0.829s (-14.4% 🟢)1.007s (-18.5% 🟢)0.178s601.19x
💻 LocalNext.js (Turbopack)0.841s1.021s0.180s591.20x
💻 LocalExpress0.977s (-3.4%)1.076s (-30.3% 🟢)0.098s561.40x
💻 LocalNitro1.015s (+50.6% 🔺)1.628s (+62.1% 🔺)0.613s371.45x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro10.274s (+2.1%)12.099s (+5.9% 🔺)1.825s51.00x
▲ VercelNext.js (Turbopack)10.675s (+7.4% 🔺)12.483s (+1.0%)1.808s51.04x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.674s2.006s0.332s451.00x
🐘 PostgresNext.js (Turbopack)1.861s2.030s0.169s451.11x
🐘 PostgresExpress1.920s (-13.7% 🟢)2.077s (-31.0% 🟢)0.157s441.15x
🐘 PostgresNitro1.957s (-15.4% 🟢)2.176s (-27.8% 🟢)0.219s421.17x
💻 LocalNext.js (Turbopack)2.613s3.007s0.395s301.56x
💻 LocalExpress2.984s (-1.6%)3.341s (-9.4% 🟢)0.357s271.78x
💻 LocalNitro3.007s (+37.1% 🔺)3.547s (+18.0% 🔺)0.541s261.80x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro32.892s (+9.6% 🔺)34.652s (+8.7% 🔺)1.761s31.00x
▲ VercelNext.js (Turbopack)34.160s (+5.3% 🔺)51.070s (+51.8% 🔺)16.910s21.04x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)3.329s4.009s0.680s301.00x
🐘 PostgresNext.js (Turbopack)3.777s4.011s0.234s301.13x
🐘 PostgresExpress3.894s (-13.3% 🟢)4.184s (-16.6% 🟢)0.289s291.17x
🐘 PostgresNitro3.982s (-16.9% 🟢)4.404s (-13.6% 🟢)0.422s281.20x
💻 LocalNext.js (Turbopack)8.432s9.017s0.585s142.53x
💻 LocalExpress9.097s (+3.3%)9.555s (+4.3%)0.459s132.73x
💻 LocalNitro9.155s (+26.4% 🔺)9.864s (+23.1% 🔺)0.710s132.75x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro87.838s (+1.9%)104.355s (+19.6% 🔺)16.517s21.00x
▲ VercelNext.js (Turbopack)88.131s (+2.3%)90.390s (+2.2%)2.259s21.00x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)0.258s1.008s0.750s601.00x
🐘 PostgresNitro0.270s (-14.9% 🟢)1.008s (~)0.738s601.05x
🐘 PostgresExpress0.270s (-8.7% 🟢)1.008s (~)0.738s601.05x
🌐 RedisNext.js (Turbopack)0.365s1.004s0.640s601.42x
💻 LocalNext.js (Turbopack)0.551s1.004s0.453s602.14x
💻 LocalExpress0.595s (+4.7%)1.004s (~)0.410s602.31x
💻 LocalNitro0.598s (+6.7% 🔺)1.022s (+1.7%)0.423s592.32x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro1.834s (+9.2% 🔺)3.825s (+15.9% 🔺)1.992s161.00x
▲ VercelNext.js (Turbopack)2.568s (+35.7% 🔺)9.530s (+154.0% 🔺)6.962s121.40x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)0.481s1.019s0.538s891.00x
🐘 PostgresExpress0.493s (-10.0% 🟢)1.008s (~)0.515s901.03x
🐘 PostgresNitro0.498s (-14.7% 🟢)1.008s (~)0.511s901.04x
🌐 RedisNext.js (Turbopack)1.207s2.006s0.799s452.51x
💻 LocalNitro2.505s (+6.9% 🔺)3.009s (~)0.503s305.21x
💻 LocalNext.js (Turbopack)2.539s3.009s0.470s305.28x
💻 LocalExpress2.567s (+4.7%)3.009s (~)0.442s305.34x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.344s (+11.3% 🔺)5.095s (+14.5% 🔺)1.751s181.00x
▲ VercelNext.js (Turbopack)4.913s (+51.5% 🔺)6.649s (+40.7% 🔺)1.736s141.47x
▲ VercelExpress⚠️missing----

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Next.js (Turbopack)0.760s1.008s0.247s1201.00x
🐘 PostgresNitro0.767s (-20.7% 🟢)1.008s (-27.9% 🟢)0.241s1201.01x
🐘 PostgresExpress0.774s (-15.8% 🟢)1.008s (-18.7% 🟢)0.234s1201.02x
🌐 RedisNext.js (Turbopack)2.757s3.084s0.327s393.63x
💻 LocalNext.js (Turbopack)10.553s11.027s0.473s1113.88x
💻 LocalExpress11.228s (+1.9%)11.937s (+2.3%)0.708s1114.76x
💻 LocalNitro11.257s (+13.4% 🔺)12.029s (+16.1% 🔺)0.771s1014.80x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)7.133s (-77.8% 🟢)8.902s (-73.6% 🟢)1.769s141.00x
▲ VercelNitro7.220s (~)8.773s (+1.1%)1.554s141.01x
▲ VercelExpress⚠️missing----

🔍 Observability: Next.js (Turbopack) | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.175s1.001s0.011s1.017s0.842s101.00x
🌐 RedisNext.js (Turbopack)0.183s1.000s0.001s1.008s0.825s101.05x
🐘 PostgresExpress0.195s (-12.5% 🟢)0.996s (~)0.002s (+15.4% 🔺)1.011s (~)0.816s101.11x
🐘 PostgresNext.js (Turbopack)0.199s1.000s0.004s1.014s0.814s101.14x
🐘 PostgresNitro0.204s (-15.0% 🟢)0.995s (~)0.001s (-27.8% 🟢)1.011s (~)0.807s101.17x
💻 LocalNitro0.208s (+49.5% 🔺)1.003s (~)0.012s (+19.6% 🔺)1.018s (~)0.810s101.19x
💻 LocalExpress0.211s (+2.1%)1.003s (~)0.011s (-5.9% 🟢)1.017s (~)0.806s101.20x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro1.779s (+15.1% 🔺)2.497s (-4.5%)0.810s (+76.9% 🔺)3.996s (+11.2% 🔺)2.217s101.00x
▲ VercelNext.js (Turbopack)1.926s (+17.8% 🔺)2.945s (+0.9%)0.662s (+65.8% 🔺)4.322s (+11.6% 🔺)2.396s101.08x
▲ VercelExpress⚠️missing-----

🔍 Observability: Nitro | Next.js (Turbopack)

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)0.487s1.000s0.003s1.011s0.524s601.00x
🐘 PostgresExpress0.593s (-15.7% 🟢)1.003s (~)0.013s (+103.1% 🔺)1.033s (~)0.440s591.22x
🐘 PostgresNitro0.602s (-17.0% 🟢)1.004s (~)0.004s (-37.7% 🟢)1.023s (-0.7%)0.421s591.24x
🐘 PostgresNext.js (Turbopack)0.611s1.007s0.004s1.025s0.414s591.25x
💻 LocalNext.js (Turbopack)0.666s1.008s0.009s1.023s0.357s591.37x
💻 LocalNitro0.724s (+27.6% 🔺)1.009s (~)0.010s (+4.6%)1.024s (~)0.299s591.49x
💻 LocalExpress0.734s (+2.3%)1.009s (~)0.009s (-5.3% 🟢)1.022s (~)0.288s591.51x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)5.132s (+12.4% 🔺)6.256s (+11.9% 🔺)0.242s (+7.6% 🔺)13.240s (+104.9% 🔺)8.108s51.00x
▲ VercelNitro5.363s (~)6.372s (+3.7%)0.403s (-35.9% 🟢)7.632s (+1.2%)2.269s81.05x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)0.897s1.034s0.000s1.039s0.141s581.00x
🐘 PostgresNext.js (Turbopack)0.948s1.132s0.000s1.141s0.193s531.06x
🐘 PostgresExpress0.956s (-14.8% 🟢)1.271s (-28.6% 🟢)0.000s (-63.8% 🟢)1.286s (-28.9% 🟢)0.331s471.06x
🐘 PostgresNitro0.965s (-17.5% 🟢)1.298s (-35.0% 🟢)0.000s (-2.2%)1.315s (-35.0% 🟢)0.350s461.08x
💻 LocalNitro1.224s (+8.0% 🔺)2.020s (~)0.000s (+50.0% 🔺)2.023s (~)0.799s301.36x
💻 LocalExpress1.246s (+3.6%)2.020s (~)0.000s (-42.9% 🟢)2.023s (~)0.777s301.39x
💻 LocalNext.js (Turbopack)1.270s2.020s0.000s2.024s0.754s301.42x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.626s (+3.0%)4.889s (+5.1% 🔺)0.004s (+Infinity% 🔺)5.632s (+8.2% 🔺)2.006s111.00x
▲ VercelNitro3.756s (+24.1% 🔺)4.924s (+25.5% 🔺)0.002s (+1045.5% 🔺)5.654s (+27.8% 🔺)1.898s111.04x
▲ VercelExpress⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Nitro

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

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.603s2.002s0.000s2.007s0.403s301.00x
🐘 PostgresNext.js (Turbopack)1.831s2.106s0.000s2.115s0.284s291.14x
🐘 PostgresExpress1.840s (-10.6% 🟢)2.258s (-8.8% 🟢)0.000s (NaN%)2.271s (-9.1% 🟢)0.431s271.15x
🐘 PostgresNitro1.853s (-18.2% 🟢)2.223s (-24.7% 🟢)0.000s (-100.0% 🟢)2.235s (-24.7% 🟢)0.382s271.16x
💻 LocalNitro3.585s (+3.6%)4.300s (+6.7% 🔺)0.000s (-61.5% 🟢)4.304s (+6.7% 🔺)0.720s152.24x
💻 LocalNext.js (Turbopack)3.632s4.230s0.001s4.235s0.604s152.27x
💻 LocalExpress3.670s (+8.3% 🔺)4.032s (-1.6%)0.000s (+250.0% 🔺)4.036s (-1.6%)0.366s152.29x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro4.200s (+21.3% 🔺)5.030s (+20.0% 🔺)0.000s (+Infinity% 🔺)5.900s (+24.3% 🔺)1.700s111.00x
▲ VercelNext.js (Turbopack)5.104s (+14.0% 🔺)6.017s (+11.1% 🔺)0.000s (+10.0% 🔺)6.656s (+11.9% 🔺)1.552s101.22x
▲ VercelExpress⚠️missing-----

🔍 Observability: Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)15/21
🐘 PostgresNext.js (Turbopack)13/21
▲ VercelNitro16/21
Fastest World by Framework

Winner determined by most benchmark wins

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

📋 View full workflow run


Some benchmark jobs failed:

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

Check the workflow run for details.

@github-actions

github-actionsBot commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production813067880
✅ 💻 Local Development7820178960
✅ 📦 Local Production7820178960
✅ 🐘 Local Postgres7820178960
✅ 🪟 Windows720880
❌ 🌍 Community Worlds1185630204
✅ 📋 Other198042240
Total3547566814284

❌ Failed Tests

🌍 Community Worlds (56 failed)

mongodb (3 failed):

  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KMEKZQ811N46GD5AK5RW1HD9
  • webhookWorkflow | wrun_01KMEKZZZNPHAFY93GWGMA33E4
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KMEM7DRXD1RYA1E11HJTJFPW

redis (2 failed):

  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KMEKZQ811N46GD5AK5RW1HD9
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KMEM7DRXD1RYA1E11HJTJFPW

turso (51 failed):

  • addTenWorkflow | wrun_01KMEKYFN28CV699JDRY8EVENQ
  • addTenWorkflow | wrun_01KMEKYFN28CV699JDRY8EVENQ
  • wellKnownAgentWorkflow (.well-known/agent) | wrun_01KMEKZV5Y7H35GS496CWE2PWY
  • should work with react rendering in step
  • promiseAllWorkflow | wrun_01KMEKYP9VT3R0SKBHBMF46WF1
  • promiseRaceWorkflow | wrun_01KMEKYV2NHKMK1PT16PHGC0TE
  • promiseAnyWorkflow | wrun_01KMEKYYRJ4XWER78HC3BC88RH
  • importedStepOnlyWorkflow | wrun_01KMEM07SDMHGNED3K1Z8822HA
  • hookWorkflow | wrun_01KMEKZB3KDGNSRDK1ZP1ZWCS2
  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KMEKZQ811N46GD5AK5RW1HD9
  • webhookWorkflow | wrun_01KMEKZZZNPHAFY93GWGMA33E4
  • sleepingWorkflow | wrun_01KMEM07AP1ZHHP0B1Y28XPTPF
  • parallelSleepWorkflow | wrun_01KMEM0M62BA11GZH9N30PA01V
  • nullByteWorkflow | wrun_01KMEM0RNC851K91RM7QKG9J0A
  • workflowAndStepMetadataWorkflow | wrun_01KMEM0TS1NDEE94P4ZK643V6C
  • fetchWorkflow | wrun_01KMEM3MRRD40CZ6RZHVTBN0QE
  • promiseRaceStressTestWorkflow | wrun_01KMEM3RJJYY05KEDAWHC7YZCF
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion | wrun_01KMEM6SVSD36537C7RMSNZ7K2
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KMEM7DRXD1RYA1E11HJTJFPW
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running | wrun_01KMEM84D71052EEDK1NTW09QR
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars) | wrun_01KMEM8QRVX2ZN0ZY8KSFXYRFW
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument | wrun_01KMEM90F9YAZG8G7CZZR9KQWQ
  • closureVariableWorkflow - nested step functions with closure variables | wrun_01KMEM96EMKN1H5C673QHQM0RJ
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step | wrun_01KMEM9A0HFZKBNE055EZ9P5H4
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly | wrun_01KMEM9RJS5TNK0QPDKP86M45K
  • Calculator.calculate - static workflow method using static step methods from another class | wrun_01KMEM9XZANZTCTYJ5TEE55MRZ
  • AllInOneService.processNumber - static workflow method using sibling static step methods | wrun_01KMEMA4B58C28JE56JM1YFVQV
  • ChainableService.processWithThis - static step methods using this to reference the class | wrun_01KMEMAATKT6G9NX72T548BXF1
  • thisSerializationWorkflow - step function invoked with .call() and .apply() | wrun_01KMEMAH46HQ91CQS2CS66CDC6
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE | wrun_01KMEMAQHGV2M8JHE762DZMKC6
  • instanceMethodStepWorkflow - instance methods with "use step" directive | wrun_01KMEMAYB4Y9GXM4F81Y6CNJ6B
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context | wrun_01KMEMB8XA6D9RX061RHB81PKZ
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument | wrun_01KMEMBGE2CX800GQHW1H2HAJF
  • cancelRun - cancelling a running workflow | wrun_01KMEMBQWVXW1JB5P0HV743Q4P
  • cancelRun via CLI - cancelling a running workflow | wrun_01KMEMC0XTPK7JCE38TSAYE9N8
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep | wrun_01KMEMCCDFRYCKTFTV8TJ03YRG
  • sleepInLoopWorkflow - sleep inside loop with steps actually delays each iteration | wrun_01KMEMD0561BCM0VA669B3A6CS
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control) | wrun_01KMEMDAE0C841QKT27361H8BN

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro7307
✅ example7307
✅ express7307
✅ fastify7307
✅ hono7307
✅ nextjs-turbopack7802
✅ nextjs-webpack7802
✅ nitro7307
✅ nuxt7307
✅ sveltekit7307
✅ vite7307
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable66014
✅ express-stable66014
✅ fastify-stable66014
✅ hono-stable66014
✅ nextjs-turbopack-canary55025
✅ nextjs-turbopack-stable7208
✅ nextjs-webpack-canary55025
✅ nextjs-webpack-stable7208
✅ nitro-stable66014
✅ nuxt-stable66014
✅ sveltekit-stable66014
✅ vite-stable66014
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable66014
✅ express-stable66014
✅ fastify-stable66014
✅ hono-stable66014
✅ nextjs-turbopack-canary55025
✅ nextjs-turbopack-stable7208
✅ nextjs-webpack-canary55025
✅ nextjs-webpack-stable7208
✅ nitro-stable66014
✅ nuxt-stable66014
✅ sveltekit-stable66014
✅ vite-stable66014
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable66014
✅ express-stable66014
✅ fastify-stable66014
✅ hono-stable66014
✅ nextjs-turbopack-canary55025
✅ nextjs-turbopack-stable7208
✅ nextjs-webpack-canary55025
✅ nextjs-webpack-stable7208
✅ nitro-stable66014
✅ nuxt-stable66014
✅ sveltekit-stable66014
✅ vite-stable66014
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack7208
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev302
❌ mongodb5238
✅ redis-dev302
❌ redis5328
✅ turso-dev302
❌ turso4518
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable66014
✅ e2e-local-postgres-nest-stable66014
✅ e2e-local-prod-nest-stable66014

📋 View full workflow run

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Comment threadpackages/core/e2e/e2e.test.ts Outdated

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Here's my review of PR #1470:

Summary

This is a feature PR by @VaguelySerious that adds two new Streamer interface methods (getStreamChunks and getStreamInfo) across all world implementations, extends Run.getReadable() with a getTailIndex() helper, and adds initialStartIndex support to WorkflowChatTransport for resuming streams from the end.

No Blockers

CI is in excellent shape. The code changes are well-structured, well-documented, and backward-compatible.

CI Status

Passing:

  • All Vercel Production E2E tests: 11/11 frameworks pass (802 passed, 0 failed)
  • All Local Dev E2E tests: 12/12 pass (782 passed)
  • All Local Prod E2E tests: 12/12 pass
  • All Local Postgres E2E tests: 12/12 pass
  • Windows E2E tests pass
  • Unit tests pass (ubuntu + windows)
  • Vitest plugin tests pass
  • All Vercel deployments succeed (except workbench-nestjs-workflow - discussed below)
  • Benchmarks pass for local/postgres; no performance regressions
  • Docs code samples and links pass

Failing (non-blocking / pre-existing):

  • workbench-nestjs-workflow Vercel deployment fails - this is likely a pre-existing issue unrelated to streaming changes (the nest workbench has no streaming code changes in this PR)
  • Benchmark Vercel (nitro-v3) fails at ~30min - likely infra timeout, not PR-related
  • Community World (Turso) - 51 failures, pre-existing community world issue (same pattern as other PRs)
  • Community World (MongoDB/Redis) - 3/2 failures on hook-related tests, pre-existing

Code Review

1. Streamer interface additions (packages/world/src/interfaces.ts) - Clean

Two new methods added to the Streamer interface:

  • getStreamChunks(name, runId, options?) - cursor-based pagination for stream chunks
  • getStreamInfo(name, runId) - lightweight metadata (tailIndex, done flag)

New types (StreamChunk, StreamChunksResponse, GetChunksOptions, StreamInfoResponse) are well-documented in packages/world/src/shared.ts. The interface is additive and non-breaking.

2. world-vercel implementation (packages/world-vercel/src/streamer.ts) - Clean

Uses makeRequest + Zod schemas to call two new server endpoints:

  • GET /v2/runs/:runId/streams/:name/chunks?limit=&cursor=
  • GET /v2/runs/:runId/streams/:name/info

The StreamChunksResponseSchema correctly expects z.instanceof(Uint8Array) since CBOR handles binary natively. Clean and minimal.

3. world-local implementation (packages/world-local/src/streamer.ts) - Works but verbose

The implementation reads chunk files from disk, builds a file extension map, sorts by ULID, and paginates. One observation:

  • getStreamInfo reads every chunk file to count data chunks and detect EOF. For large streams this could be expensive. The getStreamChunks implementation has the same pattern. Both could potentially be optimized with a count-based approach, but for local dev this is fine.
  • The cursor encoding (base64-encoded JSON { i: index }) is a reasonable pattern.

4. world-postgres implementation (packages/world-postgres/src/streamer.ts) - Solid

Uses Drizzle ORM with proper query patterns:

  • Fetches limit + 1 rows to detect hasMore efficiently
  • Correctly handles the EOF row being mixed into results
  • Has a follow-up EOF check query when !streamDone && !hasMore to correctly report done status
  • Cursor encodes both chunk ID and running index ({ c: chunkId, i: baseIndex })

One minor cleanup: void fn() on line 407 (changing cleanups.forEach((fn) => fn()) to cleanups.forEach((fn) => void fn())) is a good lint fix to suppress floating promise warnings.

5. WorkflowReadableStream type and getTailIndex() (packages/core/src/runtime/run.ts) - Clean

Uses Object.assign to add getTailIndex() to the ReadableStream. This is a clean pattern that avoids subclassing. The type is properly exported through packages/workflow/src/api.ts.

6. WorkflowChatTransport changes (packages/ai/src/workflow-chat-transport.ts) - Well thought out

The negative initialStartIndex feature is carefully designed:

  • First request uses the explicit (potentially negative) startIndex
  • On retry, resolves to an absolute position using x-workflow-stream-tail-index header
  • Falls back to startIndex=0 with a warning if the header is missing
  • useExplicitStartIndex flag ensures the negative value is only used once, then subsequent retries use the running chunkIndex

The replayFromStart flag handles the edge case where the header is absent gracefully.

7. Tests - Good coverage

  • Two new e2e tests for getTailIndex and getStreamChunks pagination (skipped on local deployments since they require server-side endpoints)
  • Two new unit tests for WorkflowChatTransport covering negative initialStartIndex with and without the tail-index header

8. Docs - Thorough

Updated resumable-streams.mdx with:

  • New "Resuming from the end of the stream" section
  • Updated stream reconnection endpoint to include getTailIndex() and the x-workflow-stream-tail-index header
  • Callout about header requirement for negative initialStartIndex
  • API reference docs updated

9. Changeset - Present and correct

Includes @workflow/world, world-local, world-vercel, world-postgres, core, and ai as patch changes.

Minor Notes

  • The PR description is empty ("No description provided"). Worth adding a summary for posterity.
  • The getStreamChunks method uses _runId (unused) in local/postgres implementations since the stream name is already unique there. The vercel implementation passes it to the server endpoint. This is fine - the interface is designed for the vercel world's needs.

Verdict

No blockers or regressions. This is a well-implemented, additive feature with good test coverage and documentation. The only CI failures are pre-existing (nest deployment, community world Turso/MongoDB/Redis, nitro benchmark timeout). Ready to merge once it gets a review approval.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall this is a well-designed, additive feature. The negative initialStartIndex logic in the transport is carefully thought through, the interface additions are clean, and the docs/tests cover the new behavior well. A few issues worth addressing below.

GetChunksOptions,
StreamChunk,
StreamChunksResponse,
} from './shared.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The explicit export type { GetChunksOptions, StreamChunk, StreamChunksResponse } is redundant with the export type * from './shared.js' on line 34 — and it's also missing StreamInfoResponse. Since line 34 already re-exports all types from shared.js, you can just remove this block entirely (lines 39-43).

.select({ chunkId: streams.chunkId })
.from(streams)
.where(and(eq(streams.streamId, name), eq(streams.eof, false)))
.orderBy(asc(streams.chunkId));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

getStreamInfo fetches all data rows into memory just to count them. For streams with thousands of chunks this is wasteful — a SELECT COUNT(*) FROM streams WHERE streamId = $1 AND eof = false would be much cheaper. Consider using Drizzle's count() aggregate or a raw SQL count instead of selecting all chunk IDs.

hasMore,
done: streamDone,
};
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The file-listing + extension-mapping logic (~30 lines) is duplicated verbatim between getStreamChunks and getStreamInfo. Consider extracting a helper like listStreamDataFiles(name) that returns { dataFiles, fileExtMap, streamDone } to keep this DRY.

Comment threadpackages/world-local/src/streamer.ts Outdated
break;
}
dataFiles.push(file);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

getStreamChunks reads and deserializes every chunk file from disk on every call (to separate data files from the EOF marker), regardless of the cursor position. For a stream with 10k chunks, page 2 still reads all 10k files.

Two possible improvements:

  1. Only read files starting from the cursor position (the ULID sort gives chronological order, so you can skip earlier files)
  2. At minimum, stop reading once you have enough files past the cursor — you only need startIndex + limit data files plus potentially the EOF marker

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Deeper review: testing, docs, observability, edge cases, and performance

Testing gaps

world-local and world-postgres have zero unit tests for the new getStreamChunks and getStreamInfo methods. The only coverage comes from two e2e tests that are skipIf(isLocalDeployment()), so local dev never exercises these paths. Cursor pagination logic (invalid cursors, out-of-range cursors, single-item pages), empty stream behavior (tailIndex = -1), and mid-flight streams (calling before workflow completes) are all untested.

The transport unit tests cover the happy path for negative initialStartIndex but miss: NaN/malformed x-workflow-stream-tail-index header values (the !Number.isNaN guard is untested), positive explicit startIndex override, boundary clamping when magnitude exceeds stream length, and multi-retry chains where the header varies across attempts.

Documentation gaps

  1. building-a-world.mdx still shows the old Streamer interface without getStreamChunks or getStreamInfo. Custom world implementers won't know these methods are required.
  2. No API reference for WorkflowReadableStream or getTailIndex() — the get-run.mdx page documents WorkflowReadableStreamOptions but not the new return type.
  3. Changeset mentions getStreamInfo but omits getStreamChunks.

Observability

Existing storage methods are wrapped with instrumentObject() for automatic OTEL spans (e.g. world.runs.get, world.events.create). Streamer methods — both existing and new — are not instrumented at all. No spans, no semantic conventions for stream names/indices, and no structured logging in any world implementation's streaming code. The only observability signal is the console.warn in WorkflowChatTransport for missing headers. This is consistent with the pre-existing pattern (streaming was never instrumented), but it means production debugging of pagination issues, tail-index mismatches, or reconnection failures will be blind.

Edge cases and performance (see inline comments below)

Several specific issues are called out inline.

break;
}
dataRows.push(row);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Edge case: EOF row appearing mid-batch. The query fetches limit + 1 rows ordered by chunkId without filtering out EOF rows. If the EOF row sorts between data rows (e.g. due to concurrent writes or clock skew), the loop breaks early on row.eof, and subsequent data rows in the result set are silently dropped.

Then hasMore = !streamDone && dataRows.length > limit evaluates to false (because streamDone = true), so the client sees done: true, hasMore: false even though there were more data rows in the fetch.

This is probably fine in practice since the EOF ULID is generated after all data chunks, but it's worth noting the implicit ordering assumption. Adding eq(streams.eof, false) to the main query and doing a separate EOF check (like the lookahead on line 250) would make this robust.

.select({ chunkId: streams.chunkId })
.from(streams)
.where(and(eq(streams.streamId, name), eq(streams.eof, false)))
.orderBy(asc(streams.chunkId));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Performance: O(N) memory to count rows. This SELECT chunkId FROM streams WHERE ... loads every non-EOF row into Node.js memory just to take .length. For a stream with 100k chunks, that's 100k objects allocated and immediately discarded.

Use a COUNT(*) aggregate instead:

const[{ count }]=awaitdrizzle.select({count: sql<number>`count(*)`}).from(streams).where(and(eq(streams.streamId,name),eq(streams.eof,false)));return{tailIndex: (count??0)-1,done: !!eofRow};

Comment threadpackages/world-local/src/streamer.ts Outdated
break;
}
dataFiles.push(file);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Performance: every page reads ALL chunk files.getStreamChunks deserializes every chunk file from disk (lines 286-296) to separate data files from the EOF marker, even for chunks that precede the cursor. Page 50 of a 10k-chunk stream still reads all 10k files.

getStreamInfo (line 372) has the exact same pattern — reads all files just to count them.

For local dev this is tolerable, but if these methods get called frequently (e.g. polling for tail index), the cost adds up. A lighter approach: since ULID sort gives chronological order, you could skip files before the cursor index and stop after collecting limit data files + EOF check.

const readable = run.getReadable();
const tailIndex = await readable.getTailIndex();

// outputStreamWorkflow writes 2 chunks to the default stream

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Testing gap: no empty-stream or mid-flight test. These tests only run after await run.returnValue (stream fully complete) and outputStreamWorkflow always writes exactly 2 chunks.

Missing scenarios:

  • Empty stream: What does getTailIndex() return when 0 chunks have been written? (should be -1 per the type docs)
  • Mid-flight stream: What does getTailIndex() / getStreamChunks() return while the workflow is still running and the stream isn't closed yet?
  • getStreamInfo directly: Only tested indirectly via getTailIndex() — no direct assertion on the done flag


warnSpy.mockRestore();
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Testing gap: malformed header values. The implementation has a !Number.isNaN(tailIndex) guard (transport line 362) but no test covers it. Consider adding a case where the header is "not-a-number"parseInt returns NaN, the guard should skip resolution, and the code should fall through without setting chunkIndex.

Also missing: a test for initialStartIndex: -1000 on a 5-chunk stream (should clamp to 0 via Math.max), and a test where a positive startIndex is passed via reconnectToStream options to override initialStartIndex.

const tailIndexHeader = res.headers.get('x-workflow-stream-tail-index');
if (tailIndexHeader !== null) {
const tailIndex = parseInt(tailIndexHeader, 10);
if (!Number.isNaN(tailIndex)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Edge case: NaN guard doesn't fall through cleanly. When parseInt returns NaN, the if (!Number.isNaN(tailIndex)) block is skipped, but replayFromStart is NOT set (that only happens in the else branch for tailIndexHeader === null). So on the next iteration, useExplicitStartIndex is false and replayFromStart is false, meaning the code falls through to chunkIndex — which is still 0 from initialization.

This accidentally works (retries from 0), but the intent is unclear. If the header is present but malformed, should it warn and set replayFromStart = true like the missing-header case? As-is, a malformed header silently degrades to replaying from 0 without the console warning.

GetChunksOptions,
StreamChunk,
StreamChunksResponse,
} from './shared.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Redundant + incomplete export. Line 34 already has export type * from './shared.js' which re-exports all types. This explicit block is redundant and also missing StreamInfoResponse. Just delete lines 39-43.

VaguelySeriousand others added 2 commits March 23, 2026 17:22
- world-postgres: use COUNT(*) in getStreamInfo instead of fetching all
rows; filter EOF rows in getStreamChunks query to prevent mid-batch
data loss from clock-skewed EOF rows
- world-local: skip files before cursor in getStreamChunks, read only
EOF byte in getStreamInfo and pre-cursor files
- WorkflowChatTransport: treat unparseable tail-index header same as
missing (set replayFromStart); add unit test for NaN header fallback
- e2e: add test for getTailIndex returning -1 on nonexistent stream
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- building-a-world.mdx: add getStreamChunks and getStreamInfo to
Streamer interface definition
- get-run.mdx: document WorkflowReadableStream and getTailIndex()
- Changeset: mention getStreamChunks alongside getStreamInfo
- world-vercel, world-local: wrap streamer with instrumentObject for
OTEL spans (world.streams.* prefix), matching storage instrumentation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fresh review after update

This update addresses all the critical issues from the previous round. Nice work cleaning this up.

Previously flagged — now resolved

  • Postgres getStreamInfo: Now uses COUNT(*) aggregate instead of fetching all rows into memory
  • Postgres getStreamChunks EOF mid-batch: Main query now filters eq(streams.eof, false), with a separate EOF check — no more risk of EOF silently dropping data rows
  • Transport NaN guard: Missing and malformed headers now both flow through the same else branch — replayFromStart = true + console.warn in both cases
  • world-local getStreamChunks perf: No longer deserializes every chunk — pre-cursor files use isEofChunk() (byte check only), and the loop stops early after collecting enough data
  • world-local getStreamInfo: Also uses isEofChunk() instead of full deserialization
  • Changeset: Now mentions both getStreamChunks() and getStreamInfo()
  • Docs: building-a-world.mdx includes the new Streamer methods with explanatory text. get-run.mdx has a WorkflowReadableStream section with getTailIndex() reference.
  • E2e tests: Added tailIndex returns -1 test using a nonexistent namespace. Added NaN header unit test.
  • OTEL: Both world-vercel and world-local now wrap the streamer with instrumentObject('world.streams', ...)
  • Redundant export: StreamInfoResponse added to the explicit export list in world/src/index.ts

Minor remaining items (non-blocking)

  1. world-local file-listing boilerplate still duplicated — the ~20-line block that lists files by extension, builds the fileExtMap, and filters by stream name is copy-pasted between getStreamChunks (lines 266-286) and getStreamInfo (lines 367-389). Extracting a listChunkFilesForStream(name) helper would reduce maintenance surface.

  2. world-postgres streamer not instrumentedworld-vercel and world-local both wrap their streamer with instrumentObject('world.streams', ...), giving the new methods automatic OTEL spans. world-postgres spreads the streamer directly without instrumentation (packages/world-postgres/src/index.ts:49). Pre-existing inconsistency, but worth noting since this PR adds new methods that would benefit from tracing.

  3. No unit tests for world-local or world-postgres implementations of getStreamChunks/getStreamInfo. The cursor pagination logic (invalid cursors, boundary conditions, EOF detection) is only tested via e2e tests that are skipped in local dev. Consider adding unit tests in a follow-up.

dataCount++;
}

return { tailIndex: dataCount - 1, done: streamDone };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: The file-listing + extension-mapping block (~20 lines) is duplicated between getStreamChunks (lines 266-286) and getStreamInfo (lines 367-389). A shared listChunkFilesForStream(name) helper returning { chunkFiles, fileExtMap } would keep these two methods DRY.

@ghostghost mentioned this pull request Mar 23, 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.

3 participants

@VaguelySerious@pranaygp@karthikscale3