Skip to content

perf(core): parallel inline steps + optimistic lazy step start - #2516

Merged
VaguelySerious merged 9 commits into
mainfrom
peter/inline-step-batch-optimistic
Jun 18, 2026
Merged

perf(core): parallel inline steps + optimistic lazy step start#2516
VaguelySerious merged 9 commits into
mainfrom
peter/inline-step-batch-optimistic

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Jun 18, 2026

Copy link
Copy Markdown
Member

Two latency wins on top of lazy inline step start (#2478), both inside the core runtime.

1. Inline up to N steps in parallel

Previously the owned-inline path ran exactly one step inline per suspension and queued the rest, so a Promise.all([a(), b(), c()]) fan-out paid a queue round-trip per extra branch before making progress.

  • handleSuspension now defers step_created for up to WORKFLOW_MAX_INLINE_STEPS (default 3) steps and returns them as lazyInlineSteps.
  • The runtime runs that batch inline in parallel (Promise.all), each via its own lazy step_started; steps beyond the cap keep their eager step_created and are queued exactly as before.
  • Per-step result aggregation: retry/throttled steps are re-queued individually; we only loop back to replay once every inline step reaches a terminal state. A lone throttled inline step still delays redelivery of the orchestrator message (single-step backpressure contract preserved).
  • The inline-delta fast path is kept single-step only — with >1 inline step each writes its own events, so a per-write delta would be partial.
  • Clamped to 1..16. WORKFLOW_MAX_INLINE_STEPS=1 reproduces prior behavior exactly (kill-switch).

2. Optimistic inline start

executeStep already holds the step input locally on the inline path, so it doesn't need the step_started round-trip to begin.

  • With WORKFLOW_OPTIMISTIC_INLINE_START (default on), it fires step_startedwithout awaiting and runs the body against locally-synthesized attempt-1 state, reconciling the in-flight start just before the terminal write:
    • lost create-claim (409) / gone / throttled / too-early → discard the body result and surface skipped/gone/throttled/retry.
    • transient (non-translatable) failure → propagate for redelivery (same as the await path).
    • success → write the terminal event (start is awaited first, so the log stays created → started → completed).

Safety

  • Exactly-one terminal write preserved — still gated by the awaited step_started create-claim; losers return skipped.
  • Bounded to attempt 1 — only brand-new (!hasCreatedEvent) steps are lazy; retried steps take the normal await-then-run path with the real attempt counter.
  • Wider double-execution — racing handlers can both run side effects before one wins. Explicit accepted tradeoff: inline step bodies must be idempotent; WORKFLOW_OPTIMISTIC_INLINE_START=0 restores await-first.

Build two latency wins on top of lazy inline step start (#2478), both
client-side only (no world/backend changes):
1. Inline up to WORKFLOW_MAX_INLINE_STEPS (default 3) steps per suspension,
in parallel. The suspension handler defers step_created for the first N
uncreated steps (lazyInlineSteps) and the runtime runs them via Promise.all
of lazy step_started events; steps beyond the cap keep their eager
step_created and are queued as before. The inline-delta fast path stays
single-step only. WORKFLOW_MAX_INLINE_STEPS=1 reproduces prior behavior.
2. Optimistic inline start (WORKFLOW_OPTIMISTIC_INLINE_START, default on):
executeStep fires step_started without awaiting and runs the body against
locally-synthesized attempt-1 state, reconciling the in-flight start before
the terminal write. A lost create-claim (409) / gone / throttle discards the
body result; a transient failure propagates for redelivery. Exactly-one
terminal write is preserved; bodies may run more than once under contention
so inline steps must be idempotent (disable with =0).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@VaguelySerious
VaguelySerious requested a review from a team as a code ownerJune 18, 2026 19:04
@changeset-bot

changeset-botBot commented Jun 18, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ba626b6

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

This PR includes changesets to release 16 packages
NameType
workflowMinor
@workflow/coreMinor
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@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

@vercel

vercelBot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

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

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro0.038s (-6.2% 🟢)1.006s (~)0.968s101.00x
💻 LocalExpress0.042s (-3.5%)1.006s (~)0.964s101.10x
💻 LocalNext.js (Turbopack)0.058s (-12.7% 🟢)1.006s (~)0.948s101.51x
🐘 PostgresExpress0.058s (-19.8% 🟢)1.013s (~)0.955s101.53x
🐘 PostgresNitro0.058s (-7.3% 🟢)1.013s (~)0.954s101.53x
🐘 PostgresNext.js (Turbopack)0.067s (-4.1%)1.012s (~)0.945s101.77x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express0.372s (-1.3%)2.517s (+9.7% 🔺)2.144s101.00x
▲ VercelNext.js (Turbopack)0.432s (+48.3% 🔺)2.779s (+47.0% 🔺)2.348s101.16x
▲ VercelNitro0.445s (+18.6% 🔺)2.065s (-8.1% 🟢)1.620s101.19x

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

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro1.085s (~)2.006s (~)0.920s101.00x
💻 LocalExpress1.087s (-1.3%)2.007s (~)0.920s101.00x
🐘 PostgresExpress1.098s (-0.8%)2.010s (~)0.912s101.01x
🐘 PostgresNitro1.118s (~)2.012s (~)0.894s101.03x
💻 LocalNext.js (Turbopack)1.123s (-2.2%)2.006s (~)0.883s101.03x
🐘 PostgresNext.js (Turbopack)1.150s (+0.8%)2.010s (~)0.859s101.06x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro1.739s (-24.9% 🟢)3.737s (-6.4% 🟢)1.998s101.00x
▲ VercelNext.js (Turbopack)1.828s (+3.6%)3.463s (-6.7% 🟢)1.635s101.05x
▲ VercelExpress1.834s (-5.2% 🟢)3.632s (-1.2%)1.797s101.05x

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

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro10.472s (-0.6%)11.022s (~)0.550s31.00x
🐘 PostgresExpress10.516s (~)11.017s (~)0.500s31.00x
🐘 PostgresNitro10.521s (~)11.018s (~)0.497s31.00x
💻 LocalExpress10.522s (~)11.022s (~)0.500s31.00x
💻 LocalNext.js (Turbopack)10.783s (~)11.023s (~)0.240s31.03x
🐘 PostgresNext.js (Turbopack)10.876s (~)11.016s (~)0.140s31.04x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro13.524s (-28.7% 🟢)15.201s (-28.7% 🟢)1.677s21.00x
▲ VercelNext.js (Turbopack)13.931s (-2.7%)16.137s (-1.0%)2.206s21.03x
▲ VercelExpress14.809s (-18.0% 🟢)16.765s (-16.0% 🟢)1.956s21.10x

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

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro13.635s (~)14.029s (~)0.394s51.00x
🐘 PostgresExpress13.669s (-1.3%)14.018s (~)0.349s51.00x
💻 LocalExpress13.677s (-0.6%)14.027s (~)0.350s51.00x
🐘 PostgresNitro13.759s (-0.6%)14.020s (~)0.261s51.01x
💻 LocalNext.js (Turbopack)14.296s (~)15.029s (~)0.734s41.05x
🐘 PostgresNext.js (Turbopack)14.348s (-0.6%)15.014s (~)0.666s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)23.497s (-33.7% 🟢)26.269s (-28.6% 🟢)2.773s31.00x
▲ VercelExpress24.027s (-0.8%)26.656s (+1.8%)2.629s31.02x
▲ VercelNitro24.972s (-19.8% 🟢)27.410s (-15.8% 🟢)2.438s31.06x

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

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro12.215s (+1.2%)13.026s (+1.0%)0.810s71.00x
🐘 PostgresExpress12.245s (-1.6%)13.019s (~)0.774s71.00x
💻 LocalExpress12.425s (-1.0%)13.025s (~)0.600s71.02x
🐘 PostgresNitro12.579s (~)13.023s (~)0.444s71.03x
💻 LocalNext.js (Turbopack)13.614s (~)14.026s (~)0.411s71.11x
🐘 PostgresNext.js (Turbopack)13.734s (-0.9%)14.024s (-1.0%)0.289s71.12x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express31.602s (-9.9% 🟢)34.210s (-7.3% 🟢)2.608s31.00x
▲ VercelNext.js (Turbopack)31.849s (-9.5% 🟢)34.012s (-7.9% 🟢)2.163s31.01x
▲ VercelNitro33.484s (-14.0% 🟢)35.851s (-12.4% 🟢)2.367s31.06x

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

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.169s (-3.7%)2.007s (~)0.838s151.00x
🐘 PostgresNitro1.188s (-0.9%)2.008s (~)0.821s151.02x
💻 LocalNitro1.209s (+3.3%)2.006s (~)0.797s151.03x
💻 LocalExpress1.211s (+3.9%)2.006s (~)0.795s151.04x
🐘 PostgresNext.js (Turbopack)1.264s (-1.7%)2.007s (~)0.743s151.08x
💻 LocalNext.js (Turbopack)1.336s (+3.1%)2.006s (~)0.670s151.14x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.760s (+40.9% 🔺)6.098s (+48.8% 🔺)2.338s51.00x
▲ VercelExpress3.955s (+78.3% 🔺)5.767s (+48.4% 🔺)1.812s61.05x
▲ VercelNitro4.319s (+48.4% 🔺)6.788s (+53.9% 🔺)2.469s51.15x

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

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.247s (-12.5% 🟢)2.007s (-16.1% 🟢)0.760s151.00x
🐘 PostgresNitro1.309s (-4.1%)2.008s (-20.0% 🟢)0.699s151.05x
🐘 PostgresNext.js (Turbopack)1.578s (-3.7%)2.151s (-10.1% 🟢)0.573s141.27x
💻 LocalExpress2.151s (+32.2% 🔺)2.392s (+19.2% 🔺)0.240s131.73x
💻 LocalNitro2.183s (+40.7% 🔺)2.509s (+24.8% 🔺)0.325s121.75x
💻 LocalNext.js (Turbopack)2.297s (+20.3% 🔺)3.008s (+31.2% 🔺)0.711s101.84x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro4.497s (+11.1% 🔺)6.358s (+11.5% 🔺)1.861s51.00x
▲ VercelExpress5.492s (+38.7% 🔺)7.557s (+33.2% 🔺)2.065s41.22x
▲ VercelNext.js (Turbopack)5.585s (+24.4% 🔺)7.897s (+21.9% 🔺)2.312s41.24x

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

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.494s (-15.3% 🟢)3.109s (-20.0% 🟢)1.615s101.00x
🐘 PostgresNitro1.510s (-5.4% 🟢)3.762s (-6.2% 🟢)2.253s81.01x
🐘 PostgresNext.js (Turbopack)2.502s (-20.8% 🟢)3.213s (-25.3% 🟢)0.711s101.67x
💻 LocalNitro4.660s (+36.2% 🔺)5.012s (+25.0% 🔺)0.352s63.12x
💻 LocalExpress5.508s (+26.0% 🔺)5.847s (+20.1% 🔺)0.339s63.69x
💻 LocalNext.js (Turbopack)6.546s (+13.4% 🔺)7.017s (+12.9% 🔺)0.471s54.38x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro4.683s (-23.5% 🟢)7.188s (-13.8% 🟢)2.505s51.00x
▲ VercelExpress5.737s (+4.2%)7.916s (~)2.178s41.23x
▲ VercelNext.js (Turbopack)6.452s (+14.7% 🔺)8.175s (+5.9% 🔺)1.723s41.38x

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

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.178s (-3.2%)2.007s (~)0.828s151.00x
🐘 PostgresNitro1.200s (-0.9%)2.007s (~)0.807s151.02x
💻 LocalNitro1.204s (~)2.006s (~)0.803s151.02x
💻 LocalExpress1.210s (-23.3% 🟢)2.007s (~)0.797s151.03x
🐘 PostgresNext.js (Turbopack)1.330s (+3.8%)2.074s (+3.3%)0.744s151.13x
💻 LocalNext.js (Turbopack)1.384s (-1.1%)2.006s (~)0.623s151.17x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.459s (-31.1% 🟢)4.054s (-23.4% 🟢)1.595s81.00x
▲ VercelNext.js (Turbopack)2.953s (-20.6% 🟢)4.451s (-14.9% 🟢)1.499s71.20x
▲ VercelExpress3.193s (+37.4% 🔺)4.831s (+27.0% 🔺)1.639s71.30x

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

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.261s (-7.7% 🟢)2.007s (-13.3% 🟢)0.747s151.00x
🐘 PostgresNitro1.295s (-10.2% 🟢)2.008s (-16.1% 🟢)0.713s151.03x
🐘 PostgresNext.js (Turbopack)1.478s (-4.2%)2.009s (-9.6% 🟢)0.531s151.17x
💻 LocalNitro1.833s (+8.2% 🔺)2.148s (+7.0% 🔺)0.315s141.45x
💻 LocalExpress1.953s (+2.9%)2.508s (+16.7% 🔺)0.555s121.55x
💻 LocalNext.js (Turbopack)2.380s (+9.7% 🔺)3.010s (~)0.630s101.89x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.373s (+20.5% 🔺)5.378s (+28.6% 🔺)2.006s61.00x
▲ VercelExpress3.889s (+42.7% 🔺)5.587s (+22.3% 🔺)1.698s61.15x
▲ VercelNext.js (Turbopack)3.942s (-11.7% 🟢)5.824s (-5.4% 🟢)1.882s61.17x

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

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.419s (-21.2% 🟢)3.677s (-8.3% 🟢)2.259s91.00x
🐘 PostgresNitro1.452s (-19.5% 🟢)3.885s (-9.6% 🟢)2.433s81.02x
🐘 PostgresNext.js (Turbopack)2.662s (-34.5% 🟢)3.348s (-24.7% 🟢)0.686s91.88x
💻 LocalNitro5.221s (+16.3% 🔺)6.013s (+20.0% 🔺)0.792s53.68x
💻 LocalExpress5.644s (+23.0% 🔺)6.016s (+20.0% 🔺)0.371s63.98x
💻 LocalNext.js (Turbopack)6.891s (+18.7% 🔺)7.515s (+17.1% 🔺)0.624s44.86x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.960s (-53.0% 🟢)6.027s (-41.1% 🟢)2.068s51.00x
▲ VercelNext.js (Turbopack)4.146s (+9.6% 🔺)6.047s (+15.6% 🔺)1.901s61.05x
▲ VercelNitro4.297s (-69.0% 🟢)6.552s (-58.2% 🟢)2.255s51.09x

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

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.533s (-6.7% 🟢)1.023s (+1.7%)0.490s591.00x
💻 LocalNitro0.544s (+8.7% 🔺)1.005s (~)0.461s601.02x
💻 LocalExpress0.566s (-9.4% 🟢)1.005s (-1.7%)0.439s601.06x
🐘 PostgresNitro0.595s (~)1.041s (~)0.446s581.12x
🐘 PostgresNext.js (Turbopack)0.796s (-4.7%)1.006s (-1.7%)0.210s601.49x
💻 LocalNext.js (Turbopack)0.826s (-6.9% 🟢)1.005s (-3.3%)0.178s601.55x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.907s (-14.1% 🟢)5.710s (-11.9% 🟢)1.804s111.00x
▲ VercelNitro4.038s (-38.9% 🟢)5.833s (-30.0% 🟢)1.795s111.03x
▲ VercelNext.js (Turbopack)4.517s (-28.4% 🟢)6.530s (-19.8% 🟢)2.013s101.16x

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

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro1.386s (+16.7% 🔺)2.006s (~)0.620s451.00x
🐘 PostgresExpress1.392s (+2.6%)2.123s (+4.7%)0.731s431.00x
🐘 PostgresNitro1.407s (+1.3%)2.053s (+1.2%)0.646s441.01x
💻 LocalExpress1.437s (-3.5%)2.006s (~)0.569s451.04x
🐘 PostgresNext.js (Turbopack)1.878s (-3.2%)2.053s (-1.1%)0.175s441.35x
💻 LocalNext.js (Turbopack)2.018s (-3.9%)2.563s (-14.8% 🟢)0.544s361.46x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express10.506s (-39.9% 🟢)12.436s (-35.1% 🟢)1.930s81.00x
▲ VercelNitro10.528s (-10.9% 🟢)12.787s (-9.7% 🟢)2.259s81.00x
▲ VercelNext.js (Turbopack)11.989s (-11.8% 🟢)14.012s (-8.6% 🟢)2.023s71.14x

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

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express2.489s (-8.2% 🟢)3.085s (-0.8%)0.596s391.00x
🐘 PostgresNitro2.668s (-4.7%)3.058s (-3.4%)0.390s401.07x
💻 LocalNitro3.093s (+13.3% 🔺)3.494s (+8.4% 🔺)0.401s351.24x
💻 LocalExpress3.138s (-1.9%)3.945s (-1.6%)0.807s311.26x
🐘 PostgresNext.js (Turbopack)3.733s (-3.5%)4.012s (-1.6%)0.279s301.50x
💻 LocalNext.js (Turbopack)4.195s (-3.4%)5.011s (~)0.816s241.69x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro20.534s (-21.9% 🟢)23.134s (-17.7% 🟢)2.600s61.00x
▲ VercelExpress21.961s (-15.8% 🟢)24.577s (-12.3% 🟢)2.616s51.07x
▲ VercelNext.js (Turbopack)25.840s (+3.4%)27.959s (+4.4%)2.119s51.26x

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

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.206s (-10.8% 🟢)1.006s (~)0.800s601.00x
🐘 PostgresNitro0.224s (-4.7%)1.006s (~)0.782s601.09x
🐘 PostgresNext.js (Turbopack)0.241s (-18.1% 🟢)1.006s (~)0.765s601.17x
💻 LocalExpress0.337s (-23.4% 🟢)1.005s (~)0.668s601.63x
💻 LocalNitro0.361s (-3.1%)1.004s (~)0.643s601.75x
💻 LocalNext.js (Turbopack)0.585s (~)1.022s (+1.7%)0.437s592.84x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro1.960s (+51.9% 🔺)3.765s (+31.0% 🔺)1.804s161.00x
▲ VercelExpress2.232s (+44.0% 🔺)4.076s (+42.0% 🔺)1.843s151.14x
▲ VercelNext.js (Turbopack)2.469s (+32.0% 🔺)4.483s (+15.3% 🔺)2.013s141.26x

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

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.302s (-15.1% 🟢)1.006s (-3.3%)0.704s901.00x
🐘 PostgresNitro0.354s (+3.4%)1.030s (+1.2%)0.676s881.17x
🐘 PostgresNext.js (Turbopack)0.437s (-18.9% 🟢)1.018s (-9.0% 🟢)0.581s891.45x
💻 LocalExpress1.990s (-4.8%)2.536s (-4.5%)0.546s366.59x
💻 LocalNitro2.028s (+33.0% 🔺)2.468s (+14.7% 🔺)0.441s376.72x
💻 LocalNext.js (Turbopack)2.509s (+7.6% 🔺)3.148s (+2.3%)0.639s298.31x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.611s (-10.8% 🟢)4.513s (-5.9% 🟢)1.903s201.00x
▲ VercelNitro20.412s (+643.4% 🔺)22.358s (+367.6% 🔺)1.947s177.82x
▲ VercelNext.js (Turbopack)28.605s (+839.9% 🔺)30.668s (+540.1% 🔺)2.063s1210.96x

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

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.511s (-10.1% 🟢)1.068s (-12.2% 🟢)0.557s1131.00x
🐘 PostgresNitro0.552s (-2.8%)1.078s (-12.5% 🟢)0.526s1121.08x
🐘 PostgresNext.js (Turbopack)1.697s (-35.2% 🟢)2.552s (-26.5% 🟢)0.855s483.32x
💻 LocalNitro9.525s (+50.9% 🔺)10.195s (+51.2% 🔺)0.669s1218.64x
💻 LocalExpress10.013s (+20.5% 🔺)10.860s (+22.3% 🔺)0.847s1219.60x
💻 LocalNext.js (Turbopack)10.467s (-2.7%)11.480s (-0.8%)1.013s1120.49x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.705s (-50.5% 🟢)4.757s (-32.9% 🟢)2.052s261.00x
▲ VercelNitro4.306s (-18.4% 🟢)6.162s (-12.2% 🟢)1.856s201.59x
▲ VercelNext.js (Turbopack)5.045s (+4.2%)7.027s (+7.5% 🔺)1.982s181.87x

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

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.150s (-2.4%)1.998s (~)0.001s (+9.1% 🔺)2.010s (~)0.860s101.00x
💻 LocalNitro1.159s (+3.7%)2.005s (~)0.010s (-64.4% 🟢)2.017s (-1.0%)0.858s101.01x
💻 LocalExpress1.163s (+1.1%)2.005s (~)0.012s (+22.8% 🔺)2.020s (~)0.857s101.01x
🐘 PostgresNitro1.170s (+0.6%)1.997s (~)0.001s (+40.0% 🔺)2.011s (~)0.841s101.02x
💻 LocalNext.js (Turbopack)1.213s (~)2.004s (~)0.012s (-2.4%)2.020s (~)0.807s101.05x
🐘 PostgresNext.js (Turbopack)1.332s (+8.3% 🔺)2.102s (+5.0% 🔺)0.001s (-15.4% 🟢)2.111s (+5.0% 🔺)0.779s101.16x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.944s (+22.6% 🔺)4.116s (+11.9% 🔺)4.689s (+532.8% 🔺)9.323s (+91.3% 🔺)6.379s101.00x
▲ VercelNitro3.044s (+15.3% 🔺)4.543s (+19.3% 🔺)4.338s (+446.8% 🔺)9.385s (+85.6% 🔺)6.341s101.03x
▲ VercelNext.js (Turbopack)3.089s (+22.5% 🔺)4.472s (+17.1% 🔺)4.632s (+473.7% 🔺)9.599s (+88.8% 🔺)6.510s101.05x

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

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro1.550s (+7.8% 🔺)2.010s (~)0.013s (-27.5% 🟢)2.025s (~)0.475s301.00x
🐘 PostgresExpress1.553s (-2.0%)2.002s (~)0.005s (+11.4% 🔺)2.026s (~)0.472s301.00x
💻 LocalExpress1.589s (+1.4%)2.010s (~)0.013s (+0.8%)2.025s (~)0.436s301.03x
🐘 PostgresNitro1.595s (+1.1%)2.003s (~)0.005s (-0.6%)2.025s (~)0.430s301.03x
💻 LocalNext.js (Turbopack)1.736s (-0.8%)2.009s (~)0.012s (-3.2%)2.024s (~)0.288s301.12x
🐘 PostgresNext.js (Turbopack)1.744s (-2.3%)2.010s (~)0.005s (~)2.024s (~)0.281s301.13x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express6.533s (+2.3%)7.955s (+1.9%)0.367s (+105.4% 🔺)8.807s (+4.1%)2.273s71.00x
▲ VercelNext.js (Turbopack)6.938s (-18.3% 🟢)8.455s (-13.9% 🟢)0.279s (+5.9% 🔺)9.235s (-12.7% 🟢)2.297s71.06x
▲ VercelNitro7.227s (+0.9%)8.765s (+1.2%)0.236s (~)9.475s (+0.7%)2.248s71.11x

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

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.718s (-6.0% 🟢)1.048s (+2.2%)0.000s (-75.0% 🟢)1.059s (~)0.341s571.00x
🐘 PostgresNitro0.782s (+1.2%)1.066s (+1.7%)0.000s (-1.8%)1.089s (~)0.306s561.09x
🐘 PostgresNext.js (Turbopack)0.857s (-15.9% 🟢)1.155s (-21.1% 🟢)0.000s (+18.3% 🔺)1.161s (-21.1% 🟢)0.304s521.19x
💻 LocalNitro1.343s (+24.9% 🔺)2.014s (+9.6% 🔺)0.000s (-72.5% 🟢)2.016s (+9.6% 🔺)0.673s301.87x
💻 LocalExpress1.348s (-1.0%)2.014s (~)0.001s (+233.3% 🔺)2.016s (~)0.668s301.88x
💻 LocalNext.js (Turbopack)1.565s (+3.5%)2.013s (~)0.000s (+22.2% 🔺)2.016s (~)0.451s302.18x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.842s (+2.3%)5.079s (-5.6% 🟢)0.000s (-100.0% 🟢)5.563s (-5.1% 🟢)1.722s111.00x
▲ VercelNext.js (Turbopack)3.980s (+15.4% 🔺)5.323s (+11.6% 🔺)0.000s (-100.0% 🟢)5.836s (+10.9% 🔺)1.855s111.04x
▲ VercelExpress4.465s (+47.3% 🔺)5.889s (+29.0% 🔺)0.000s (NaN%)6.362s (+26.3% 🔺)1.897s101.16x

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

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

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.621s (~)2.253s (+9.2% 🔺)0.000s (NaN%)2.268s (+8.6% 🔺)0.647s271.00x
🐘 PostgresNitro1.717s (+10.7% 🔺)2.304s (+8.0% 🔺)0.000s (+11.5% 🔺)2.318s (+8.0% 🔺)0.601s261.06x
🐘 PostgresNext.js (Turbopack)1.940s (-8.1% 🟢)2.483s (-4.0%)0.000s (-100.0% 🟢)2.518s (-3.0%)0.578s251.20x
💻 LocalNitro3.546s (+60.7% 🔺)4.090s (+49.1% 🔺)0.001s (+11.7% 🔺)4.099s (+48.9% 🔺)0.553s152.19x
💻 LocalExpress3.621s (+23.6% 🔺)4.161s (+13.4% 🔺)0.000s (-54.7% 🟢)4.165s (+13.4% 🔺)0.544s152.23x
💻 LocalNext.js (Turbopack)3.830s (+32.1% 🔺)4.456s (+32.7% 🔺)0.001s (+128.6% 🔺)4.463s (+32.7% 🔺)0.633s142.36x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.574s (+6.2% 🔺)7.140s (+10.6% 🔺)0.000s (+Infinity% 🔺)7.644s (+10.4% 🔺)2.070s81.00x
▲ VercelNext.js (Turbopack)6.305s (-13.0% 🟢)7.819s (-6.9% 🟢)0.000s (-100.0% 🟢)8.329s (-5.8% 🟢)2.024s81.13x
▲ VercelExpress6.529s (+14.7% 🔺)8.255s (+14.2% 🔺)0.000s (NaN%)8.724s (+13.5% 🔺)2.195s71.17x

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

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNitro18/21
🐘 PostgresExpress21/21
▲ VercelNitro10/21
Fastest World by Framework

Winner determined by most benchmark wins

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

Worlds:

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

📋 View full workflow run

@github-actions

github-actionsBot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production144202301672
✅ 💻 Local Development190902192128
✅ 📦 Local Production190902192128
✅ 🐘 Local Postgres189502332128
✅ 🪟 Windows15200152
✅ 📋 Other88501791064
Total8192010809272

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro125027
✅ example125027
✅ express125027
✅ fastify125027
✅ hono125027
✅ nextjs-turbopack14903
✅ nextjs-webpack14903
✅ nitro125027
✅ nuxt125027
✅ sveltekit14408
✅ vite125027
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable127025
✅ express-stable127025
✅ fastify-stable127025
✅ hono-stable127025
✅ nextjs-turbopack-canary133019
✅ nextjs-turbopack-stable-lazy-discovery-disabled15200
✅ nextjs-turbopack-stable-lazy-discovery-enabled15200
✅ nextjs-webpack-canary133019
✅ nextjs-webpack-stable-lazy-discovery-disabled15200
✅ nextjs-webpack-stable-lazy-discovery-enabled15200
✅ nitro-stable127025
✅ nuxt-stable127025
✅ sveltekit-stable14606
✅ vite-stable127025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable127025
✅ express-stable127025
✅ fastify-stable127025
✅ hono-stable127025
✅ nextjs-turbopack-canary133019
✅ nextjs-turbopack-stable-lazy-discovery-disabled15200
✅ nextjs-turbopack-stable-lazy-discovery-enabled15200
✅ nextjs-webpack-canary133019
✅ nextjs-webpack-stable-lazy-discovery-disabled15200
✅ nextjs-webpack-stable-lazy-discovery-enabled15200
✅ nitro-stable127025
✅ nuxt-stable127025
✅ sveltekit-stable14606
✅ vite-stable127025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable126026
✅ express-stable126026
✅ fastify-stable126026
✅ hono-stable126026
✅ nextjs-turbopack-canary132020
✅ nextjs-turbopack-stable-lazy-discovery-disabled15101
✅ nextjs-turbopack-stable-lazy-discovery-enabled15101
✅ nextjs-webpack-canary132020
✅ nextjs-webpack-stable-lazy-discovery-disabled15101
✅ nextjs-webpack-stable-lazy-discovery-enabled15101
✅ nitro-stable126026
✅ nuxt-stable126026
✅ sveltekit-stable14507
✅ vite-stable126026
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15200
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable127025
✅ e2e-local-dev-tanstack-start-127025
✅ e2e-local-postgres-nest-stable126026
✅ e2e-local-postgres-tanstack-start-126026
✅ e2e-local-prod-nest-stable127025
✅ e2e-local-prod-tanstack-start-127025
✅ e2e-vercel-prod-tanstack-start125027

📋 View full workflow run

Comment threadpackages/core/src/runtime.ts
… input-less background step, but since a throttle rejects the lazy `step_started` before the step is created, the re-delivery sends a bare `step_started` that the world rejects with "Step not found", causing repeated redeliveries until MAX_QUEUE_DELIVERIES marks the step (and run) failed.
This commit fixes the issue reported at packages/core/src/runtime.ts:1484
## Bug
In `packages/core/src/runtime.ts`, the inline parallel-step batch aggregation handled a `throttled` result like this:
```ts
} else if (r.type === 'throttled') {
if (lazyInlineSteps.length === 1) {
soleThrottleTimeout = r.timeoutSeconds; // re-delivers orchestrator (safe)
} else {
toRetry.push({ step: s, delaySeconds: r.timeoutSeconds }); // re-queues as background step (broken)
}
}
```
`toRetry` entries are re-queued as workflow-queue messages carrying only `{ runId, stepId, stepName }` (no input). They are consumed by the background-step branch (~line 510), which calls `executeStep` **without** `lazyStepInput`.
### Why a throttled step has no input to recover
A `throttled` result is produced by `startErrorToResult` in `step-executor.ts` when the lazy `step_started` (the atomic create-claim) is rejected with a `ThrottleError`. That means the step was **never created** — there is no `step_created` event and no step entity.
When the background-step branch later runs `executeStep` without `lazyStepInput`, `executeStep` sends a bare `step_started` with `eventData: { stepName }` (no `input`). In `packages/world-local/src/storage/events-storage.ts`, the lazy-start detector requires both `stepName` **and** `input`:
```ts
const lazyStepStart = data.eventType === 'step_started' && ... &&
(data.eventData as { input?: unknown }).input !== undefined;
...
if (!validatedStep && !lazyStepStart) {
throw new WorkflowWorldError(`Step "${data.correlationId}" not found`);
}
```
So a bare `step_started` for a never-created step throws `WorkflowWorldError("Step not found")`. That error is not one of the translatable errors (Throttle/RunExpired/EntityConflict/TooEarly), so `executeStep` rethrows it, the queue message is redelivered, and it fails repeatedly until `MAX_QUEUE_DELIVERIES`, at which point the step is marked failed and the run can fail.
### Trigger
Two or more steps run inline in parallel (`lazyInlineSteps.length > 1`) and at least one of them is rate-limited on its lazy `step_started` create-claim (returns `throttled`). The single-step path was safe only because it returned `soleThrottleTimeout`, re-delivering the orchestrator so the step is re-attempted inline **with** its input on replay.
## Fix
Generalized the single-step `soleThrottleTimeout` contract to all batch sizes. A throttled step is no longer pushed into `toRetry`; instead the batch tracks the longest throttle backoff (`throttleTimeout = Math.max(...)`) and returns `{ timeoutSeconds: throttleTimeout }`, deferring redelivery of the orchestrator message. On replay:
- still-throttled steps are re-attempted **inline with their input** (their `step_created` is deferred anew),
- `retry` steps from the same batch are re-dispatched as background steps by the suspension handler (they already exist, so a bare `step_started` is valid; their `retryAfter` is honored),
- terminal steps (completed/failed/skipped/gone) are observed from their events and not re-run,
- pending background ops are flushed via `waitUntil` before the replay reads them.
Because the orchestrator replay drives all remaining work, the early `return` deliberately skips re-queuing `toRetry`, avoiding double-dispatch of those steps. `retry` results remain safe to re-queue as background steps because a retry implies `step_started` already succeeded and the step exists — only `throttled` (which fails on the create-claim itself) leaves the step uncreated.
Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: VaguelySerious <mittgfu@gmail.com>
Optimistic inline start can run a step body more than once when handlers race
for the create-claim. For non-idempotent steps this is unsafe — notably, two
concurrent runs of a step writing to the workflow stream (e.g. an AI agent
streaming tokens) can interleave and corrupt the stream. Make the optimization
opt-in (enable with WORKFLOW_OPTIMISTIC_INLINE_START=1); parallel inline
execution (WORKFLOW_MAX_INLINE_STEPS) stays on by default.
Co-Authored-By: Claude Opus 4.8 (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.

Review — parallel inline steps + optimistic lazy step start

Nice, carefully-reasoned change. The core safety properties hold up under inspection:

  • Default config is safe. With WORKFLOW_OPTIMISTIC_INLINE_START now off, the parallel inline path still awaits each lazy step_started create-claim before running a body, so exactly-one body execution is preserved per step (the loser 409s → skipped). Only the opt-in optimistic path widens double-execution, and that's documented + kill-switchable.
  • Optimistic path is sound where it matters. Every reachable terminal write on the optimistic path is preceded by reconcileOptimisticStart() (completed @ step-executor.ts:611, failed/retrying @ :685); the attempt > maxRetries+1 early-fail path is unreachable for a synthesized attempt-1 step; and optimisticStartSettled attaches its .then(ok, err) synchronously so a fast step_started rejection can't surface as an unhandledRejection. The attempt-1 synthesis is correct because lazy candidates are always brand-new (!hasCreatedEvent).
  • Throttle fix is correct. Deferring the orchestrator (rather than re-queuing a never-created step as an input-less background step) is the right call, and I confirmed world-local rejects a bare step_started for an uncreated step with Step "<id>" not found (events-storage.ts:782-784), exactly as the comment claims. Good regression test.

A few things below — one process item and three low/medium inline notes. None are blocking on their own; the inline #1 (retry + pendingOps) is the one most worth a second look.

⚠️ CI has not run against the head commit

The last green Tests run (27784908704, 19:43Z) was against 01290dfbefore the WORKFLOW_OPTIMISTIC_INLINE_START default-off flip (145a848, 20:41Z), which changed both constants.ts and constants.test.ts. The 583d3ca "re-trigger CI" commit has no Tests / Lint / Benchmark check-runs attached (only DCO/Socket/Vercel). So the default-off behavior and its updated tests are currently unverified by CI. I read both diffs and they're mutually consistent, but please get Tests green on the head SHA before merge — the empty commit didn't actually re-trigger them.

Notes

  • Behavioral default change (non-blocking): parallel inline (default 3) now runs up to 3 step bodies concurrently in one invocation vs. 1 before — ~3× peak per-handler memory/CPU for a clean Promise.all fan-out. Bounded and documented; just flagging the default shift.

Details inline.

// continuation so waitUntil can flush them before the
// next replay reads them. This matches V1 behavior where
// each step ran in a separate function invocation.
if (anyPendingOps) {

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.

Double-dispatch when a batch has both a retry step and a completed step with hasPendingOps.

Walk the ordering for a batch like Promise.all([streamingStep(), flakyStep()]) where streamingStep completes with unflushed ops and flakyStep throws and is retried:

  1. L1533 queues the retry message for flakyStep — delayed by delaySeconds, noidempotencyKey.
  2. This anyPendingOps block queues a plain continuation and returns. That continuation replays immediately, re-encounters the still-retryingflakyStep, and the dispatch loop (~L1315) re-dispatches it — immediate, with idempotencyKey.

So flakyStep is dispatched twice via two un-deduped messages. For a RetryableError the world's retryAfter guard TooEarlys the premature one (just a wasted round-trip). But a plain-Error retry writes step_retryingwithout a retryAfter (step-executor.ts:882 only sets it for RetryableError), so there's no world-side early-start guard — the immediate re-dispatch starts the step and runs the body before the intended (≈1s) backoff, and can run concurrently with the delayed message for a slow body.

Exactly-one terminal write still holds and steps must tolerate re-execution, so this isn't a correctness violation — but it diverges from the single-step path (which queued the retry and returned, one execution honoring the delay) and adds redundant queue traffic + an early body run. Worth considering whether the anyPendingOps continuation should avoid re-driving steps already in toRetry (e.g. fold the two toRetry checks together and let the explicit retry messages be the sole driver when ops are pending).

return {
timeoutSeconds: stepResult.timeoutSeconds,
};
if (throttleTimeout !== undefined) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This early return (correct for the backpressure contract) sits before the anyPendingOps flush continuation below. So a batch containing both a throttled step and a completed step with hasPendingOps won't queue the explicit flush continuation — the stream flush then relies solely on waitUntil completing within this invocation before the post-backoff redelivery replays and reads the stream. In practice the throttle backoff (≥1s) exceeds the 500ms flush window so it's fine, but a one-line note here would save the next reader from re-deriving that this is safe.

if (queueItem.correlationId === lazyInlineCorrelationId) {
lazyInlineStep = {
if (lazyInlineCorrelationIds.has(queueItem.correlationId)) {
lazyInlineSteps.push({

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.

lazyInlineSteps is populated by .push() inside the parallel ops closures (awaited by Promise.all(ops) at ~L564), so its final order reflects dehydrateStepArguments completion order, not stepItems order. Correctness is unaffected — the runtime zips stepResults to this array by index via Promise.all, so index alignment holds regardless of order, and which steps are deferred is fixed deterministically by the .slice(0, getMaxInlineSteps()) selection above.

The only snag is the test defers up to getMaxInlineSteps() uncreated steps asserting result.lazyInlineSteps.map(s => s.correlationId)toEqual(['s1','s2','s3']), which assumes stepItems order. With empty args dehydration resolves in start order so it passes today, but it could flake if dehydration ever does real async work (encryption / large-arg stream uploads). Consider asserting on a set, or pushing in index order to make it robust.

pranaygpand others added 2 commits June 18, 2026 14:31
…ch-optimistic
* origin/main:
Show pending runs as gray animated stripes in trace viewer (#2520)
[world-vercel] Route v4 event requests through global fetch (#2514)
[core] Send workflowName with step events (#2511)
# Conflicts:
#	packages/core/src/runtime/step-executor.ts
Two review follow-ups:
- suspension-handler: collect deferred lazy inline steps into a Map keyed by
correlationId and rebuild lazyInlineSteps in deterministic
lazyInlineCorrelationIds (ordered first-N slice) order after the concurrent
dehydration ops settle, instead of push()-ing in ops-completion order. No
behavior change (results are zipped by index in the runtime), but the batch
order is now stable regardless of dehydration timing.
- runtime: document why the throttle-defer early return is safe to skip the
anyPendingOps flush continuation (throttle backoff >= 1s always exceeds the
<= 500ms + waitUntil in-invocation flush window).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment threadpackages/core/src/runtime.ts Outdated
Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@github-actionsgithub-actionsBot mentioned this pull request Jun 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 84ccd40 (AI decision).

This commit builds directly on the main-only lazy inline step start feature (#2478): it extends lazyInlineStep/lazyStepInput and the lazy step_started create-on-the-fly contract into parallel inline batches and optimistic start. Verified on origin/stable that none of this foundation exists — there is no lazyInlineStep/lazyStepInput, no getMaxInlineSteps/WORKFLOW_OPTIMISTIC_INLINE_START, and packages/core/src/runtime/step-executor.ts (and executeStep) is absent — so the change cannot apply without the prerequisite main-only work.

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

84ccd40ea3e12ba6b67967a4ff9f0b84b2393c48

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.

2 participants

@VaguelySerious@pranaygp