Skip to content

Replace hook.hasConflict with hook.getConflict() returning the conflicting Run - #2373

Merged
TooTallNate merged 5 commits into
mainfrom
pranaygp/hook-get-conflict
Jun 12, 2026
Merged

Replace hook.hasConflict with hook.getConflict() returning the conflicting Run#2373
TooTallNate merged 5 commits into
mainfrom
pranaygp/hook-get-conflict

Conversation

@pranaygp

@pranaygppranaygp commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #2015. hook.hasConflict's boolean told the workflow that a conflict exists but not which run owns the token, so the duplicate run couldn't act on it. This replaces it with hook.getConflict(), a promise that resolves with:

  • null once the hook registration is committed (hook_created recorded), or
  • a Run handle for the conflicting run when another active hook owns the token.

Like hasConflict, awaiting getConflict() suspends the workflow to commit the hook registration without waiting for payload data. But on conflict, the workflow now has the active owner in hand and can choose its own idempotency strategy in code:

using hook=createHook({token: `order:${orderId}`});constconflict=awaithook.getConflict();if(conflict){// any of:return{dedupedTo: conflict.runId};// route caller to the ownerconststatus=awaitconflict.status;// inspect before decidingreturnawaitconflict.returnValue;// adopt the owner's resultawaitconflict.cancel();// supersede the owner and continue}

This is deliberately code-driven rather than a fixed set of ID-reuse policies (cf. Temporal's WorkflowIdReusePolicy / WorkflowIdConflictPolicy).

Implementation

  • The workflow-mode create-hook module exposes the bundle's compiled Run class on a well-known global symbol (WORKFLOW_RUN_CLASS). The bundle's Run is the SWC-plugin-compiled variant whose accessors (status, returnValue, cancel(), …) are durable step proxies — safe and deterministic inside workflow code. The host-side hook event consumer constructs the conflicting Run from the hook_conflict event's conflictingRunId (Expose conflicting run id on hook conflicts #2012) using that class.
  • getConflict never resolves with anything but a real Run or null. In the degenerate cases where a Run cannot be constructed — a hook_conflict event persisted by an old world without conflictingRunId, or a context that never loaded the workflow-mode create-hook module — it rejects with HookConflictError (which still carries the token) instead of resolving with a { runId }-shaped impostor. Note: the { runId } shape remains the documented v4 return value, since v4 has no native Run serialization — v4 docs direct users to getRun(conflict.runId) inside a step.
  • Repeated await hook.getConflict() calls observe the same Run instance; both fast-paths settle through promiseQueue to keep resolution order aligned with the event log.
  • Awaiting the hook payload on a conflicted token still rejects with HookConflictError.

Docs

Updated createHook() / createWebhook() references, hooks foundations, and event-sourcing pages (v4 + v5; v5 documents the Run return, v4 documents { runId }). A follow-up to #2011 adds the full conflict-handling strategy guide to the run-idempotency docs.

Validation

  • cd packages/core && pnpm typecheck && pnpm vitest run src/ (1155 tests)
  • pnpm test:docs
  • Hook e2e suite against local nextjs-turbopack: all hookGetConflict* tests pass, including the conflict test asserting the duplicate run resolves the actual owner's runId and reads await conflict.status === 'running' via a durable step.

…ull>)
hasConflict's boolean didn't expose WHICH run owns the token, so the
duplicate run couldn't act on the conflict. getConflict resolves with
null once registration commits, or with a Run handle for the conflicting
run — letting the workflow return/log the owner's runId, inspect its
status, await its result, or cancel it and continue, all in code.
The workflow-mode create-hook module exposes the bundle's compiled Run
class (durable step-proxy methods) on a well-known symbol so the host-
side hook consumer can construct the conflicting run inside the VM.
Contexts without the class (plain unit tests) fall back to a { runId }
object, which is also the documented v4 shape (no native Run
serialization in v4).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings June 11, 2026 23:11
@pranaygp
pranaygp requested a review from a team as a code ownerJune 11, 2026 23:11
@changeset-bot

changeset-botBot commented Jun 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0aa3cb0

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

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

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production137602191595
✅ 💻 Local Development181102192030
✅ 📦 Local Production181102192030
✅ 🐘 Local Postgres179702332030
✅ 🪟 Windows14500145
✅ 📋 Other83701781015
Total7777010688845

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro119026
✅ example119026
✅ express119026
✅ fastify119026
✅ hono119026
✅ nextjs-turbopack14302
✅ nextjs-webpack14302
✅ nitro119026
✅ nuxt119026
✅ sveltekit13807
✅ vite119026
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable120025
✅ express-stable120025
✅ fastify-stable120025
✅ hono-stable120025
✅ nextjs-turbopack-canary126019
✅ nextjs-turbopack-stable-lazy-discovery-disabled14500
✅ nextjs-turbopack-stable-lazy-discovery-enabled14500
✅ nextjs-webpack-canary126019
✅ nextjs-webpack-stable-lazy-discovery-disabled14500
✅ nextjs-webpack-stable-lazy-discovery-enabled14500
✅ nitro-stable120025
✅ nuxt-stable120025
✅ sveltekit-stable13906
✅ vite-stable120025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable120025
✅ express-stable120025
✅ fastify-stable120025
✅ hono-stable120025
✅ nextjs-turbopack-canary126019
✅ nextjs-turbopack-stable-lazy-discovery-disabled14500
✅ nextjs-turbopack-stable-lazy-discovery-enabled14500
✅ nextjs-webpack-canary126019
✅ nextjs-webpack-stable-lazy-discovery-disabled14500
✅ nextjs-webpack-stable-lazy-discovery-enabled14500
✅ nitro-stable120025
✅ nuxt-stable120025
✅ sveltekit-stable13906
✅ vite-stable120025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable119026
✅ express-stable119026
✅ fastify-stable119026
✅ hono-stable119026
✅ nextjs-turbopack-canary125020
✅ nextjs-turbopack-stable-lazy-discovery-disabled14401
✅ nextjs-turbopack-stable-lazy-discovery-enabled14401
✅ nextjs-webpack-canary125020
✅ nextjs-webpack-stable-lazy-discovery-disabled14401
✅ nextjs-webpack-stable-lazy-discovery-enabled14401
✅ nitro-stable119026
✅ nuxt-stable119026
✅ sveltekit-stable13807
✅ vite-stable119026
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack14500
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable120025
✅ e2e-local-dev-tanstack-start-120025
✅ e2e-local-postgres-nest-stable119026
✅ e2e-local-postgres-tanstack-start-119026
✅ e2e-local-prod-nest-stable120025
✅ e2e-local-prod-tanstack-start-120025
✅ e2e-vercel-prod-tanstack-start119026

📋 View full workflow run

@github-actions

github-actionsBot commented Jun 11, 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.042s (-1.9%)1.006s (~)0.965s101.00x
💻 LocalNitro0.046s (+7.8% 🔺)1.006s (~)0.960s101.10x
🐘 PostgresNitro0.065s (-1.5%)1.013s (~)0.948s101.56x
💻 LocalNext.js (Turbopack)0.066s (+5.2% 🔺)1.006s (~)0.940s101.59x
🐘 PostgresNext.js (Turbopack)0.072s (-4.3%)1.012s (~)0.941s101.72x
🐘 PostgresExpress0.080s (+30.0% 🔺)1.032s (+2.0%)0.952s101.92x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.244s (-35.3% 🟢)2.122s (-12.6% 🟢)1.878s101.00x
▲ VercelNitro0.260s (-31.1% 🟢)2.099s (~)1.839s101.07x
▲ VercelExpress0.268s (-1.1%)2.117s (-18.0% 🟢)1.850s101.10x

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

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.089s (-2.4%)2.011s (~)0.922s101.00x
💻 LocalExpress1.096s (~)2.007s (~)0.911s101.01x
💻 LocalNitro1.101s (+0.9%)2.006s (~)0.905s101.01x
🐘 PostgresNitro1.108s (~)2.009s (~)0.900s101.02x
💻 LocalNext.js (Turbopack)1.139s (+1.0%)2.007s (~)0.867s101.05x
🐘 PostgresNext.js (Turbopack)1.185s (+3.7%)2.011s (~)0.826s101.09x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express1.616s (-7.8% 🟢)3.302s (-11.0% 🟢)1.686s101.00x
▲ VercelNitro1.696s (+1.2%)3.485s (+1.0%)1.790s101.05x
▲ VercelNext.js (Turbopack)1.769s (+5.4% 🔺)3.307s (-9.1% 🟢)1.539s101.09x

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

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express10.496s (-0.9%)11.031s (~)0.535s31.00x
💻 LocalExpress10.500s (~)11.021s (~)0.521s31.00x
💻 LocalNitro10.535s (~)11.023s (~)0.488s31.00x
🐘 PostgresNitro10.568s (~)11.017s (~)0.450s31.01x
💻 LocalNext.js (Turbopack)10.794s (~)11.023s (~)0.229s31.03x
🐘 PostgresNext.js (Turbopack)10.842s (-1.6%)11.017s (-5.7% 🟢)0.175s31.03x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro13.610s (+2.0%)15.211s (~)1.601s21.00x
▲ VercelExpress14.017s (+4.5%)15.119s (-3.9%)1.102s21.03x
▲ VercelNext.js (Turbopack)14.111s (~)15.512s (-4.1%)1.401s21.04x

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

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express13.731s (-0.8%)14.028s (~)0.297s51.00x
💻 LocalNitro13.787s (~)14.027s (-1.4%)0.240s51.00x
🐘 PostgresExpress13.956s (+0.9%)14.418s (+2.8%)0.462s51.02x
🐘 PostgresNitro13.986s (+1.2%)14.220s (+1.4%)0.234s51.02x
💻 LocalNext.js (Turbopack)14.348s (-1.0%)15.030s (~)0.681s41.05x
🐘 PostgresNext.js (Turbopack)14.458s (~)15.020s (~)0.562s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro20.475s (-8.7% 🟢)22.157s (-8.0% 🟢)1.681s31.00x
▲ VercelExpress20.531s (-4.4%)21.964s (-4.8%)1.434s31.00x
▲ VercelNext.js (Turbopack)21.986s (+1.8%)23.788s (+1.9%)1.801s31.07x

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

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express12.391s (-0.9%)13.024s (~)0.633s71.00x
🐘 PostgresExpress12.417s (-2.6%)13.017s (~)0.600s71.00x
💻 LocalNitro12.529s (~)13.024s (~)0.495s71.01x
🐘 PostgresNitro12.730s (+1.2%)13.162s (+1.1%)0.433s71.03x
💻 LocalNext.js (Turbopack)13.654s (~)14.026s (~)0.372s71.10x
🐘 PostgresNext.js (Turbopack)14.084s (+0.8%)14.450s (+1.0%)0.366s71.14x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro29.382s (-7.3% 🟢)31.701s (-4.8%)2.319s31.00x
▲ VercelExpress29.422s (-0.8%)31.369s (+0.7%)1.947s31.00x
▲ VercelNext.js (Turbopack)30.219s (+5.4% 🔺)32.193s (+6.0% 🔺)1.973s31.03x

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

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.204s (~)2.015s (~)0.811s151.00x
🐘 PostgresNitro1.217s (+1.1%)2.008s (~)0.791s151.01x
💻 LocalExpress1.228s (+3.2%)2.006s (~)0.778s151.02x
💻 LocalNitro1.250s (+2.2%)2.006s (~)0.756s151.04x
💻 LocalNext.js (Turbopack)1.275s (-5.4% 🟢)2.006s (~)0.731s151.06x
🐘 PostgresNext.js (Turbopack)1.294s (+2.8%)2.008s (~)0.714s151.07x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.316s (-37.4% 🟢)3.726s (-28.1% 🟢)1.410s91.00x
▲ VercelExpress2.794s (+9.5% 🔺)4.184s (-7.0% 🟢)1.391s81.21x
▲ VercelNext.js (Turbopack)3.004s (-1.3%)4.488s (-2.3%)1.484s71.30x

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

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.367s (+8.6% 🔺)2.508s (+25.0% 🔺)1.141s121.00x
🐘 PostgresExpress1.422s (+12.6% 🔺)2.172s (+8.2% 🔺)0.750s141.04x
💻 LocalExpress1.755s (-4.7%)2.006s (-6.7% 🟢)0.251s151.28x
💻 LocalNext.js (Turbopack)1.834s (-2.4%)2.008s (-6.6% 🟢)0.174s151.34x
💻 LocalNitro1.853s (+8.9% 🔺)2.151s (+7.2% 🔺)0.298s141.36x
🐘 PostgresNext.js (Turbopack)1.855s (+32.4% 🔺)2.396s (+19.4% 🔺)0.541s131.36x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.168s (-28.3% 🟢)4.565s (-25.4% 🟢)1.397s81.00x
▲ VercelNext.js (Turbopack)3.520s (-98.9% 🟢)5.051s (-98.4% 🟢)1.531s61.11x
▲ VercelNitro4.477s (-28.2% 🟢)6.029s (-22.2% 🟢)1.552s51.41x

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

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.578s (+12.1% 🔺)3.762s (+87.3% 🔺)2.184s81.00x
🐘 PostgresExpress1.758s (+25.4% 🔺)3.577s (+78.1% 🔺)1.819s91.11x
🐘 PostgresNext.js (Turbopack)3.622s (+100.8% 🔺)4.583s (+99.5% 🔺)0.962s72.30x
💻 LocalExpress4.738s (-12.0% 🟢)5.514s (-10.8% 🟢)0.775s63.00x
💻 LocalNitro5.252s (~)5.846s (-2.8%)0.595s63.33x
💻 LocalNext.js (Turbopack)5.763s (+1.0%)6.216s (~)0.453s53.65x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro4.006s (-76.4% 🟢)5.907s (-67.7% 🟢)1.902s61.00x
▲ VercelExpress4.380s (-52.0% 🟢)6.113s (-46.2% 🟢)1.733s61.09x
▲ VercelNext.js (Turbopack)6.477s (+7.7% 🔺)7.965s (-5.6% 🟢)1.488s41.62x

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

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.212s (+1.8%)2.007s (~)0.794s151.00x
🐘 PostgresExpress1.254s (+3.5%)2.027s (+1.0%)0.773s151.03x
🐘 PostgresNext.js (Turbopack)1.295s (+2.5%)2.007s (~)0.712s151.07x
💻 LocalNext.js (Turbopack)1.377s (+1.9%)2.006s (~)0.629s151.14x
💻 LocalExpress1.560s (-5.9% 🟢)2.006s (-3.3%)0.446s151.29x
💻 LocalNitro1.603s (+6.3% 🔺)2.007s (~)0.404s151.32x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.267s (-12.1% 🟢)3.589s (-15.3% 🟢)1.323s91.00x
▲ VercelExpress2.338s (-13.4% 🟢)3.777s (-19.9% 🟢)1.439s81.03x
▲ VercelNext.js (Turbopack)2.688s (-34.5% 🟢)4.300s (-23.7% 🟢)1.613s71.19x

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

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.374s (+6.0% 🔺)2.149s (+6.9% 🔺)0.775s151.00x
🐘 PostgresNitro1.378s (+8.4% 🔺)2.223s (+10.7% 🔺)0.845s141.00x
🐘 PostgresNext.js (Turbopack)1.535s (+9.3% 🔺)2.296s (+14.3% 🔺)0.761s141.12x
💻 LocalNext.js (Turbopack)2.019s (-1.3%)2.591s (-13.9% 🟢)0.572s121.47x
💻 LocalNitro2.118s (-2.4%)2.675s (+3.2%)0.557s121.54x
💻 LocalExpress2.183s (+1.1%)2.735s (+2.2%)0.552s111.59x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.708s (-21.0% 🟢)4.034s (-20.9% 🟢)1.327s81.00x
▲ VercelNext.js (Turbopack)2.904s (-20.4% 🟢)4.585s (-12.1% 🟢)1.681s71.07x
▲ VercelNitro3.259s (-32.5% 🟢)4.713s (-26.4% 🟢)1.455s71.20x

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

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.633s (+17.6% 🔺)4.015s (+99.9% 🔺)2.383s81.00x
🐘 PostgresExpress1.778s (+26.9% 🔺)3.764s (+87.2% 🔺)1.985s81.09x
🐘 PostgresNext.js (Turbopack)3.795s (+109.9% 🔺)4.588s (+106.3% 🔺)0.793s72.32x
💻 LocalExpress4.915s (-22.7% 🟢)5.515s (-21.4% 🟢)0.600s63.01x
💻 LocalNext.js (Turbopack)5.698s (-5.9% 🟢)6.217s (-8.8% 🟢)0.518s53.49x
💻 LocalNitro6.163s (+13.4% 🔺)6.417s (+6.7% 🔺)0.254s53.77x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.482s (-31.8% 🟢)5.220s (-27.9% 🟢)1.738s61.00x
▲ VercelNitro3.723s (-46.8% 🟢)5.600s (-34.9% 🟢)1.877s61.07x
▲ VercelNext.js (Turbopack)4.865s (-39.4% 🟢)6.332s (-35.3% 🟢)1.467s51.40x

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

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.594s (-3.0%)1.005s (~)0.411s601.00x
💻 LocalNitro0.599s (~)1.005s (~)0.406s601.01x
🐘 PostgresNitro0.609s (+5.1% 🔺)1.040s (+3.4%)0.432s581.03x
🐘 PostgresExpress0.671s (+11.7% 🔺)1.147s (+13.9% 🔺)0.476s531.13x
🐘 PostgresNext.js (Turbopack)0.849s (-0.6%)1.006s (-1.7%)0.157s601.43x
💻 LocalNext.js (Turbopack)0.912s (+5.7% 🔺)1.075s (+5.2% 🔺)0.163s571.54x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.786s (+16.6% 🔺)7.078s (+8.6% 🔺)1.292s91.00x
▲ VercelNext.js (Turbopack)5.799s (+11.1% 🔺)7.275s (+6.0% 🔺)1.476s91.00x
▲ VercelExpress6.123s (-39.6% 🟢)7.604s (-35.4% 🟢)1.481s81.06x

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

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.390s (-3.2%)2.029s (~)0.639s451.00x
🐘 PostgresExpress1.504s (+4.9%)2.110s (+5.0% 🔺)0.605s431.08x
💻 LocalExpress1.510s (-2.8%)2.006s (-1.1%)0.496s451.09x
💻 LocalNitro1.528s (+1.5%)2.006s (~)0.479s451.10x
🐘 PostgresNext.js (Turbopack)1.999s (-1.5%)2.308s (-13.1% 🟢)0.309s401.44x
💻 LocalNext.js (Turbopack)2.117s (+0.8%)3.008s (~)0.892s301.52x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express14.919s (+3.8%)16.513s (+2.2%)1.594s61.00x
▲ VercelNitro15.432s (+17.8% 🔺)17.056s (+17.9% 🔺)1.624s61.03x
▲ VercelNext.js (Turbopack)15.850s (+19.2% 🔺)17.815s (+18.0% 🔺)1.965s61.06x

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

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.755s (-1.5%)3.085s (-2.6%)0.330s391.00x
🐘 PostgresExpress2.768s (-1.5%)3.311s (+5.5% 🔺)0.543s371.00x
💻 LocalExpress3.336s (+1.5%)4.042s (+0.8%)0.707s301.21x
💻 LocalNitro3.340s (+1.3%)4.010s (~)0.670s301.21x
🐘 PostgresNext.js (Turbopack)3.913s (-1.8%)4.110s (-5.1% 🟢)0.197s301.42x
💻 LocalNext.js (Turbopack)4.360s (~)5.011s (~)0.650s241.58x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express27.778s (+5.9% 🔺)30.392s (+7.7% 🔺)2.614s51.00x
▲ VercelNitro27.783s (-4.6%)30.056s (-2.4%)2.274s41.00x
▲ VercelNext.js (Turbopack)29.676s (-16.9% 🟢)31.852s (-15.0% 🟢)2.176s41.07x

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

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.252s (+14.8% 🔺)1.006s (~)0.754s601.00x
🐘 PostgresExpress0.264s (+13.3% 🔺)1.009s (~)0.745s601.05x
🐘 PostgresNext.js (Turbopack)0.319s (+15.0% 🔺)1.006s (~)0.687s601.27x
💻 LocalNitro0.417s (-3.0%)1.005s (-1.7%)0.588s601.66x
💻 LocalExpress0.453s (+9.7% 🔺)1.021s (+1.6%)0.568s591.80x
💻 LocalNext.js (Turbopack)0.553s (+1.3%)1.005s (~)0.452s602.20x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.055s (-20.0% 🟢)3.438s (-16.3% 🟢)1.383s181.00x
▲ VercelNitro2.234s (-20.9% 🟢)3.836s (-11.1% 🟢)1.602s161.09x
▲ VercelNext.js (Turbopack)2.376s (-46.7% 🟢)3.893s (-37.5% 🟢)1.517s161.16x

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

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.393s (+5.1% 🔺)1.036s (+3.0%)0.643s871.00x
🐘 PostgresNitro0.421s (+17.3% 🔺)1.029s (+2.2%)0.608s881.07x
🐘 PostgresNext.js (Turbopack)0.619s (+24.1% 🔺)1.133s (+12.5% 🔺)0.513s801.57x
💻 LocalNitro2.207s (+1.2%)2.715s (~)0.508s345.61x
💻 LocalExpress2.208s (+8.4% 🔺)2.737s (+7.9% 🔺)0.529s335.61x
💻 LocalNext.js (Turbopack)2.513s (+2.6%)3.225s (+2.5%)0.712s286.39x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.890s (-46.6% 🟢)4.303s (-39.2% 🟢)1.414s211.00x
▲ VercelNitro3.175s (-65.7% 🟢)4.598s (-57.7% 🟢)1.424s211.10x
▲ VercelNext.js (Turbopack)3.728s (-51.6% 🟢)5.309s (-43.4% 🟢)1.581s181.29x

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

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.711s (+2.2%)1.190s (+18.3% 🔺)0.479s1011.00x
🐘 PostgresNitro0.808s (+18.6% 🔺)1.340s (+33.2% 🔺)0.532s901.14x
🐘 PostgresNext.js (Turbopack)3.015s (+199.9% 🔺)3.798s (+104.8% 🔺)0.782s324.24x
💻 LocalExpress9.681s (+3.9%)10.113s (+2.4%)0.431s1213.61x
💻 LocalNitro9.892s (+6.6% 🔺)10.362s (+5.8% 🔺)0.470s1213.90x
💻 LocalNext.js (Turbopack)10.774s (-1.5%)11.482s (~)0.708s1115.14x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express6.916s (-63.4% 🟢)8.385s (-59.1% 🟢)1.469s151.00x
▲ VercelNitro7.160s (-62.8% 🟢)8.614s (-61.1% 🟢)1.454s151.04x
▲ VercelNext.js (Turbopack)7.650s (-65.4% 🟢)9.225s (-62.2% 🟢)1.575s141.11x

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

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.161s (-0.6%)2.005s (~)0.011s (-10.3% 🟢)2.018s (~)0.857s101.00x
🐘 PostgresExpress1.171s (-0.7%)2.002s (~)0.001s (-25.0% 🟢)2.011s (~)0.840s101.01x
🐘 PostgresNitro1.173s (~)1.998s (~)0.001s (~)2.010s (~)0.838s101.01x
💻 LocalNitro1.188s (+1.9%)2.005s (~)0.013s (+20.2% 🔺)2.020s (~)0.832s101.02x
💻 LocalNext.js (Turbopack)1.210s (~)2.004s (~)0.013s (+1.6%)2.021s (~)0.811s101.04x
🐘 PostgresNext.js (Turbopack)1.252s (+0.5%)2.002s (~)0.001s (-7.7% 🟢)2.011s (~)0.759s101.08x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.283s (+5.3% 🔺)3.012s (-6.0% 🟢)1.285s (+41.4% 🔺)4.735s (+3.4%)2.452s101.00x
▲ VercelNext.js (Turbopack)2.429s (+8.2% 🔺)3.474s (+7.5% 🔺)1.219s (+6.8% 🔺)5.101s (+4.5%)2.672s101.06x
▲ VercelNitro2.460s (+0.5%)3.395s (+1.0%)1.287s (+35.6% 🔺)5.115s (+7.3% 🔺)2.654s101.08x

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

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.574s (~)2.011s (~)0.013s (+11.9% 🔺)2.026s (~)0.452s301.00x
🐘 PostgresNitro1.591s (-1.3%)2.004s (~)0.005s (-0.7%)2.027s (~)0.435s301.01x
💻 LocalNitro1.593s (~)2.010s (~)0.012s (-6.7% 🟢)2.024s (~)0.430s301.01x
🐘 PostgresExpress1.658s (+3.6%)2.071s (+3.2%)0.004s (-20.1% 🟢)2.099s (+3.5%)0.440s291.05x
💻 LocalNext.js (Turbopack)1.743s (~)2.011s (~)0.012s (-1.4%)2.026s (~)0.284s301.11x
🐘 PostgresNext.js (Turbopack)1.822s (+2.0%)2.043s (+1.6%)0.005s (+3.3%)2.061s (+1.6%)0.239s301.16x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.706s (-3.6%)6.557s (-6.8% 🟢)0.248s (-47.2% 🟢)7.186s (-10.0% 🟢)1.480s91.00x
▲ VercelNext.js (Turbopack)5.845s (-9.3% 🟢)6.951s (-9.8% 🟢)0.332s (-40.8% 🟢)7.707s (-13.2% 🟢)1.862s81.02x
▲ VercelExpress5.862s (-3.5%)6.751s (-7.5% 🟢)0.441s (+67.7% 🔺)7.547s (-5.6% 🟢)1.685s81.03x

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

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.809s (+14.2% 🔺)1.081s (+3.6%)0.000s (-32.1% 🟢)1.096s (+3.1%)0.287s561.00x
🐘 PostgresExpress0.948s (+34.0% 🔺)1.295s (+27.7% 🔺)0.000s (+28.3% 🔺)1.319s (+28.2% 🔺)0.371s461.17x
🐘 PostgresNext.js (Turbopack)1.035s (+20.1% 🔺)1.539s (+41.1% 🔺)0.000s (-29.5% 🟢)1.547s (+40.8% 🔺)0.512s391.28x
💻 LocalNitro1.399s (-2.9%)2.013s (~)0.000s (+60.0% 🔺)2.015s (~)0.616s301.73x
💻 LocalExpress1.415s (+4.8%)2.013s (~)0.000s (+75.0% 🔺)2.015s (~)0.600s301.75x
💻 LocalNext.js (Turbopack)1.481s (+1.0%)2.012s (~)0.000s (-13.3% 🟢)2.016s (~)0.535s301.83x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.721s (-28.4% 🟢)3.796s (-21.4% 🟢)0.000s (NaN%)4.171s (-20.1% 🟢)1.450s151.00x
▲ VercelExpress2.801s (-16.6% 🟢)4.062s (-11.2% 🟢)0.000s (-100.0% 🟢)4.442s (-11.9% 🟢)1.641s141.03x
▲ VercelNext.js (Turbopack)2.963s (-19.9% 🟢)4.093s (-17.7% 🟢)0.000s (-100.0% 🟢)4.499s (-17.8% 🟢)1.536s141.09x

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

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

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.560s (+2.3%)2.216s (~)0.000s (-50.0% 🟢)2.245s (~)0.685s271.00x
🐘 PostgresNitro1.701s (+27.5% 🔺)2.217s (+9.4% 🔺)0.000s (+Infinity% 🔺)2.231s (+8.1% 🔺)0.530s271.09x
🐘 PostgresNext.js (Turbopack)2.076s (+20.0% 🔺)2.503s (+10.7% 🔺)0.000s (+Infinity% 🔺)2.510s (+10.5% 🔺)0.434s241.33x
💻 LocalNext.js (Turbopack)2.946s (+3.3%)3.614s (+5.8% 🔺)0.001s (+111.8% 🔺)3.617s (+5.8% 🔺)0.671s171.89x
💻 LocalNitro3.128s (-1.2%)3.779s (-1.5%)0.001s (+400.0% 🔺)3.781s (-1.6%)0.653s162.01x
💻 LocalExpress3.189s (+3.0%)3.841s (+4.6%)0.001s (+6.2% 🔺)3.844s (+4.5%)0.654s162.04x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro4.214s (-42.9% 🟢)5.507s (-30.6% 🟢)0.000s (+Infinity% 🔺)5.899s (-34.7% 🟢)1.685s111.00x
▲ VercelNext.js (Turbopack)4.747s (-46.5% 🟢)5.769s (-51.6% 🟢)0.000s (-95.0% 🟢)6.151s (-50.8% 🟢)1.404s101.13x
▲ VercelExpress4.778s (-10.2% 🟢)6.123s (-6.9% 🟢)0.000s (NaN%)6.547s (-9.0% 🟢)1.769s101.13x

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

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalExpress15/21
🐘 PostgresNitro11/21
▲ VercelExpress10/21
Fastest World by Framework

Winner determined by most benchmark wins

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

Comment threaddocs/content/docs/v5/foundations/hooks.mdx Outdated

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR replaces the hook conflict detection API from hook.hasConflict: Promise<boolean> to hook.getConflict: Promise<Run | null>, so a duplicate run can learn which run currently owns a conflicting hook token (and optionally act on it via the returned Run handle). It also updates the runtime, tests, e2e workflows, and v4/v5 docs to reflect the new API.

Changes:

  • Replace hasConflict plumbing with getConflict, including resolving to a conflicting Run handle and reusing the same instance across awaits.
  • Expose the workflow-bundle Run class via a new WORKFLOW_RUN_CLASS symbol so the hook consumer can construct durable Run handles inside workflow code.
  • Update workbench workflows, unit/e2e tests, docs (v4 + v5), and changesets for the renamed API.

Reviewed changes

Copilot reviewed 23 out of 23 changed files in this pull request and generated 5 comments.

Show a summary per file
FileDescription
workbench/nextjs-turbopack/app/workflows/definitions.tsRenames e2e workflow entries from hookHasConflict* to hookGetConflict*.
workbench/example/workflows/99_e2e.tsUpdates example workflows to use hook.getConflict and return conflict run metadata.
packages/core/src/workflow/hook.tsImplements getConflict resolution, constructs conflicting Run via WORKFLOW_RUN_CLASS.
packages/core/src/workflow/hook.test.tsUpdates unit tests to assert getConflict semantics and stable instance identity.
packages/core/src/workflow/create-hook.tsRegisters workflow-bundle Run class on globalThis[WORKFLOW_RUN_CLASS].
packages/core/src/workflow.test.tsUpdates workflow runner tests for suspension + getConflict behavior.
packages/core/src/symbols.tsAdds WORKFLOW_RUN_CLASS symbol definition and rationale.
packages/core/src/runtime/suspension-handler.tsUpdates comment/semantics references from hasConflict to getConflict.
packages/core/src/runtime/suspension-handler.test.tsUpdates tests and descriptions to the new getConflict naming.
packages/core/src/runtime.tsUpdates inline-execution gating comments to reference hook.getConflict.
packages/core/src/global.tsUpdates hook invocation queue item comment to reflect getConflict.
packages/core/src/create-hook.tsPublic Hook interface now exposes getConflict and updated docs/examples.
packages/core/e2e/e2e.test.tsRenames e2e tests and asserts conflict returns owner runId + durable status.
docs/content/docs/v5/how-it-works/event-sourcing.mdxUpdates hook conflict behavior description to reference getConflict.
docs/content/docs/v5/foundations/hooks.mdxUpdates conflict-checking guidance and examples to use getConflict.
docs/content/docs/v5/api-reference/workflow/create-webhook.mdxUpdates webhook return shape docs to getConflict.
docs/content/docs/v5/api-reference/workflow/create-hook.mdxUpdates API reference + examples from hasConflict to getConflict.
docs/content/docs/v4/how-it-works/event-sourcing.mdxUpdates v4 event-sourcing docs to reference getConflict.
docs/content/docs/v4/foundations/hooks.mdxUpdates v4 conflict-checking docs to getConflict ({ runId } guidance).
docs/content/docs/v4/api-reference/workflow/create-webhook.mdxUpdates v4 webhook docs to getConflict returning { runId }/null.
docs/content/docs/v4/api-reference/workflow/create-hook.mdxUpdates v4 hook API reference to getConflict returning { runId }/null.
.changeset/hook-has-conflict.mdRemoves changeset for the superseded hasConflict API.
.changeset/hook-get-conflict.mdAdds changeset for getConflict.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

// executes inside the VM, so `Run` here is the plugin-compiled variant
// whose methods are durable step proxies. The host-side consumer uses it
// to construct the conflicting run resolved by `hook.getConflict`.
(globalThis as any)[WORKFLOW_RUN_CLASS] ??= Run;

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in 919bde5: the registration is now guarded on the workflow runtime being present (WORKFLOW_CREATE_HOOK is installed on the VM globalThis before the bundle is evaluated), so importing this module outside the VM neither mutates the host global nor exposes the host-side Run (whose methods are not step proxies). Verified via the hookGetConflict e2e suite against a real bundle.

Comment threadpackages/core/src/create-hook.ts Outdated
Comment on lines +34 to +36
* Resolves with the conflicting {@link Run} if another active hook
* already owns this hook's token, or `null` once the hook has been
* registered and is ready to receive payloads.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Already addressed in dec869d (this review appears to have run against the initial commit): createConflictingRun() no longer falls back to a { runId }-shaped object. When a real Run cannot be constructed — including hook_conflict events without conflictingRunIdgetConflict() rejects with HookConflictError instead, and the JSDoc documents exactly that.

Comment threadpackages/core/src/create-hook.ts Outdated
* ```
*/
readonly hasConflict: Promise<boolean>;
readonly getConflict: Promise<Run<unknown> | null>;

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Already addressed in dec869d: the runtime now strictly honors Promise<Run<unknown> | null>. The { runId } fallback was removed — degenerate cases (legacy events without conflictingRunId, contexts without the bundle Run class) reject with HookConflictError rather than resolving with a value that lacks Run accessors, so the public type no longer needs widening.

```

Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.hasConflict` suspends the workflow to commit the hook registration, then resolves with `false` once the hook is registered and ready to receive payloads, or `true` if another active hook already owns the same token (see [`HookConflictError`](/docs/errors/hook-conflict)).
Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.getConflict` suspends the workflow to commit the hook registration, then resolves with `null` once the hook is registered and ready to receive payloads, or with a `Run` handle for the run that owns the token if another active hook already claimed it (see [`HookConflictError`](/docs/errors/hook-conflict)). The conflicting run's accessors are durable steps, so the workflow can inspect `await conflict.status`, wait on `await conflict.returnValue`, or cancel the owner with `await conflict.cancel()` — see [Run idempotency](/docs/foundations/idempotency#run-idempotency) for these strategies.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Clarified in 919bde5: the hooks docs (v4 + v5) now state that for hook_conflict events persisted by older worlds without the owning run ID, getConflict() rejects with HookConflictError instead of resolving with an incomplete handle — so when it does resolve with a conflict, the durable accessors are always available.

Comment thread.changeset/hook-get-conflict.md Outdated
"workflow": minor
---

Add `hook.getConflict`, a promise that suspends the workflow to commit hook registration and resolves with the conflicting `Run` when another active hook owns the token (or `null` once the hook is registered), without waiting for hook payload data.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

that doesn't matter because we didn't ship a new release with the previousl documented API so it's a fast follow and change

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Updated in 919bde5: the changeset now explicitly calls out that this replaces hook.hasConflict and includes the migration (await hook.hasConflictconst conflict = await hook.getConflict(), branch on conflict !== null).

getConflict's contract is Promise<Run | null>. In the degenerate cases
where a real Run cannot be constructed — a hook_conflict event persisted
by an old world without conflictingRunId, or a context that never loaded
the workflow-mode create-hook module — reject with HookConflictError
instead of resolving with a { runId }-shaped impostor.
Test harnesses now register the Run class on the (VM) globalThis like
real bundles do.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Request Changes — the feature is right, but the WORKFLOW_RUN_CLASS global duplicates infrastructure the serialization pipeline already provides

First: the API direction is a clear improvement. A Run handle beats the boolean (hasConflict told you that, not who), code-driven conflict strategy beats a fixed policy enum, and the dec869d9e decision to reject with HookConflictError rather than resolve a { runId }-shaped impostor keeps the Run contract honest. The determinism story checks out too: both fast-paths settle through promiseQueue, conflictRunRef is shared so repeated awaits observe one instance, replay reconstructs the handle from the same event-log conflictingRunId, and the bundle-compiled Run's accessors are step proxies (I verified the compiled output — Object.defineProperty(Run.prototype, ...) with __step_Run$... proxies), so await conflict.status / conflict.cancel() from workflow code are durable. 1156 unit tests pass locally; the red express E2E lane is failing errorWorkflowNested/errorWorkflowCrossFile (not hook tests) — baseline flake.

The change I'm requesting: use the class registry instead of a bespoke global

The (globalThis as any)[WORKFLOW_RUN_CLASS] ??= Run registration in workflow-mode create-hook.ts re-implements, ad hoc, something the serialization pipeline already does systematically. I built workbench/example from this branch and inspected the compiled flow bundle:

(function(__wf_cls,__wf_id){var__wf_sym=Symbol.for("workflow-class-registry"), ...
})(Run,"class//./packages/core/dist/runtime/run//Run");

The SWC plugin already registers the workflow-compiled Run in globalThis[WORKFLOW_CLASS_REGISTRY] at bundle evaluation — because Run carries WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE. This is the exact channel a start()-returned Run already uses to cross from host to VM today: the Instance reducer emits { classId, data: { runId, resilientStart } }, and getClassRevivers(global).Instance looks the class up via getSerializationClass(classId, global) against the VM's global and calls its WORKFLOW_DESERIALIZE.

So the class this PR smuggles through a new symbol is already sitting in the VM's registry when the hook consumer runs. The only missing piece is the lookup key — the plugin derives it from the module path (class//./packages/core/dist/runtime/run//Run in the workbench; different prefix under npm-installed layouts / other bundlers), so it can't be hardcoded.

Proposal (~15 lines total):

  1. Register Run under a stable, well-known classId alongside the plugin's path-derived one. registerSerializationClass can't be called twice (its non-configurable defineProperty(cls, 'classId') throws once the plugin IIFE has run), so add a tiny alias helper to class-serialization.ts:
/** Register an additional stable id for an already-registered class * (skips the classId defineProperty). */exportfunctionaliasSerializationClass(classId: string,cls: Function){getRegistry().set(classId,cls);}
  1. Call it at module scope of runtime/run.ts (e.g. aliasSerializationClass('class//workflow//Run', Run)). Note what this buys over the symbol approach: per-context discipline for free. Evaluated inside the VM, it registers the VM's step-proxy Run on the VM's registry; evaluated on the host, it registers the host Run on the host's registry. Each global gets its own correct class — which means the if (globalThis[WORKFLOW_CREATE_HOOK]) guard and the ??= (both of which exist purely to keep the host Run from leaking into the wrong global) simply disappear, along with the module-scope conditional in create-hook.ts and the new symbol in symbols.ts.

  2. The consumer constructs through the pipeline's own hook instead of new RunClass(id):

functioncreateConflictingRun(ctx,conflictingRunId){if(typeofconflictingRunId!=='string')returnnull;constRunClass=getSerializationClass(RUN_STABLE_CLASS_ID,ctx.globalThis);if(!RunClass)returnnull;return(RunClassasany)[WORKFLOW_DESERIALIZE]({runId: conflictingRunId});}

Same degenerate-case semantics you already have (registry miss → null → awaiters reject with HookConflictError), but the construction takes the identical code path a serialized Run instance takes everywhere else. class-serialization.ts is deliberately dependency-light ("separate from private.ts to avoid pulling in Node.js-only dependencies"), so importing it from workflow/hook.ts is safe.

Side benefit worth banking: a stable classId hardens Run serialization generally. Today a Run persisted in the event log (e.g. a start()-in-workflow handle in a step result) embeds the path-derived id, which is only safe because version-skew protection pins replay to the same deployment whose bundles agree on the path. A stable alias removes that coupling — any future cross-deployment replay (or a bundler change mid-release) stops being able to break Run hydration with "Class not found".

I considered the fuller "interact with the serialization pipeline" variant — making getConflict() resolution a step so the standard step-return serde does everything — and I think your current design is better: piggybacking on hook_created/hook_conflict costs zero extra events and zero queue hops, where a step-based resolution would add one of each per call. The registry lookup keeps that property while deleting the bespoke channel.

Smaller notes

  • The v4 docs describe a { runId } return that no released 4.x SDK has (and getConflict itself isn't on stable). Same sequencing flag as #2015/#2011 — the v4 pages keep widening the documented-but-unreleased surface; fine if the backport chain is imminent, worth holding otherwise.
  • e56e6fbc25b3b88b0b's getter→method change (hook.getConflict()) is the right call — a property that allocates and enqueues new promise resolvers on every access was the same trap hasConflict had; a method at least signals effects.
  • Changeset correctly replaces the hasConflict one and is scoped to @workflow/core + workflow (minor) — and since hasConflict shipped in no stable release and at most one beta, replacing rather than deprecating is fine.

Happy to re-review quickly once the registry switch is in — everything else here is ready.

… registry
Replace the bespoke WORKFLOW_RUN_CLASS global with the registry the
serialization pipeline already uses to revive Run instances:
- The SWC plugin already auto-registers the workflow bundle's compiled
Run in globalThis[workflow-class-registry], but under a path-derived
classId the host cannot know statically. The workflow-mode create-hook
module now aliases it under a stable id (class//workflow//Run) via a
new aliasSerializationClass() helper (a plain registry entry —
registerSerializationClass cannot be reused since the plugin's IIFE
already defined the non-configurable classId property).
- createConflictingRun() looks the class up with
getSerializationClass(RUN_CLASS_ID, ctx.globalThis) and constructs
through its WORKFLOW_DESERIALIZE hook, exactly as the Instance reviver
would for a serialized Run crossing from a step into the workflow.
- Because the registry is keyed per-global, no environment guard is
needed: a stray host-side import registers the host Run on the host
registry, which is the correct class for that context. The
WORKFLOW_CREATE_HOOK guard, the ??=, and the WORKFLOW_RUN_CLASS symbol
are all deleted.
Verified: 1156 core unit tests; compiled workbench bundle contains the
stable alias alongside the plugin's path-derived registration with zero
WORKFLOW_RUN_CLASS references; all 5 hookGetConflict e2e tests pass
against a local nextjs-turbopack dev server, including conflict
resolution reading conflict.status through a durable step.

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approve — the registry switch from my previous review is now implemented (0aa3cb0)

I implemented the change I requested (after discussing with Nathan), so this approval covers Pranay's feature plus that refactor — Pranay, please give 0aa3cb0 a once-over since it rewrites the class-handoff mechanism you authored.

What changed:

  • WORKFLOW_RUN_CLASS is gone (symbol, the ??= registration, and the WORKFLOW_CREATE_HOOK guard). The workflow-mode create-hook module now aliases the bundle's Run into the existing serialization class registry under a stable id (class//workflow//Run) via a new aliasSerializationClass() helper — a plain registry entry, since registerSerializationClass's non-configurable defineProperty(classId) would throw on a class the SWC plugin already registered.
  • createConflictingRun resolves through the pipeline: getSerializationClass(RUN_CLASS_ID, ctx.globalThis) + construction via the class's WORKFLOW_DESERIALIZE hook — the identical path the Instance reviver takes for a serialized Run crossing from a step into the workflow. Degenerate-case semantics unchanged: registry miss or missing deserialize hook → null → awaiters reject with HookConflictError.
  • No environment guard needed: the registry is per-global by construction, so a stray host-side import of the module registers the host Run on the host registry — correct for that context.

Verification:

  • 1156 core unit tests pass; hook.test.ts / workflow.test.ts mocks updated to register through the registry (the VM-string test now mirrors the real bundle shape, including the WORKFLOW_DESERIALIZE hook)
  • Rebuilt workbench/example: compiled flow bundle contains the stable alias alongside the plugin's path-derived registration, with zero WORKFLOW_RUN_CLASS references
  • All 5 hookGetConflict* e2e tests pass against a local nextjs-turbopack dev server, including the conflict test that resolves the actual owner and reads await conflict.status through a durable step — the full host → VM-registry → WORKFLOW_DESERIALIZE → step-proxy chain
  • The two biome complexity findings in hook.ts are pre-existing on this branch (identical count before/after)

Remaining notes from my previous review stand but don't block: the v4 docs describe a surface not yet on stable (recurring sequencing flag), and the stable classId incidentally hardens Run event-log serialization against future cross-deployment replay.

@github-actions

Copy link
Copy Markdown
Contributor

Backport PR opened against stable: #2382. Merge conflicts were resolved by AI — please review carefully. (backport job run)

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

@pranaygp@TooTallNate
, '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" + '
Replace hook.hasConflict with hook.getConflict() returning the conflicting Run by pranaygp · Pull Request #2373 · vercel/workflow · GitHub
Skip to content

Replace hook.hasConflict with hook.getConflict() returning the conflicting Run - #2373

Merged
TooTallNate merged 5 commits into
mainfrom
pranaygp/hook-get-conflict
Jun 12, 2026
Merged

Replace hook.hasConflict with hook.getConflict() returning the conflicting Run#2373
TooTallNate merged 5 commits into
mainfrom
pranaygp/hook-get-conflict

Conversation

@pranaygp

@pranaygppranaygp commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #2015. hook.hasConflict's boolean told the workflow that a conflict exists but not which run owns the token, so the duplicate run couldn't act on it. This replaces it with hook.getConflict(), a promise that resolves with:

  • null once the hook registration is committed (hook_created recorded), or
  • a Run handle for the conflicting run when another active hook owns the token.

Like hasConflict, awaiting getConflict() suspends the workflow to commit the hook registration without waiting for payload data. But on conflict, the workflow now has the active owner in hand and can choose its own idempotency strategy in code:

using hook=createHook({token: `order:${orderId}`});constconflict=awaithook.getConflict();if(conflict){// any of:return{dedupedTo: conflict.runId};// route caller to the ownerconststatus=awaitconflict.status;// inspect before decidingreturnawaitconflict.returnValue;// adopt the owner's resultawaitconflict.cancel();// supersede the owner and continue}

This is deliberately code-driven rather than a fixed set of ID-reuse policies (cf. Temporal's WorkflowIdReusePolicy / WorkflowIdConflictPolicy).

Implementation

  • The workflow-mode create-hook module exposes the bundle's compiled Run class on a well-known global symbol (WORKFLOW_RUN_CLASS). The bundle's Run is the SWC-plugin-compiled variant whose accessors (status, returnValue, cancel(), …) are durable step proxies — safe and deterministic inside workflow code. The host-side hook event consumer constructs the conflicting Run from the hook_conflict event's conflictingRunId (Expose conflicting run id on hook conflicts #2012) using that class.
  • getConflict never resolves with anything but a real Run or null. In the degenerate cases where a Run cannot be constructed — a hook_conflict event persisted by an old world without conflictingRunId, or a context that never loaded the workflow-mode create-hook module — it rejects with HookConflictError (which still carries the token) instead of resolving with a { runId }-shaped impostor. Note: the { runId } shape remains the documented v4 return value, since v4 has no native Run serialization — v4 docs direct users to getRun(conflict.runId) inside a step.
  • Repeated await hook.getConflict() calls observe the same Run instance; both fast-paths settle through promiseQueue to keep resolution order aligned with the event log.
  • Awaiting the hook payload on a conflicted token still rejects with HookConflictError.

Docs

Updated createHook() / createWebhook() references, hooks foundations, and event-sourcing pages (v4 + v5; v5 documents the Run return, v4 documents { runId }). A follow-up to #2011 adds the full conflict-handling strategy guide to the run-idempotency docs.

Validation

  • cd packages/core && pnpm typecheck && pnpm vitest run src/ (1155 tests)
  • pnpm test:docs
  • Hook e2e suite against local nextjs-turbopack: all hookGetConflict* tests pass, including the conflict test asserting the duplicate run resolves the actual owner's runId and reads await conflict.status === 'running' via a durable step.

…ull>)
hasConflict's boolean didn't expose WHICH run owns the token, so the
duplicate run couldn't act on the conflict. getConflict resolves with
null once registration commits, or with a Run handle for the conflicting
run — letting the workflow return/log the owner's runId, inspect its
status, await its result, or cancel it and continue, all in code.
The workflow-mode create-hook module exposes the bundle's compiled Run
class (durable step-proxy methods) on a well-known symbol so the host-
side hook consumer can construct the conflicting run inside the VM.
Contexts without the class (plain unit tests) fall back to a { runId }
object, which is also the documented v4 shape (no native Run
serialization in v4).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings June 11, 2026 23:11
@pranaygp
pranaygp requested a review from a team as a code ownerJune 11, 2026 23:11
@changeset-bot

changeset-botBot commented Jun 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0aa3cb0

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

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

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production137602191595
✅ 💻 Local Development181102192030
✅ 📦 Local Production181102192030
✅ 🐘 Local Postgres179702332030
✅ 🪟 Windows14500145
✅ 📋 Other83701781015
Total7777010688845

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro119026
✅ example119026
✅ express119026
✅ fastify119026
✅ hono119026
✅ nextjs-turbopack14302
✅ nextjs-webpack14302
✅ nitro119026
✅ nuxt119026
✅ sveltekit13807
✅ vite119026
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable120025
✅ express-stable120025
✅ fastify-stable120025
✅ hono-stable120025
✅ nextjs-turbopack-canary126019
✅ nextjs-turbopack-stable-lazy-discovery-disabled14500
✅ nextjs-turbopack-stable-lazy-discovery-enabled14500
✅ nextjs-webpack-canary126019
✅ nextjs-webpack-stable-lazy-discovery-disabled14500
✅ nextjs-webpack-stable-lazy-discovery-enabled14500
✅ nitro-stable120025
✅ nuxt-stable120025
✅ sveltekit-stable13906
✅ vite-stable120025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable120025
✅ express-stable120025
✅ fastify-stable120025
✅ hono-stable120025
✅ nextjs-turbopack-canary126019
✅ nextjs-turbopack-stable-lazy-discovery-disabled14500
✅ nextjs-turbopack-stable-lazy-discovery-enabled14500
✅ nextjs-webpack-canary126019
✅ nextjs-webpack-stable-lazy-discovery-disabled14500
✅ nextjs-webpack-stable-lazy-discovery-enabled14500
✅ nitro-stable120025
✅ nuxt-stable120025
✅ sveltekit-stable13906
✅ vite-stable120025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable119026
✅ express-stable119026
✅ fastify-stable119026
✅ hono-stable119026
✅ nextjs-turbopack-canary125020
✅ nextjs-turbopack-stable-lazy-discovery-disabled14401
✅ nextjs-turbopack-stable-lazy-discovery-enabled14401
✅ nextjs-webpack-canary125020
✅ nextjs-webpack-stable-lazy-discovery-disabled14401
✅ nextjs-webpack-stable-lazy-discovery-enabled14401
✅ nitro-stable119026
✅ nuxt-stable119026
✅ sveltekit-stable13807
✅ vite-stable119026
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack14500
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable120025
✅ e2e-local-dev-tanstack-start-120025
✅ e2e-local-postgres-nest-stable119026
✅ e2e-local-postgres-tanstack-start-119026
✅ e2e-local-prod-nest-stable120025
✅ e2e-local-prod-tanstack-start-120025
✅ e2e-vercel-prod-tanstack-start119026

📋 View full workflow run

@github-actions

github-actionsBot commented Jun 11, 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.042s (-1.9%)1.006s (~)0.965s101.00x
💻 LocalNitro0.046s (+7.8% 🔺)1.006s (~)0.960s101.10x
🐘 PostgresNitro0.065s (-1.5%)1.013s (~)0.948s101.56x
💻 LocalNext.js (Turbopack)0.066s (+5.2% 🔺)1.006s (~)0.940s101.59x
🐘 PostgresNext.js (Turbopack)0.072s (-4.3%)1.012s (~)0.941s101.72x
🐘 PostgresExpress0.080s (+30.0% 🔺)1.032s (+2.0%)0.952s101.92x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.244s (-35.3% 🟢)2.122s (-12.6% 🟢)1.878s101.00x
▲ VercelNitro0.260s (-31.1% 🟢)2.099s (~)1.839s101.07x
▲ VercelExpress0.268s (-1.1%)2.117s (-18.0% 🟢)1.850s101.10x

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

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.089s (-2.4%)2.011s (~)0.922s101.00x
💻 LocalExpress1.096s (~)2.007s (~)0.911s101.01x
💻 LocalNitro1.101s (+0.9%)2.006s (~)0.905s101.01x
🐘 PostgresNitro1.108s (~)2.009s (~)0.900s101.02x
💻 LocalNext.js (Turbopack)1.139s (+1.0%)2.007s (~)0.867s101.05x
🐘 PostgresNext.js (Turbopack)1.185s (+3.7%)2.011s (~)0.826s101.09x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express1.616s (-7.8% 🟢)3.302s (-11.0% 🟢)1.686s101.00x
▲ VercelNitro1.696s (+1.2%)3.485s (+1.0%)1.790s101.05x
▲ VercelNext.js (Turbopack)1.769s (+5.4% 🔺)3.307s (-9.1% 🟢)1.539s101.09x

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

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express10.496s (-0.9%)11.031s (~)0.535s31.00x
💻 LocalExpress10.500s (~)11.021s (~)0.521s31.00x
💻 LocalNitro10.535s (~)11.023s (~)0.488s31.00x
🐘 PostgresNitro10.568s (~)11.017s (~)0.450s31.01x
💻 LocalNext.js (Turbopack)10.794s (~)11.023s (~)0.229s31.03x
🐘 PostgresNext.js (Turbopack)10.842s (-1.6%)11.017s (-5.7% 🟢)0.175s31.03x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro13.610s (+2.0%)15.211s (~)1.601s21.00x
▲ VercelExpress14.017s (+4.5%)15.119s (-3.9%)1.102s21.03x
▲ VercelNext.js (Turbopack)14.111s (~)15.512s (-4.1%)1.401s21.04x

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

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express13.731s (-0.8%)14.028s (~)0.297s51.00x
💻 LocalNitro13.787s (~)14.027s (-1.4%)0.240s51.00x
🐘 PostgresExpress13.956s (+0.9%)14.418s (+2.8%)0.462s51.02x
🐘 PostgresNitro13.986s (+1.2%)14.220s (+1.4%)0.234s51.02x
💻 LocalNext.js (Turbopack)14.348s (-1.0%)15.030s (~)0.681s41.05x
🐘 PostgresNext.js (Turbopack)14.458s (~)15.020s (~)0.562s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro20.475s (-8.7% 🟢)22.157s (-8.0% 🟢)1.681s31.00x
▲ VercelExpress20.531s (-4.4%)21.964s (-4.8%)1.434s31.00x
▲ VercelNext.js (Turbopack)21.986s (+1.8%)23.788s (+1.9%)1.801s31.07x

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

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express12.391s (-0.9%)13.024s (~)0.633s71.00x
🐘 PostgresExpress12.417s (-2.6%)13.017s (~)0.600s71.00x
💻 LocalNitro12.529s (~)13.024s (~)0.495s71.01x
🐘 PostgresNitro12.730s (+1.2%)13.162s (+1.1%)0.433s71.03x
💻 LocalNext.js (Turbopack)13.654s (~)14.026s (~)0.372s71.10x
🐘 PostgresNext.js (Turbopack)14.084s (+0.8%)14.450s (+1.0%)0.366s71.14x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro29.382s (-7.3% 🟢)31.701s (-4.8%)2.319s31.00x
▲ VercelExpress29.422s (-0.8%)31.369s (+0.7%)1.947s31.00x
▲ VercelNext.js (Turbopack)30.219s (+5.4% 🔺)32.193s (+6.0% 🔺)1.973s31.03x

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

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.204s (~)2.015s (~)0.811s151.00x
🐘 PostgresNitro1.217s (+1.1%)2.008s (~)0.791s151.01x
💻 LocalExpress1.228s (+3.2%)2.006s (~)0.778s151.02x
💻 LocalNitro1.250s (+2.2%)2.006s (~)0.756s151.04x
💻 LocalNext.js (Turbopack)1.275s (-5.4% 🟢)2.006s (~)0.731s151.06x
🐘 PostgresNext.js (Turbopack)1.294s (+2.8%)2.008s (~)0.714s151.07x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.316s (-37.4% 🟢)3.726s (-28.1% 🟢)1.410s91.00x
▲ VercelExpress2.794s (+9.5% 🔺)4.184s (-7.0% 🟢)1.391s81.21x
▲ VercelNext.js (Turbopack)3.004s (-1.3%)4.488s (-2.3%)1.484s71.30x

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

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.367s (+8.6% 🔺)2.508s (+25.0% 🔺)1.141s121.00x
🐘 PostgresExpress1.422s (+12.6% 🔺)2.172s (+8.2% 🔺)0.750s141.04x
💻 LocalExpress1.755s (-4.7%)2.006s (-6.7% 🟢)0.251s151.28x
💻 LocalNext.js (Turbopack)1.834s (-2.4%)2.008s (-6.6% 🟢)0.174s151.34x
💻 LocalNitro1.853s (+8.9% 🔺)2.151s (+7.2% 🔺)0.298s141.36x
🐘 PostgresNext.js (Turbopack)1.855s (+32.4% 🔺)2.396s (+19.4% 🔺)0.541s131.36x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.168s (-28.3% 🟢)4.565s (-25.4% 🟢)1.397s81.00x
▲ VercelNext.js (Turbopack)3.520s (-98.9% 🟢)5.051s (-98.4% 🟢)1.531s61.11x
▲ VercelNitro4.477s (-28.2% 🟢)6.029s (-22.2% 🟢)1.552s51.41x

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

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.578s (+12.1% 🔺)3.762s (+87.3% 🔺)2.184s81.00x
🐘 PostgresExpress1.758s (+25.4% 🔺)3.577s (+78.1% 🔺)1.819s91.11x
🐘 PostgresNext.js (Turbopack)3.622s (+100.8% 🔺)4.583s (+99.5% 🔺)0.962s72.30x
💻 LocalExpress4.738s (-12.0% 🟢)5.514s (-10.8% 🟢)0.775s63.00x
💻 LocalNitro5.252s (~)5.846s (-2.8%)0.595s63.33x
💻 LocalNext.js (Turbopack)5.763s (+1.0%)6.216s (~)0.453s53.65x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro4.006s (-76.4% 🟢)5.907s (-67.7% 🟢)1.902s61.00x
▲ VercelExpress4.380s (-52.0% 🟢)6.113s (-46.2% 🟢)1.733s61.09x
▲ VercelNext.js (Turbopack)6.477s (+7.7% 🔺)7.965s (-5.6% 🟢)1.488s41.62x

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

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.212s (+1.8%)2.007s (~)0.794s151.00x
🐘 PostgresExpress1.254s (+3.5%)2.027s (+1.0%)0.773s151.03x
🐘 PostgresNext.js (Turbopack)1.295s (+2.5%)2.007s (~)0.712s151.07x
💻 LocalNext.js (Turbopack)1.377s (+1.9%)2.006s (~)0.629s151.14x
💻 LocalExpress1.560s (-5.9% 🟢)2.006s (-3.3%)0.446s151.29x
💻 LocalNitro1.603s (+6.3% 🔺)2.007s (~)0.404s151.32x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.267s (-12.1% 🟢)3.589s (-15.3% 🟢)1.323s91.00x
▲ VercelExpress2.338s (-13.4% 🟢)3.777s (-19.9% 🟢)1.439s81.03x
▲ VercelNext.js (Turbopack)2.688s (-34.5% 🟢)4.300s (-23.7% 🟢)1.613s71.19x

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

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.374s (+6.0% 🔺)2.149s (+6.9% 🔺)0.775s151.00x
🐘 PostgresNitro1.378s (+8.4% 🔺)2.223s (+10.7% 🔺)0.845s141.00x
🐘 PostgresNext.js (Turbopack)1.535s (+9.3% 🔺)2.296s (+14.3% 🔺)0.761s141.12x
💻 LocalNext.js (Turbopack)2.019s (-1.3%)2.591s (-13.9% 🟢)0.572s121.47x
💻 LocalNitro2.118s (-2.4%)2.675s (+3.2%)0.557s121.54x
💻 LocalExpress2.183s (+1.1%)2.735s (+2.2%)0.552s111.59x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.708s (-21.0% 🟢)4.034s (-20.9% 🟢)1.327s81.00x
▲ VercelNext.js (Turbopack)2.904s (-20.4% 🟢)4.585s (-12.1% 🟢)1.681s71.07x
▲ VercelNitro3.259s (-32.5% 🟢)4.713s (-26.4% 🟢)1.455s71.20x

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

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.633s (+17.6% 🔺)4.015s (+99.9% 🔺)2.383s81.00x
🐘 PostgresExpress1.778s (+26.9% 🔺)3.764s (+87.2% 🔺)1.985s81.09x
🐘 PostgresNext.js (Turbopack)3.795s (+109.9% 🔺)4.588s (+106.3% 🔺)0.793s72.32x
💻 LocalExpress4.915s (-22.7% 🟢)5.515s (-21.4% 🟢)0.600s63.01x
💻 LocalNext.js (Turbopack)5.698s (-5.9% 🟢)6.217s (-8.8% 🟢)0.518s53.49x
💻 LocalNitro6.163s (+13.4% 🔺)6.417s (+6.7% 🔺)0.254s53.77x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.482s (-31.8% 🟢)5.220s (-27.9% 🟢)1.738s61.00x
▲ VercelNitro3.723s (-46.8% 🟢)5.600s (-34.9% 🟢)1.877s61.07x
▲ VercelNext.js (Turbopack)4.865s (-39.4% 🟢)6.332s (-35.3% 🟢)1.467s51.40x

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

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.594s (-3.0%)1.005s (~)0.411s601.00x
💻 LocalNitro0.599s (~)1.005s (~)0.406s601.01x
🐘 PostgresNitro0.609s (+5.1% 🔺)1.040s (+3.4%)0.432s581.03x
🐘 PostgresExpress0.671s (+11.7% 🔺)1.147s (+13.9% 🔺)0.476s531.13x
🐘 PostgresNext.js (Turbopack)0.849s (-0.6%)1.006s (-1.7%)0.157s601.43x
💻 LocalNext.js (Turbopack)0.912s (+5.7% 🔺)1.075s (+5.2% 🔺)0.163s571.54x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.786s (+16.6% 🔺)7.078s (+8.6% 🔺)1.292s91.00x
▲ VercelNext.js (Turbopack)5.799s (+11.1% 🔺)7.275s (+6.0% 🔺)1.476s91.00x
▲ VercelExpress6.123s (-39.6% 🟢)7.604s (-35.4% 🟢)1.481s81.06x

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

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.390s (-3.2%)2.029s (~)0.639s451.00x
🐘 PostgresExpress1.504s (+4.9%)2.110s (+5.0% 🔺)0.605s431.08x
💻 LocalExpress1.510s (-2.8%)2.006s (-1.1%)0.496s451.09x
💻 LocalNitro1.528s (+1.5%)2.006s (~)0.479s451.10x
🐘 PostgresNext.js (Turbopack)1.999s (-1.5%)2.308s (-13.1% 🟢)0.309s401.44x
💻 LocalNext.js (Turbopack)2.117s (+0.8%)3.008s (~)0.892s301.52x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express14.919s (+3.8%)16.513s (+2.2%)1.594s61.00x
▲ VercelNitro15.432s (+17.8% 🔺)17.056s (+17.9% 🔺)1.624s61.03x
▲ VercelNext.js (Turbopack)15.850s (+19.2% 🔺)17.815s (+18.0% 🔺)1.965s61.06x

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

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.755s (-1.5%)3.085s (-2.6%)0.330s391.00x
🐘 PostgresExpress2.768s (-1.5%)3.311s (+5.5% 🔺)0.543s371.00x
💻 LocalExpress3.336s (+1.5%)4.042s (+0.8%)0.707s301.21x
💻 LocalNitro3.340s (+1.3%)4.010s (~)0.670s301.21x
🐘 PostgresNext.js (Turbopack)3.913s (-1.8%)4.110s (-5.1% 🟢)0.197s301.42x
💻 LocalNext.js (Turbopack)4.360s (~)5.011s (~)0.650s241.58x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express27.778s (+5.9% 🔺)30.392s (+7.7% 🔺)2.614s51.00x
▲ VercelNitro27.783s (-4.6%)30.056s (-2.4%)2.274s41.00x
▲ VercelNext.js (Turbopack)29.676s (-16.9% 🟢)31.852s (-15.0% 🟢)2.176s41.07x

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

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.252s (+14.8% 🔺)1.006s (~)0.754s601.00x
🐘 PostgresExpress0.264s (+13.3% 🔺)1.009s (~)0.745s601.05x
🐘 PostgresNext.js (Turbopack)0.319s (+15.0% 🔺)1.006s (~)0.687s601.27x
💻 LocalNitro0.417s (-3.0%)1.005s (-1.7%)0.588s601.66x
💻 LocalExpress0.453s (+9.7% 🔺)1.021s (+1.6%)0.568s591.80x
💻 LocalNext.js (Turbopack)0.553s (+1.3%)1.005s (~)0.452s602.20x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.055s (-20.0% 🟢)3.438s (-16.3% 🟢)1.383s181.00x
▲ VercelNitro2.234s (-20.9% 🟢)3.836s (-11.1% 🟢)1.602s161.09x
▲ VercelNext.js (Turbopack)2.376s (-46.7% 🟢)3.893s (-37.5% 🟢)1.517s161.16x

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

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.393s (+5.1% 🔺)1.036s (+3.0%)0.643s871.00x
🐘 PostgresNitro0.421s (+17.3% 🔺)1.029s (+2.2%)0.608s881.07x
🐘 PostgresNext.js (Turbopack)0.619s (+24.1% 🔺)1.133s (+12.5% 🔺)0.513s801.57x
💻 LocalNitro2.207s (+1.2%)2.715s (~)0.508s345.61x
💻 LocalExpress2.208s (+8.4% 🔺)2.737s (+7.9% 🔺)0.529s335.61x
💻 LocalNext.js (Turbopack)2.513s (+2.6%)3.225s (+2.5%)0.712s286.39x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.890s (-46.6% 🟢)4.303s (-39.2% 🟢)1.414s211.00x
▲ VercelNitro3.175s (-65.7% 🟢)4.598s (-57.7% 🟢)1.424s211.10x
▲ VercelNext.js (Turbopack)3.728s (-51.6% 🟢)5.309s (-43.4% 🟢)1.581s181.29x

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

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.711s (+2.2%)1.190s (+18.3% 🔺)0.479s1011.00x
🐘 PostgresNitro0.808s (+18.6% 🔺)1.340s (+33.2% 🔺)0.532s901.14x
🐘 PostgresNext.js (Turbopack)3.015s (+199.9% 🔺)3.798s (+104.8% 🔺)0.782s324.24x
💻 LocalExpress9.681s (+3.9%)10.113s (+2.4%)0.431s1213.61x
💻 LocalNitro9.892s (+6.6% 🔺)10.362s (+5.8% 🔺)0.470s1213.90x
💻 LocalNext.js (Turbopack)10.774s (-1.5%)11.482s (~)0.708s1115.14x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express6.916s (-63.4% 🟢)8.385s (-59.1% 🟢)1.469s151.00x
▲ VercelNitro7.160s (-62.8% 🟢)8.614s (-61.1% 🟢)1.454s151.04x
▲ VercelNext.js (Turbopack)7.650s (-65.4% 🟢)9.225s (-62.2% 🟢)1.575s141.11x

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

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.161s (-0.6%)2.005s (~)0.011s (-10.3% 🟢)2.018s (~)0.857s101.00x
🐘 PostgresExpress1.171s (-0.7%)2.002s (~)0.001s (-25.0% 🟢)2.011s (~)0.840s101.01x
🐘 PostgresNitro1.173s (~)1.998s (~)0.001s (~)2.010s (~)0.838s101.01x
💻 LocalNitro1.188s (+1.9%)2.005s (~)0.013s (+20.2% 🔺)2.020s (~)0.832s101.02x
💻 LocalNext.js (Turbopack)1.210s (~)2.004s (~)0.013s (+1.6%)2.021s (~)0.811s101.04x
🐘 PostgresNext.js (Turbopack)1.252s (+0.5%)2.002s (~)0.001s (-7.7% 🟢)2.011s (~)0.759s101.08x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.283s (+5.3% 🔺)3.012s (-6.0% 🟢)1.285s (+41.4% 🔺)4.735s (+3.4%)2.452s101.00x
▲ VercelNext.js (Turbopack)2.429s (+8.2% 🔺)3.474s (+7.5% 🔺)1.219s (+6.8% 🔺)5.101s (+4.5%)2.672s101.06x
▲ VercelNitro2.460s (+0.5%)3.395s (+1.0%)1.287s (+35.6% 🔺)5.115s (+7.3% 🔺)2.654s101.08x

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

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.574s (~)2.011s (~)0.013s (+11.9% 🔺)2.026s (~)0.452s301.00x
🐘 PostgresNitro1.591s (-1.3%)2.004s (~)0.005s (-0.7%)2.027s (~)0.435s301.01x
💻 LocalNitro1.593s (~)2.010s (~)0.012s (-6.7% 🟢)2.024s (~)0.430s301.01x
🐘 PostgresExpress1.658s (+3.6%)2.071s (+3.2%)0.004s (-20.1% 🟢)2.099s (+3.5%)0.440s291.05x
💻 LocalNext.js (Turbopack)1.743s (~)2.011s (~)0.012s (-1.4%)2.026s (~)0.284s301.11x
🐘 PostgresNext.js (Turbopack)1.822s (+2.0%)2.043s (+1.6%)0.005s (+3.3%)2.061s (+1.6%)0.239s301.16x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.706s (-3.6%)6.557s (-6.8% 🟢)0.248s (-47.2% 🟢)7.186s (-10.0% 🟢)1.480s91.00x
▲ VercelNext.js (Turbopack)5.845s (-9.3% 🟢)6.951s (-9.8% 🟢)0.332s (-40.8% 🟢)7.707s (-13.2% 🟢)1.862s81.02x
▲ VercelExpress5.862s (-3.5%)6.751s (-7.5% 🟢)0.441s (+67.7% 🔺)7.547s (-5.6% 🟢)1.685s81.03x

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

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.809s (+14.2% 🔺)1.081s (+3.6%)0.000s (-32.1% 🟢)1.096s (+3.1%)0.287s561.00x
🐘 PostgresExpress0.948s (+34.0% 🔺)1.295s (+27.7% 🔺)0.000s (+28.3% 🔺)1.319s (+28.2% 🔺)0.371s461.17x
🐘 PostgresNext.js (Turbopack)1.035s (+20.1% 🔺)1.539s (+41.1% 🔺)0.000s (-29.5% 🟢)1.547s (+40.8% 🔺)0.512s391.28x
💻 LocalNitro1.399s (-2.9%)2.013s (~)0.000s (+60.0% 🔺)2.015s (~)0.616s301.73x
💻 LocalExpress1.415s (+4.8%)2.013s (~)0.000s (+75.0% 🔺)2.015s (~)0.600s301.75x
💻 LocalNext.js (Turbopack)1.481s (+1.0%)2.012s (~)0.000s (-13.3% 🟢)2.016s (~)0.535s301.83x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.721s (-28.4% 🟢)3.796s (-21.4% 🟢)0.000s (NaN%)4.171s (-20.1% 🟢)1.450s151.00x
▲ VercelExpress2.801s (-16.6% 🟢)4.062s (-11.2% 🟢)0.000s (-100.0% 🟢)4.442s (-11.9% 🟢)1.641s141.03x
▲ VercelNext.js (Turbopack)2.963s (-19.9% 🟢)4.093s (-17.7% 🟢)0.000s (-100.0% 🟢)4.499s (-17.8% 🟢)1.536s141.09x

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

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

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.560s (+2.3%)2.216s (~)0.000s (-50.0% 🟢)2.245s (~)0.685s271.00x
🐘 PostgresNitro1.701s (+27.5% 🔺)2.217s (+9.4% 🔺)0.000s (+Infinity% 🔺)2.231s (+8.1% 🔺)0.530s271.09x
🐘 PostgresNext.js (Turbopack)2.076s (+20.0% 🔺)2.503s (+10.7% 🔺)0.000s (+Infinity% 🔺)2.510s (+10.5% 🔺)0.434s241.33x
💻 LocalNext.js (Turbopack)2.946s (+3.3%)3.614s (+5.8% 🔺)0.001s (+111.8% 🔺)3.617s (+5.8% 🔺)0.671s171.89x
💻 LocalNitro3.128s (-1.2%)3.779s (-1.5%)0.001s (+400.0% 🔺)3.781s (-1.6%)0.653s162.01x
💻 LocalExpress3.189s (+3.0%)3.841s (+4.6%)0.001s (+6.2% 🔺)3.844s (+4.5%)0.654s162.04x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro4.214s (-42.9% 🟢)5.507s (-30.6% 🟢)0.000s (+Infinity% 🔺)5.899s (-34.7% 🟢)1.685s111.00x
▲ VercelNext.js (Turbopack)4.747s (-46.5% 🟢)5.769s (-51.6% 🟢)0.000s (-95.0% 🟢)6.151s (-50.8% 🟢)1.404s101.13x
▲ VercelExpress4.778s (-10.2% 🟢)6.123s (-6.9% 🟢)0.000s (NaN%)6.547s (-9.0% 🟢)1.769s101.13x

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

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalExpress15/21
🐘 PostgresNitro11/21
▲ VercelExpress10/21
Fastest World by Framework

Winner determined by most benchmark wins

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

Comment threaddocs/content/docs/v5/foundations/hooks.mdx Outdated

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR replaces the hook conflict detection API from hook.hasConflict: Promise<boolean> to hook.getConflict: Promise<Run | null>, so a duplicate run can learn which run currently owns a conflicting hook token (and optionally act on it via the returned Run handle). It also updates the runtime, tests, e2e workflows, and v4/v5 docs to reflect the new API.

Changes:

  • Replace hasConflict plumbing with getConflict, including resolving to a conflicting Run handle and reusing the same instance across awaits.
  • Expose the workflow-bundle Run class via a new WORKFLOW_RUN_CLASS symbol so the hook consumer can construct durable Run handles inside workflow code.
  • Update workbench workflows, unit/e2e tests, docs (v4 + v5), and changesets for the renamed API.

Reviewed changes

Copilot reviewed 23 out of 23 changed files in this pull request and generated 5 comments.

Show a summary per file
FileDescription
workbench/nextjs-turbopack/app/workflows/definitions.tsRenames e2e workflow entries from hookHasConflict* to hookGetConflict*.
workbench/example/workflows/99_e2e.tsUpdates example workflows to use hook.getConflict and return conflict run metadata.
packages/core/src/workflow/hook.tsImplements getConflict resolution, constructs conflicting Run via WORKFLOW_RUN_CLASS.
packages/core/src/workflow/hook.test.tsUpdates unit tests to assert getConflict semantics and stable instance identity.
packages/core/src/workflow/create-hook.tsRegisters workflow-bundle Run class on globalThis[WORKFLOW_RUN_CLASS].
packages/core/src/workflow.test.tsUpdates workflow runner tests for suspension + getConflict behavior.
packages/core/src/symbols.tsAdds WORKFLOW_RUN_CLASS symbol definition and rationale.
packages/core/src/runtime/suspension-handler.tsUpdates comment/semantics references from hasConflict to getConflict.
packages/core/src/runtime/suspension-handler.test.tsUpdates tests and descriptions to the new getConflict naming.
packages/core/src/runtime.tsUpdates inline-execution gating comments to reference hook.getConflict.
packages/core/src/global.tsUpdates hook invocation queue item comment to reflect getConflict.
packages/core/src/create-hook.tsPublic Hook interface now exposes getConflict and updated docs/examples.
packages/core/e2e/e2e.test.tsRenames e2e tests and asserts conflict returns owner runId + durable status.
docs/content/docs/v5/how-it-works/event-sourcing.mdxUpdates hook conflict behavior description to reference getConflict.
docs/content/docs/v5/foundations/hooks.mdxUpdates conflict-checking guidance and examples to use getConflict.
docs/content/docs/v5/api-reference/workflow/create-webhook.mdxUpdates webhook return shape docs to getConflict.
docs/content/docs/v5/api-reference/workflow/create-hook.mdxUpdates API reference + examples from hasConflict to getConflict.
docs/content/docs/v4/how-it-works/event-sourcing.mdxUpdates v4 event-sourcing docs to reference getConflict.
docs/content/docs/v4/foundations/hooks.mdxUpdates v4 conflict-checking docs to getConflict ({ runId } guidance).
docs/content/docs/v4/api-reference/workflow/create-webhook.mdxUpdates v4 webhook docs to getConflict returning { runId }/null.
docs/content/docs/v4/api-reference/workflow/create-hook.mdxUpdates v4 hook API reference to getConflict returning { runId }/null.
.changeset/hook-has-conflict.mdRemoves changeset for the superseded hasConflict API.
.changeset/hook-get-conflict.mdAdds changeset for getConflict.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

// executes inside the VM, so `Run` here is the plugin-compiled variant
// whose methods are durable step proxies. The host-side consumer uses it
// to construct the conflicting run resolved by `hook.getConflict`.
(globalThis as any)[WORKFLOW_RUN_CLASS] ??= Run;

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in 919bde5: the registration is now guarded on the workflow runtime being present (WORKFLOW_CREATE_HOOK is installed on the VM globalThis before the bundle is evaluated), so importing this module outside the VM neither mutates the host global nor exposes the host-side Run (whose methods are not step proxies). Verified via the hookGetConflict e2e suite against a real bundle.

Comment threadpackages/core/src/create-hook.ts Outdated
Comment on lines +34 to +36
* Resolves with the conflicting {@link Run} if another active hook
* already owns this hook's token, or `null` once the hook has been
* registered and is ready to receive payloads.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Already addressed in dec869d (this review appears to have run against the initial commit): createConflictingRun() no longer falls back to a { runId }-shaped object. When a real Run cannot be constructed — including hook_conflict events without conflictingRunIdgetConflict() rejects with HookConflictError instead, and the JSDoc documents exactly that.

Comment threadpackages/core/src/create-hook.ts Outdated
* ```
*/
readonly hasConflict: Promise<boolean>;
readonly getConflict: Promise<Run<unknown> | null>;

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Already addressed in dec869d: the runtime now strictly honors Promise<Run<unknown> | null>. The { runId } fallback was removed — degenerate cases (legacy events without conflictingRunId, contexts without the bundle Run class) reject with HookConflictError rather than resolving with a value that lacks Run accessors, so the public type no longer needs widening.

```

Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.hasConflict` suspends the workflow to commit the hook registration, then resolves with `false` once the hook is registered and ready to receive payloads, or `true` if another active hook already owns the same token (see [`HookConflictError`](/docs/errors/hook-conflict)).
Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.getConflict` suspends the workflow to commit the hook registration, then resolves with `null` once the hook is registered and ready to receive payloads, or with a `Run` handle for the run that owns the token if another active hook already claimed it (see [`HookConflictError`](/docs/errors/hook-conflict)). The conflicting run's accessors are durable steps, so the workflow can inspect `await conflict.status`, wait on `await conflict.returnValue`, or cancel the owner with `await conflict.cancel()` — see [Run idempotency](/docs/foundations/idempotency#run-idempotency) for these strategies.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Clarified in 919bde5: the hooks docs (v4 + v5) now state that for hook_conflict events persisted by older worlds without the owning run ID, getConflict() rejects with HookConflictError instead of resolving with an incomplete handle — so when it does resolve with a conflict, the durable accessors are always available.

Comment thread.changeset/hook-get-conflict.md Outdated
"workflow": minor
---

Add `hook.getConflict`, a promise that suspends the workflow to commit hook registration and resolves with the conflicting `Run` when another active hook owns the token (or `null` once the hook is registered), without waiting for hook payload data.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

that doesn't matter because we didn't ship a new release with the previousl documented API so it's a fast follow and change

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Updated in 919bde5: the changeset now explicitly calls out that this replaces hook.hasConflict and includes the migration (await hook.hasConflictconst conflict = await hook.getConflict(), branch on conflict !== null).

getConflict's contract is Promise<Run | null>. In the degenerate cases
where a real Run cannot be constructed — a hook_conflict event persisted
by an old world without conflictingRunId, or a context that never loaded
the workflow-mode create-hook module — reject with HookConflictError
instead of resolving with a { runId }-shaped impostor.
Test harnesses now register the Run class on the (VM) globalThis like
real bundles do.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Request Changes — the feature is right, but the WORKFLOW_RUN_CLASS global duplicates infrastructure the serialization pipeline already provides

First: the API direction is a clear improvement. A Run handle beats the boolean (hasConflict told you that, not who), code-driven conflict strategy beats a fixed policy enum, and the dec869d9e decision to reject with HookConflictError rather than resolve a { runId }-shaped impostor keeps the Run contract honest. The determinism story checks out too: both fast-paths settle through promiseQueue, conflictRunRef is shared so repeated awaits observe one instance, replay reconstructs the handle from the same event-log conflictingRunId, and the bundle-compiled Run's accessors are step proxies (I verified the compiled output — Object.defineProperty(Run.prototype, ...) with __step_Run$... proxies), so await conflict.status / conflict.cancel() from workflow code are durable. 1156 unit tests pass locally; the red express E2E lane is failing errorWorkflowNested/errorWorkflowCrossFile (not hook tests) — baseline flake.

The change I'm requesting: use the class registry instead of a bespoke global

The (globalThis as any)[WORKFLOW_RUN_CLASS] ??= Run registration in workflow-mode create-hook.ts re-implements, ad hoc, something the serialization pipeline already does systematically. I built workbench/example from this branch and inspected the compiled flow bundle:

(function(__wf_cls,__wf_id){var__wf_sym=Symbol.for("workflow-class-registry"), ...
})(Run,"class//./packages/core/dist/runtime/run//Run");

The SWC plugin already registers the workflow-compiled Run in globalThis[WORKFLOW_CLASS_REGISTRY] at bundle evaluation — because Run carries WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE. This is the exact channel a start()-returned Run already uses to cross from host to VM today: the Instance reducer emits { classId, data: { runId, resilientStart } }, and getClassRevivers(global).Instance looks the class up via getSerializationClass(classId, global) against the VM's global and calls its WORKFLOW_DESERIALIZE.

So the class this PR smuggles through a new symbol is already sitting in the VM's registry when the hook consumer runs. The only missing piece is the lookup key — the plugin derives it from the module path (class//./packages/core/dist/runtime/run//Run in the workbench; different prefix under npm-installed layouts / other bundlers), so it can't be hardcoded.

Proposal (~15 lines total):

  1. Register Run under a stable, well-known classId alongside the plugin's path-derived one. registerSerializationClass can't be called twice (its non-configurable defineProperty(cls, 'classId') throws once the plugin IIFE has run), so add a tiny alias helper to class-serialization.ts:
/** Register an additional stable id for an already-registered class * (skips the classId defineProperty). */exportfunctionaliasSerializationClass(classId: string,cls: Function){getRegistry().set(classId,cls);}
  1. Call it at module scope of runtime/run.ts (e.g. aliasSerializationClass('class//workflow//Run', Run)). Note what this buys over the symbol approach: per-context discipline for free. Evaluated inside the VM, it registers the VM's step-proxy Run on the VM's registry; evaluated on the host, it registers the host Run on the host's registry. Each global gets its own correct class — which means the if (globalThis[WORKFLOW_CREATE_HOOK]) guard and the ??= (both of which exist purely to keep the host Run from leaking into the wrong global) simply disappear, along with the module-scope conditional in create-hook.ts and the new symbol in symbols.ts.

  2. The consumer constructs through the pipeline's own hook instead of new RunClass(id):

functioncreateConflictingRun(ctx,conflictingRunId){if(typeofconflictingRunId!=='string')returnnull;constRunClass=getSerializationClass(RUN_STABLE_CLASS_ID,ctx.globalThis);if(!RunClass)returnnull;return(RunClassasany)[WORKFLOW_DESERIALIZE]({runId: conflictingRunId});}

Same degenerate-case semantics you already have (registry miss → null → awaiters reject with HookConflictError), but the construction takes the identical code path a serialized Run instance takes everywhere else. class-serialization.ts is deliberately dependency-light ("separate from private.ts to avoid pulling in Node.js-only dependencies"), so importing it from workflow/hook.ts is safe.

Side benefit worth banking: a stable classId hardens Run serialization generally. Today a Run persisted in the event log (e.g. a start()-in-workflow handle in a step result) embeds the path-derived id, which is only safe because version-skew protection pins replay to the same deployment whose bundles agree on the path. A stable alias removes that coupling — any future cross-deployment replay (or a bundler change mid-release) stops being able to break Run hydration with "Class not found".

I considered the fuller "interact with the serialization pipeline" variant — making getConflict() resolution a step so the standard step-return serde does everything — and I think your current design is better: piggybacking on hook_created/hook_conflict costs zero extra events and zero queue hops, where a step-based resolution would add one of each per call. The registry lookup keeps that property while deleting the bespoke channel.

Smaller notes

  • The v4 docs describe a { runId } return that no released 4.x SDK has (and getConflict itself isn't on stable). Same sequencing flag as #2015/#2011 — the v4 pages keep widening the documented-but-unreleased surface; fine if the backport chain is imminent, worth holding otherwise.
  • e56e6fbc25b3b88b0b's getter→method change (hook.getConflict()) is the right call — a property that allocates and enqueues new promise resolvers on every access was the same trap hasConflict had; a method at least signals effects.
  • Changeset correctly replaces the hasConflict one and is scoped to @workflow/core + workflow (minor) — and since hasConflict shipped in no stable release and at most one beta, replacing rather than deprecating is fine.

Happy to re-review quickly once the registry switch is in — everything else here is ready.

… registry
Replace the bespoke WORKFLOW_RUN_CLASS global with the registry the
serialization pipeline already uses to revive Run instances:
- The SWC plugin already auto-registers the workflow bundle's compiled
Run in globalThis[workflow-class-registry], but under a path-derived
classId the host cannot know statically. The workflow-mode create-hook
module now aliases it under a stable id (class//workflow//Run) via a
new aliasSerializationClass() helper (a plain registry entry —
registerSerializationClass cannot be reused since the plugin's IIFE
already defined the non-configurable classId property).
- createConflictingRun() looks the class up with
getSerializationClass(RUN_CLASS_ID, ctx.globalThis) and constructs
through its WORKFLOW_DESERIALIZE hook, exactly as the Instance reviver
would for a serialized Run crossing from a step into the workflow.
- Because the registry is keyed per-global, no environment guard is
needed: a stray host-side import registers the host Run on the host
registry, which is the correct class for that context. The
WORKFLOW_CREATE_HOOK guard, the ??=, and the WORKFLOW_RUN_CLASS symbol
are all deleted.
Verified: 1156 core unit tests; compiled workbench bundle contains the
stable alias alongside the plugin's path-derived registration with zero
WORKFLOW_RUN_CLASS references; all 5 hookGetConflict e2e tests pass
against a local nextjs-turbopack dev server, including conflict
resolution reading conflict.status through a durable step.

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approve — the registry switch from my previous review is now implemented (0aa3cb0)

I implemented the change I requested (after discussing with Nathan), so this approval covers Pranay's feature plus that refactor — Pranay, please give 0aa3cb0 a once-over since it rewrites the class-handoff mechanism you authored.

What changed:

  • WORKFLOW_RUN_CLASS is gone (symbol, the ??= registration, and the WORKFLOW_CREATE_HOOK guard). The workflow-mode create-hook module now aliases the bundle's Run into the existing serialization class registry under a stable id (class//workflow//Run) via a new aliasSerializationClass() helper — a plain registry entry, since registerSerializationClass's non-configurable defineProperty(classId) would throw on a class the SWC plugin already registered.
  • createConflictingRun resolves through the pipeline: getSerializationClass(RUN_CLASS_ID, ctx.globalThis) + construction via the class's WORKFLOW_DESERIALIZE hook — the identical path the Instance reviver takes for a serialized Run crossing from a step into the workflow. Degenerate-case semantics unchanged: registry miss or missing deserialize hook → null → awaiters reject with HookConflictError.
  • No environment guard needed: the registry is per-global by construction, so a stray host-side import of the module registers the host Run on the host registry — correct for that context.

Verification:

  • 1156 core unit tests pass; hook.test.ts / workflow.test.ts mocks updated to register through the registry (the VM-string test now mirrors the real bundle shape, including the WORKFLOW_DESERIALIZE hook)
  • Rebuilt workbench/example: compiled flow bundle contains the stable alias alongside the plugin's path-derived registration, with zero WORKFLOW_RUN_CLASS references
  • All 5 hookGetConflict* e2e tests pass against a local nextjs-turbopack dev server, including the conflict test that resolves the actual owner and reads await conflict.status through a durable step — the full host → VM-registry → WORKFLOW_DESERIALIZE → step-proxy chain
  • The two biome complexity findings in hook.ts are pre-existing on this branch (identical count before/after)

Remaining notes from my previous review stand but don't block: the v4 docs describe a surface not yet on stable (recurring sequencing flag), and the stable classId incidentally hardens Run event-log serialization against future cross-deployment replay.

@github-actions

Copy link
Copy Markdown
Contributor

Backport PR opened against stable: #2382. Merge conflicts were resolved by AI — please review carefully. (backport job run)

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

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

Replace hook.hasConflict with hook.getConflict() returning the conflicting Run - #2373

Merged
TooTallNate merged 5 commits into
mainfrom
pranaygp/hook-get-conflict
Jun 12, 2026
Merged

Replace hook.hasConflict with hook.getConflict() returning the conflicting Run#2373
TooTallNate merged 5 commits into
mainfrom
pranaygp/hook-get-conflict

Conversation

@pranaygp

@pranaygppranaygp commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #2015. hook.hasConflict's boolean told the workflow that a conflict exists but not which run owns the token, so the duplicate run couldn't act on it. This replaces it with hook.getConflict(), a promise that resolves with:

  • null once the hook registration is committed (hook_created recorded), or
  • a Run handle for the conflicting run when another active hook owns the token.

Like hasConflict, awaiting getConflict() suspends the workflow to commit the hook registration without waiting for payload data. But on conflict, the workflow now has the active owner in hand and can choose its own idempotency strategy in code:

using hook=createHook({token: `order:${orderId}`});constconflict=awaithook.getConflict();if(conflict){// any of:return{dedupedTo: conflict.runId};// route caller to the ownerconststatus=awaitconflict.status;// inspect before decidingreturnawaitconflict.returnValue;// adopt the owner's resultawaitconflict.cancel();// supersede the owner and continue}

This is deliberately code-driven rather than a fixed set of ID-reuse policies (cf. Temporal's WorkflowIdReusePolicy / WorkflowIdConflictPolicy).

Implementation

  • The workflow-mode create-hook module exposes the bundle's compiled Run class on a well-known global symbol (WORKFLOW_RUN_CLASS). The bundle's Run is the SWC-plugin-compiled variant whose accessors (status, returnValue, cancel(), …) are durable step proxies — safe and deterministic inside workflow code. The host-side hook event consumer constructs the conflicting Run from the hook_conflict event's conflictingRunId (Expose conflicting run id on hook conflicts #2012) using that class.
  • getConflict never resolves with anything but a real Run or null. In the degenerate cases where a Run cannot be constructed — a hook_conflict event persisted by an old world without conflictingRunId, or a context that never loaded the workflow-mode create-hook module — it rejects with HookConflictError (which still carries the token) instead of resolving with a { runId }-shaped impostor. Note: the { runId } shape remains the documented v4 return value, since v4 has no native Run serialization — v4 docs direct users to getRun(conflict.runId) inside a step.
  • Repeated await hook.getConflict() calls observe the same Run instance; both fast-paths settle through promiseQueue to keep resolution order aligned with the event log.
  • Awaiting the hook payload on a conflicted token still rejects with HookConflictError.

Docs

Updated createHook() / createWebhook() references, hooks foundations, and event-sourcing pages (v4 + v5; v5 documents the Run return, v4 documents { runId }). A follow-up to #2011 adds the full conflict-handling strategy guide to the run-idempotency docs.

Validation

  • cd packages/core && pnpm typecheck && pnpm vitest run src/ (1155 tests)
  • pnpm test:docs
  • Hook e2e suite against local nextjs-turbopack: all hookGetConflict* tests pass, including the conflict test asserting the duplicate run resolves the actual owner's runId and reads await conflict.status === 'running' via a durable step.

…ull>)
hasConflict's boolean didn't expose WHICH run owns the token, so the
duplicate run couldn't act on the conflict. getConflict resolves with
null once registration commits, or with a Run handle for the conflicting
run — letting the workflow return/log the owner's runId, inspect its
status, await its result, or cancel it and continue, all in code.
The workflow-mode create-hook module exposes the bundle's compiled Run
class (durable step-proxy methods) on a well-known symbol so the host-
side hook consumer can construct the conflicting run inside the VM.
Contexts without the class (plain unit tests) fall back to a { runId }
object, which is also the documented v4 shape (no native Run
serialization in v4).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings June 11, 2026 23:11
@pranaygp
pranaygp requested a review from a team as a code ownerJune 11, 2026 23:11
@changeset-bot

changeset-botBot commented Jun 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0aa3cb0

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

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

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production137602191595
✅ 💻 Local Development181102192030
✅ 📦 Local Production181102192030
✅ 🐘 Local Postgres179702332030
✅ 🪟 Windows14500145
✅ 📋 Other83701781015
Total7777010688845

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro119026
✅ example119026
✅ express119026
✅ fastify119026
✅ hono119026
✅ nextjs-turbopack14302
✅ nextjs-webpack14302
✅ nitro119026
✅ nuxt119026
✅ sveltekit13807
✅ vite119026
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable120025
✅ express-stable120025
✅ fastify-stable120025
✅ hono-stable120025
✅ nextjs-turbopack-canary126019
✅ nextjs-turbopack-stable-lazy-discovery-disabled14500
✅ nextjs-turbopack-stable-lazy-discovery-enabled14500
✅ nextjs-webpack-canary126019
✅ nextjs-webpack-stable-lazy-discovery-disabled14500
✅ nextjs-webpack-stable-lazy-discovery-enabled14500
✅ nitro-stable120025
✅ nuxt-stable120025
✅ sveltekit-stable13906
✅ vite-stable120025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable120025
✅ express-stable120025
✅ fastify-stable120025
✅ hono-stable120025
✅ nextjs-turbopack-canary126019
✅ nextjs-turbopack-stable-lazy-discovery-disabled14500
✅ nextjs-turbopack-stable-lazy-discovery-enabled14500
✅ nextjs-webpack-canary126019
✅ nextjs-webpack-stable-lazy-discovery-disabled14500
✅ nextjs-webpack-stable-lazy-discovery-enabled14500
✅ nitro-stable120025
✅ nuxt-stable120025
✅ sveltekit-stable13906
✅ vite-stable120025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable119026
✅ express-stable119026
✅ fastify-stable119026
✅ hono-stable119026
✅ nextjs-turbopack-canary125020
✅ nextjs-turbopack-stable-lazy-discovery-disabled14401
✅ nextjs-turbopack-stable-lazy-discovery-enabled14401
✅ nextjs-webpack-canary125020
✅ nextjs-webpack-stable-lazy-discovery-disabled14401
✅ nextjs-webpack-stable-lazy-discovery-enabled14401
✅ nitro-stable119026
✅ nuxt-stable119026
✅ sveltekit-stable13807
✅ vite-stable119026
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack14500
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable120025
✅ e2e-local-dev-tanstack-start-120025
✅ e2e-local-postgres-nest-stable119026
✅ e2e-local-postgres-tanstack-start-119026
✅ e2e-local-prod-nest-stable120025
✅ e2e-local-prod-tanstack-start-120025
✅ e2e-vercel-prod-tanstack-start119026

📋 View full workflow run

@github-actions

github-actionsBot commented Jun 11, 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.042s (-1.9%)1.006s (~)0.965s101.00x
💻 LocalNitro0.046s (+7.8% 🔺)1.006s (~)0.960s101.10x
🐘 PostgresNitro0.065s (-1.5%)1.013s (~)0.948s101.56x
💻 LocalNext.js (Turbopack)0.066s (+5.2% 🔺)1.006s (~)0.940s101.59x
🐘 PostgresNext.js (Turbopack)0.072s (-4.3%)1.012s (~)0.941s101.72x
🐘 PostgresExpress0.080s (+30.0% 🔺)1.032s (+2.0%)0.952s101.92x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.244s (-35.3% 🟢)2.122s (-12.6% 🟢)1.878s101.00x
▲ VercelNitro0.260s (-31.1% 🟢)2.099s (~)1.839s101.07x
▲ VercelExpress0.268s (-1.1%)2.117s (-18.0% 🟢)1.850s101.10x

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

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.089s (-2.4%)2.011s (~)0.922s101.00x
💻 LocalExpress1.096s (~)2.007s (~)0.911s101.01x
💻 LocalNitro1.101s (+0.9%)2.006s (~)0.905s101.01x
🐘 PostgresNitro1.108s (~)2.009s (~)0.900s101.02x
💻 LocalNext.js (Turbopack)1.139s (+1.0%)2.007s (~)0.867s101.05x
🐘 PostgresNext.js (Turbopack)1.185s (+3.7%)2.011s (~)0.826s101.09x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express1.616s (-7.8% 🟢)3.302s (-11.0% 🟢)1.686s101.00x
▲ VercelNitro1.696s (+1.2%)3.485s (+1.0%)1.790s101.05x
▲ VercelNext.js (Turbopack)1.769s (+5.4% 🔺)3.307s (-9.1% 🟢)1.539s101.09x

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

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express10.496s (-0.9%)11.031s (~)0.535s31.00x
💻 LocalExpress10.500s (~)11.021s (~)0.521s31.00x
💻 LocalNitro10.535s (~)11.023s (~)0.488s31.00x
🐘 PostgresNitro10.568s (~)11.017s (~)0.450s31.01x
💻 LocalNext.js (Turbopack)10.794s (~)11.023s (~)0.229s31.03x
🐘 PostgresNext.js (Turbopack)10.842s (-1.6%)11.017s (-5.7% 🟢)0.175s31.03x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro13.610s (+2.0%)15.211s (~)1.601s21.00x
▲ VercelExpress14.017s (+4.5%)15.119s (-3.9%)1.102s21.03x
▲ VercelNext.js (Turbopack)14.111s (~)15.512s (-4.1%)1.401s21.04x

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

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express13.731s (-0.8%)14.028s (~)0.297s51.00x
💻 LocalNitro13.787s (~)14.027s (-1.4%)0.240s51.00x
🐘 PostgresExpress13.956s (+0.9%)14.418s (+2.8%)0.462s51.02x
🐘 PostgresNitro13.986s (+1.2%)14.220s (+1.4%)0.234s51.02x
💻 LocalNext.js (Turbopack)14.348s (-1.0%)15.030s (~)0.681s41.05x
🐘 PostgresNext.js (Turbopack)14.458s (~)15.020s (~)0.562s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro20.475s (-8.7% 🟢)22.157s (-8.0% 🟢)1.681s31.00x
▲ VercelExpress20.531s (-4.4%)21.964s (-4.8%)1.434s31.00x
▲ VercelNext.js (Turbopack)21.986s (+1.8%)23.788s (+1.9%)1.801s31.07x

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

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express12.391s (-0.9%)13.024s (~)0.633s71.00x
🐘 PostgresExpress12.417s (-2.6%)13.017s (~)0.600s71.00x
💻 LocalNitro12.529s (~)13.024s (~)0.495s71.01x
🐘 PostgresNitro12.730s (+1.2%)13.162s (+1.1%)0.433s71.03x
💻 LocalNext.js (Turbopack)13.654s (~)14.026s (~)0.372s71.10x
🐘 PostgresNext.js (Turbopack)14.084s (+0.8%)14.450s (+1.0%)0.366s71.14x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro29.382s (-7.3% 🟢)31.701s (-4.8%)2.319s31.00x
▲ VercelExpress29.422s (-0.8%)31.369s (+0.7%)1.947s31.00x
▲ VercelNext.js (Turbopack)30.219s (+5.4% 🔺)32.193s (+6.0% 🔺)1.973s31.03x

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

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.204s (~)2.015s (~)0.811s151.00x
🐘 PostgresNitro1.217s (+1.1%)2.008s (~)0.791s151.01x
💻 LocalExpress1.228s (+3.2%)2.006s (~)0.778s151.02x
💻 LocalNitro1.250s (+2.2%)2.006s (~)0.756s151.04x
💻 LocalNext.js (Turbopack)1.275s (-5.4% 🟢)2.006s (~)0.731s151.06x
🐘 PostgresNext.js (Turbopack)1.294s (+2.8%)2.008s (~)0.714s151.07x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.316s (-37.4% 🟢)3.726s (-28.1% 🟢)1.410s91.00x
▲ VercelExpress2.794s (+9.5% 🔺)4.184s (-7.0% 🟢)1.391s81.21x
▲ VercelNext.js (Turbopack)3.004s (-1.3%)4.488s (-2.3%)1.484s71.30x

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

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.367s (+8.6% 🔺)2.508s (+25.0% 🔺)1.141s121.00x
🐘 PostgresExpress1.422s (+12.6% 🔺)2.172s (+8.2% 🔺)0.750s141.04x
💻 LocalExpress1.755s (-4.7%)2.006s (-6.7% 🟢)0.251s151.28x
💻 LocalNext.js (Turbopack)1.834s (-2.4%)2.008s (-6.6% 🟢)0.174s151.34x
💻 LocalNitro1.853s (+8.9% 🔺)2.151s (+7.2% 🔺)0.298s141.36x
🐘 PostgresNext.js (Turbopack)1.855s (+32.4% 🔺)2.396s (+19.4% 🔺)0.541s131.36x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.168s (-28.3% 🟢)4.565s (-25.4% 🟢)1.397s81.00x
▲ VercelNext.js (Turbopack)3.520s (-98.9% 🟢)5.051s (-98.4% 🟢)1.531s61.11x
▲ VercelNitro4.477s (-28.2% 🟢)6.029s (-22.2% 🟢)1.552s51.41x

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

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.578s (+12.1% 🔺)3.762s (+87.3% 🔺)2.184s81.00x
🐘 PostgresExpress1.758s (+25.4% 🔺)3.577s (+78.1% 🔺)1.819s91.11x
🐘 PostgresNext.js (Turbopack)3.622s (+100.8% 🔺)4.583s (+99.5% 🔺)0.962s72.30x
💻 LocalExpress4.738s (-12.0% 🟢)5.514s (-10.8% 🟢)0.775s63.00x
💻 LocalNitro5.252s (~)5.846s (-2.8%)0.595s63.33x
💻 LocalNext.js (Turbopack)5.763s (+1.0%)6.216s (~)0.453s53.65x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro4.006s (-76.4% 🟢)5.907s (-67.7% 🟢)1.902s61.00x
▲ VercelExpress4.380s (-52.0% 🟢)6.113s (-46.2% 🟢)1.733s61.09x
▲ VercelNext.js (Turbopack)6.477s (+7.7% 🔺)7.965s (-5.6% 🟢)1.488s41.62x

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

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.212s (+1.8%)2.007s (~)0.794s151.00x
🐘 PostgresExpress1.254s (+3.5%)2.027s (+1.0%)0.773s151.03x
🐘 PostgresNext.js (Turbopack)1.295s (+2.5%)2.007s (~)0.712s151.07x
💻 LocalNext.js (Turbopack)1.377s (+1.9%)2.006s (~)0.629s151.14x
💻 LocalExpress1.560s (-5.9% 🟢)2.006s (-3.3%)0.446s151.29x
💻 LocalNitro1.603s (+6.3% 🔺)2.007s (~)0.404s151.32x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.267s (-12.1% 🟢)3.589s (-15.3% 🟢)1.323s91.00x
▲ VercelExpress2.338s (-13.4% 🟢)3.777s (-19.9% 🟢)1.439s81.03x
▲ VercelNext.js (Turbopack)2.688s (-34.5% 🟢)4.300s (-23.7% 🟢)1.613s71.19x

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

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.374s (+6.0% 🔺)2.149s (+6.9% 🔺)0.775s151.00x
🐘 PostgresNitro1.378s (+8.4% 🔺)2.223s (+10.7% 🔺)0.845s141.00x
🐘 PostgresNext.js (Turbopack)1.535s (+9.3% 🔺)2.296s (+14.3% 🔺)0.761s141.12x
💻 LocalNext.js (Turbopack)2.019s (-1.3%)2.591s (-13.9% 🟢)0.572s121.47x
💻 LocalNitro2.118s (-2.4%)2.675s (+3.2%)0.557s121.54x
💻 LocalExpress2.183s (+1.1%)2.735s (+2.2%)0.552s111.59x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.708s (-21.0% 🟢)4.034s (-20.9% 🟢)1.327s81.00x
▲ VercelNext.js (Turbopack)2.904s (-20.4% 🟢)4.585s (-12.1% 🟢)1.681s71.07x
▲ VercelNitro3.259s (-32.5% 🟢)4.713s (-26.4% 🟢)1.455s71.20x

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

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.633s (+17.6% 🔺)4.015s (+99.9% 🔺)2.383s81.00x
🐘 PostgresExpress1.778s (+26.9% 🔺)3.764s (+87.2% 🔺)1.985s81.09x
🐘 PostgresNext.js (Turbopack)3.795s (+109.9% 🔺)4.588s (+106.3% 🔺)0.793s72.32x
💻 LocalExpress4.915s (-22.7% 🟢)5.515s (-21.4% 🟢)0.600s63.01x
💻 LocalNext.js (Turbopack)5.698s (-5.9% 🟢)6.217s (-8.8% 🟢)0.518s53.49x
💻 LocalNitro6.163s (+13.4% 🔺)6.417s (+6.7% 🔺)0.254s53.77x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.482s (-31.8% 🟢)5.220s (-27.9% 🟢)1.738s61.00x
▲ VercelNitro3.723s (-46.8% 🟢)5.600s (-34.9% 🟢)1.877s61.07x
▲ VercelNext.js (Turbopack)4.865s (-39.4% 🟢)6.332s (-35.3% 🟢)1.467s51.40x

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

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.594s (-3.0%)1.005s (~)0.411s601.00x
💻 LocalNitro0.599s (~)1.005s (~)0.406s601.01x
🐘 PostgresNitro0.609s (+5.1% 🔺)1.040s (+3.4%)0.432s581.03x
🐘 PostgresExpress0.671s (+11.7% 🔺)1.147s (+13.9% 🔺)0.476s531.13x
🐘 PostgresNext.js (Turbopack)0.849s (-0.6%)1.006s (-1.7%)0.157s601.43x
💻 LocalNext.js (Turbopack)0.912s (+5.7% 🔺)1.075s (+5.2% 🔺)0.163s571.54x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.786s (+16.6% 🔺)7.078s (+8.6% 🔺)1.292s91.00x
▲ VercelNext.js (Turbopack)5.799s (+11.1% 🔺)7.275s (+6.0% 🔺)1.476s91.00x
▲ VercelExpress6.123s (-39.6% 🟢)7.604s (-35.4% 🟢)1.481s81.06x

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

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.390s (-3.2%)2.029s (~)0.639s451.00x
🐘 PostgresExpress1.504s (+4.9%)2.110s (+5.0% 🔺)0.605s431.08x
💻 LocalExpress1.510s (-2.8%)2.006s (-1.1%)0.496s451.09x
💻 LocalNitro1.528s (+1.5%)2.006s (~)0.479s451.10x
🐘 PostgresNext.js (Turbopack)1.999s (-1.5%)2.308s (-13.1% 🟢)0.309s401.44x
💻 LocalNext.js (Turbopack)2.117s (+0.8%)3.008s (~)0.892s301.52x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express14.919s (+3.8%)16.513s (+2.2%)1.594s61.00x
▲ VercelNitro15.432s (+17.8% 🔺)17.056s (+17.9% 🔺)1.624s61.03x
▲ VercelNext.js (Turbopack)15.850s (+19.2% 🔺)17.815s (+18.0% 🔺)1.965s61.06x

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

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.755s (-1.5%)3.085s (-2.6%)0.330s391.00x
🐘 PostgresExpress2.768s (-1.5%)3.311s (+5.5% 🔺)0.543s371.00x
💻 LocalExpress3.336s (+1.5%)4.042s (+0.8%)0.707s301.21x
💻 LocalNitro3.340s (+1.3%)4.010s (~)0.670s301.21x
🐘 PostgresNext.js (Turbopack)3.913s (-1.8%)4.110s (-5.1% 🟢)0.197s301.42x
💻 LocalNext.js (Turbopack)4.360s (~)5.011s (~)0.650s241.58x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express27.778s (+5.9% 🔺)30.392s (+7.7% 🔺)2.614s51.00x
▲ VercelNitro27.783s (-4.6%)30.056s (-2.4%)2.274s41.00x
▲ VercelNext.js (Turbopack)29.676s (-16.9% 🟢)31.852s (-15.0% 🟢)2.176s41.07x

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

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.252s (+14.8% 🔺)1.006s (~)0.754s601.00x
🐘 PostgresExpress0.264s (+13.3% 🔺)1.009s (~)0.745s601.05x
🐘 PostgresNext.js (Turbopack)0.319s (+15.0% 🔺)1.006s (~)0.687s601.27x
💻 LocalNitro0.417s (-3.0%)1.005s (-1.7%)0.588s601.66x
💻 LocalExpress0.453s (+9.7% 🔺)1.021s (+1.6%)0.568s591.80x
💻 LocalNext.js (Turbopack)0.553s (+1.3%)1.005s (~)0.452s602.20x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.055s (-20.0% 🟢)3.438s (-16.3% 🟢)1.383s181.00x
▲ VercelNitro2.234s (-20.9% 🟢)3.836s (-11.1% 🟢)1.602s161.09x
▲ VercelNext.js (Turbopack)2.376s (-46.7% 🟢)3.893s (-37.5% 🟢)1.517s161.16x

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

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.393s (+5.1% 🔺)1.036s (+3.0%)0.643s871.00x
🐘 PostgresNitro0.421s (+17.3% 🔺)1.029s (+2.2%)0.608s881.07x
🐘 PostgresNext.js (Turbopack)0.619s (+24.1% 🔺)1.133s (+12.5% 🔺)0.513s801.57x
💻 LocalNitro2.207s (+1.2%)2.715s (~)0.508s345.61x
💻 LocalExpress2.208s (+8.4% 🔺)2.737s (+7.9% 🔺)0.529s335.61x
💻 LocalNext.js (Turbopack)2.513s (+2.6%)3.225s (+2.5%)0.712s286.39x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.890s (-46.6% 🟢)4.303s (-39.2% 🟢)1.414s211.00x
▲ VercelNitro3.175s (-65.7% 🟢)4.598s (-57.7% 🟢)1.424s211.10x
▲ VercelNext.js (Turbopack)3.728s (-51.6% 🟢)5.309s (-43.4% 🟢)1.581s181.29x

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

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.711s (+2.2%)1.190s (+18.3% 🔺)0.479s1011.00x
🐘 PostgresNitro0.808s (+18.6% 🔺)1.340s (+33.2% 🔺)0.532s901.14x
🐘 PostgresNext.js (Turbopack)3.015s (+199.9% 🔺)3.798s (+104.8% 🔺)0.782s324.24x
💻 LocalExpress9.681s (+3.9%)10.113s (+2.4%)0.431s1213.61x
💻 LocalNitro9.892s (+6.6% 🔺)10.362s (+5.8% 🔺)0.470s1213.90x
💻 LocalNext.js (Turbopack)10.774s (-1.5%)11.482s (~)0.708s1115.14x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express6.916s (-63.4% 🟢)8.385s (-59.1% 🟢)1.469s151.00x
▲ VercelNitro7.160s (-62.8% 🟢)8.614s (-61.1% 🟢)1.454s151.04x
▲ VercelNext.js (Turbopack)7.650s (-65.4% 🟢)9.225s (-62.2% 🟢)1.575s141.11x

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

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.161s (-0.6%)2.005s (~)0.011s (-10.3% 🟢)2.018s (~)0.857s101.00x
🐘 PostgresExpress1.171s (-0.7%)2.002s (~)0.001s (-25.0% 🟢)2.011s (~)0.840s101.01x
🐘 PostgresNitro1.173s (~)1.998s (~)0.001s (~)2.010s (~)0.838s101.01x
💻 LocalNitro1.188s (+1.9%)2.005s (~)0.013s (+20.2% 🔺)2.020s (~)0.832s101.02x
💻 LocalNext.js (Turbopack)1.210s (~)2.004s (~)0.013s (+1.6%)2.021s (~)0.811s101.04x
🐘 PostgresNext.js (Turbopack)1.252s (+0.5%)2.002s (~)0.001s (-7.7% 🟢)2.011s (~)0.759s101.08x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.283s (+5.3% 🔺)3.012s (-6.0% 🟢)1.285s (+41.4% 🔺)4.735s (+3.4%)2.452s101.00x
▲ VercelNext.js (Turbopack)2.429s (+8.2% 🔺)3.474s (+7.5% 🔺)1.219s (+6.8% 🔺)5.101s (+4.5%)2.672s101.06x
▲ VercelNitro2.460s (+0.5%)3.395s (+1.0%)1.287s (+35.6% 🔺)5.115s (+7.3% 🔺)2.654s101.08x

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

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.574s (~)2.011s (~)0.013s (+11.9% 🔺)2.026s (~)0.452s301.00x
🐘 PostgresNitro1.591s (-1.3%)2.004s (~)0.005s (-0.7%)2.027s (~)0.435s301.01x
💻 LocalNitro1.593s (~)2.010s (~)0.012s (-6.7% 🟢)2.024s (~)0.430s301.01x
🐘 PostgresExpress1.658s (+3.6%)2.071s (+3.2%)0.004s (-20.1% 🟢)2.099s (+3.5%)0.440s291.05x
💻 LocalNext.js (Turbopack)1.743s (~)2.011s (~)0.012s (-1.4%)2.026s (~)0.284s301.11x
🐘 PostgresNext.js (Turbopack)1.822s (+2.0%)2.043s (+1.6%)0.005s (+3.3%)2.061s (+1.6%)0.239s301.16x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.706s (-3.6%)6.557s (-6.8% 🟢)0.248s (-47.2% 🟢)7.186s (-10.0% 🟢)1.480s91.00x
▲ VercelNext.js (Turbopack)5.845s (-9.3% 🟢)6.951s (-9.8% 🟢)0.332s (-40.8% 🟢)7.707s (-13.2% 🟢)1.862s81.02x
▲ VercelExpress5.862s (-3.5%)6.751s (-7.5% 🟢)0.441s (+67.7% 🔺)7.547s (-5.6% 🟢)1.685s81.03x

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

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.809s (+14.2% 🔺)1.081s (+3.6%)0.000s (-32.1% 🟢)1.096s (+3.1%)0.287s561.00x
🐘 PostgresExpress0.948s (+34.0% 🔺)1.295s (+27.7% 🔺)0.000s (+28.3% 🔺)1.319s (+28.2% 🔺)0.371s461.17x
🐘 PostgresNext.js (Turbopack)1.035s (+20.1% 🔺)1.539s (+41.1% 🔺)0.000s (-29.5% 🟢)1.547s (+40.8% 🔺)0.512s391.28x
💻 LocalNitro1.399s (-2.9%)2.013s (~)0.000s (+60.0% 🔺)2.015s (~)0.616s301.73x
💻 LocalExpress1.415s (+4.8%)2.013s (~)0.000s (+75.0% 🔺)2.015s (~)0.600s301.75x
💻 LocalNext.js (Turbopack)1.481s (+1.0%)2.012s (~)0.000s (-13.3% 🟢)2.016s (~)0.535s301.83x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.721s (-28.4% 🟢)3.796s (-21.4% 🟢)0.000s (NaN%)4.171s (-20.1% 🟢)1.450s151.00x
▲ VercelExpress2.801s (-16.6% 🟢)4.062s (-11.2% 🟢)0.000s (-100.0% 🟢)4.442s (-11.9% 🟢)1.641s141.03x
▲ VercelNext.js (Turbopack)2.963s (-19.9% 🟢)4.093s (-17.7% 🟢)0.000s (-100.0% 🟢)4.499s (-17.8% 🟢)1.536s141.09x

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

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

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.560s (+2.3%)2.216s (~)0.000s (-50.0% 🟢)2.245s (~)0.685s271.00x
🐘 PostgresNitro1.701s (+27.5% 🔺)2.217s (+9.4% 🔺)0.000s (+Infinity% 🔺)2.231s (+8.1% 🔺)0.530s271.09x
🐘 PostgresNext.js (Turbopack)2.076s (+20.0% 🔺)2.503s (+10.7% 🔺)0.000s (+Infinity% 🔺)2.510s (+10.5% 🔺)0.434s241.33x
💻 LocalNext.js (Turbopack)2.946s (+3.3%)3.614s (+5.8% 🔺)0.001s (+111.8% 🔺)3.617s (+5.8% 🔺)0.671s171.89x
💻 LocalNitro3.128s (-1.2%)3.779s (-1.5%)0.001s (+400.0% 🔺)3.781s (-1.6%)0.653s162.01x
💻 LocalExpress3.189s (+3.0%)3.841s (+4.6%)0.001s (+6.2% 🔺)3.844s (+4.5%)0.654s162.04x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro4.214s (-42.9% 🟢)5.507s (-30.6% 🟢)0.000s (+Infinity% 🔺)5.899s (-34.7% 🟢)1.685s111.00x
▲ VercelNext.js (Turbopack)4.747s (-46.5% 🟢)5.769s (-51.6% 🟢)0.000s (-95.0% 🟢)6.151s (-50.8% 🟢)1.404s101.13x
▲ VercelExpress4.778s (-10.2% 🟢)6.123s (-6.9% 🟢)0.000s (NaN%)6.547s (-9.0% 🟢)1.769s101.13x

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

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalExpress15/21
🐘 PostgresNitro11/21
▲ VercelExpress10/21
Fastest World by Framework

Winner determined by most benchmark wins

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

Comment threaddocs/content/docs/v5/foundations/hooks.mdx Outdated

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR replaces the hook conflict detection API from hook.hasConflict: Promise<boolean> to hook.getConflict: Promise<Run | null>, so a duplicate run can learn which run currently owns a conflicting hook token (and optionally act on it via the returned Run handle). It also updates the runtime, tests, e2e workflows, and v4/v5 docs to reflect the new API.

Changes:

  • Replace hasConflict plumbing with getConflict, including resolving to a conflicting Run handle and reusing the same instance across awaits.
  • Expose the workflow-bundle Run class via a new WORKFLOW_RUN_CLASS symbol so the hook consumer can construct durable Run handles inside workflow code.
  • Update workbench workflows, unit/e2e tests, docs (v4 + v5), and changesets for the renamed API.

Reviewed changes

Copilot reviewed 23 out of 23 changed files in this pull request and generated 5 comments.

Show a summary per file
FileDescription
workbench/nextjs-turbopack/app/workflows/definitions.tsRenames e2e workflow entries from hookHasConflict* to hookGetConflict*.
workbench/example/workflows/99_e2e.tsUpdates example workflows to use hook.getConflict and return conflict run metadata.
packages/core/src/workflow/hook.tsImplements getConflict resolution, constructs conflicting Run via WORKFLOW_RUN_CLASS.
packages/core/src/workflow/hook.test.tsUpdates unit tests to assert getConflict semantics and stable instance identity.
packages/core/src/workflow/create-hook.tsRegisters workflow-bundle Run class on globalThis[WORKFLOW_RUN_CLASS].
packages/core/src/workflow.test.tsUpdates workflow runner tests for suspension + getConflict behavior.
packages/core/src/symbols.tsAdds WORKFLOW_RUN_CLASS symbol definition and rationale.
packages/core/src/runtime/suspension-handler.tsUpdates comment/semantics references from hasConflict to getConflict.
packages/core/src/runtime/suspension-handler.test.tsUpdates tests and descriptions to the new getConflict naming.
packages/core/src/runtime.tsUpdates inline-execution gating comments to reference hook.getConflict.
packages/core/src/global.tsUpdates hook invocation queue item comment to reflect getConflict.
packages/core/src/create-hook.tsPublic Hook interface now exposes getConflict and updated docs/examples.
packages/core/e2e/e2e.test.tsRenames e2e tests and asserts conflict returns owner runId + durable status.
docs/content/docs/v5/how-it-works/event-sourcing.mdxUpdates hook conflict behavior description to reference getConflict.
docs/content/docs/v5/foundations/hooks.mdxUpdates conflict-checking guidance and examples to use getConflict.
docs/content/docs/v5/api-reference/workflow/create-webhook.mdxUpdates webhook return shape docs to getConflict.
docs/content/docs/v5/api-reference/workflow/create-hook.mdxUpdates API reference + examples from hasConflict to getConflict.
docs/content/docs/v4/how-it-works/event-sourcing.mdxUpdates v4 event-sourcing docs to reference getConflict.
docs/content/docs/v4/foundations/hooks.mdxUpdates v4 conflict-checking docs to getConflict ({ runId } guidance).
docs/content/docs/v4/api-reference/workflow/create-webhook.mdxUpdates v4 webhook docs to getConflict returning { runId }/null.
docs/content/docs/v4/api-reference/workflow/create-hook.mdxUpdates v4 hook API reference to getConflict returning { runId }/null.
.changeset/hook-has-conflict.mdRemoves changeset for the superseded hasConflict API.
.changeset/hook-get-conflict.mdAdds changeset for getConflict.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

// executes inside the VM, so `Run` here is the plugin-compiled variant
// whose methods are durable step proxies. The host-side consumer uses it
// to construct the conflicting run resolved by `hook.getConflict`.
(globalThis as any)[WORKFLOW_RUN_CLASS] ??= Run;

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in 919bde5: the registration is now guarded on the workflow runtime being present (WORKFLOW_CREATE_HOOK is installed on the VM globalThis before the bundle is evaluated), so importing this module outside the VM neither mutates the host global nor exposes the host-side Run (whose methods are not step proxies). Verified via the hookGetConflict e2e suite against a real bundle.

Comment threadpackages/core/src/create-hook.ts Outdated
Comment on lines +34 to +36
* Resolves with the conflicting {@link Run} if another active hook
* already owns this hook's token, or `null` once the hook has been
* registered and is ready to receive payloads.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Already addressed in dec869d (this review appears to have run against the initial commit): createConflictingRun() no longer falls back to a { runId }-shaped object. When a real Run cannot be constructed — including hook_conflict events without conflictingRunIdgetConflict() rejects with HookConflictError instead, and the JSDoc documents exactly that.

Comment threadpackages/core/src/create-hook.ts Outdated
* ```
*/
readonly hasConflict: Promise<boolean>;
readonly getConflict: Promise<Run<unknown> | null>;

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Already addressed in dec869d: the runtime now strictly honors Promise<Run<unknown> | null>. The { runId } fallback was removed — degenerate cases (legacy events without conflictingRunId, contexts without the bundle Run class) reject with HookConflictError rather than resolving with a value that lacks Run accessors, so the public type no longer needs widening.

```

Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.hasConflict` suspends the workflow to commit the hook registration, then resolves with `false` once the hook is registered and ready to receive payloads, or `true` if another active hook already owns the same token (see [`HookConflictError`](/docs/errors/hook-conflict)).
Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.getConflict` suspends the workflow to commit the hook registration, then resolves with `null` once the hook is registered and ready to receive payloads, or with a `Run` handle for the run that owns the token if another active hook already claimed it (see [`HookConflictError`](/docs/errors/hook-conflict)). The conflicting run's accessors are durable steps, so the workflow can inspect `await conflict.status`, wait on `await conflict.returnValue`, or cancel the owner with `await conflict.cancel()` — see [Run idempotency](/docs/foundations/idempotency#run-idempotency) for these strategies.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Clarified in 919bde5: the hooks docs (v4 + v5) now state that for hook_conflict events persisted by older worlds without the owning run ID, getConflict() rejects with HookConflictError instead of resolving with an incomplete handle — so when it does resolve with a conflict, the durable accessors are always available.

Comment thread.changeset/hook-get-conflict.md Outdated
"workflow": minor
---

Add `hook.getConflict`, a promise that suspends the workflow to commit hook registration and resolves with the conflicting `Run` when another active hook owns the token (or `null` once the hook is registered), without waiting for hook payload data.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

that doesn't matter because we didn't ship a new release with the previousl documented API so it's a fast follow and change

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Updated in 919bde5: the changeset now explicitly calls out that this replaces hook.hasConflict and includes the migration (await hook.hasConflictconst conflict = await hook.getConflict(), branch on conflict !== null).

getConflict's contract is Promise<Run | null>. In the degenerate cases
where a real Run cannot be constructed — a hook_conflict event persisted
by an old world without conflictingRunId, or a context that never loaded
the workflow-mode create-hook module — reject with HookConflictError
instead of resolving with a { runId }-shaped impostor.
Test harnesses now register the Run class on the (VM) globalThis like
real bundles do.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Request Changes — the feature is right, but the WORKFLOW_RUN_CLASS global duplicates infrastructure the serialization pipeline already provides

First: the API direction is a clear improvement. A Run handle beats the boolean (hasConflict told you that, not who), code-driven conflict strategy beats a fixed policy enum, and the dec869d9e decision to reject with HookConflictError rather than resolve a { runId }-shaped impostor keeps the Run contract honest. The determinism story checks out too: both fast-paths settle through promiseQueue, conflictRunRef is shared so repeated awaits observe one instance, replay reconstructs the handle from the same event-log conflictingRunId, and the bundle-compiled Run's accessors are step proxies (I verified the compiled output — Object.defineProperty(Run.prototype, ...) with __step_Run$... proxies), so await conflict.status / conflict.cancel() from workflow code are durable. 1156 unit tests pass locally; the red express E2E lane is failing errorWorkflowNested/errorWorkflowCrossFile (not hook tests) — baseline flake.

The change I'm requesting: use the class registry instead of a bespoke global

The (globalThis as any)[WORKFLOW_RUN_CLASS] ??= Run registration in workflow-mode create-hook.ts re-implements, ad hoc, something the serialization pipeline already does systematically. I built workbench/example from this branch and inspected the compiled flow bundle:

(function(__wf_cls,__wf_id){var__wf_sym=Symbol.for("workflow-class-registry"), ...
})(Run,"class//./packages/core/dist/runtime/run//Run");

The SWC plugin already registers the workflow-compiled Run in globalThis[WORKFLOW_CLASS_REGISTRY] at bundle evaluation — because Run carries WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE. This is the exact channel a start()-returned Run already uses to cross from host to VM today: the Instance reducer emits { classId, data: { runId, resilientStart } }, and getClassRevivers(global).Instance looks the class up via getSerializationClass(classId, global) against the VM's global and calls its WORKFLOW_DESERIALIZE.

So the class this PR smuggles through a new symbol is already sitting in the VM's registry when the hook consumer runs. The only missing piece is the lookup key — the plugin derives it from the module path (class//./packages/core/dist/runtime/run//Run in the workbench; different prefix under npm-installed layouts / other bundlers), so it can't be hardcoded.

Proposal (~15 lines total):

  1. Register Run under a stable, well-known classId alongside the plugin's path-derived one. registerSerializationClass can't be called twice (its non-configurable defineProperty(cls, 'classId') throws once the plugin IIFE has run), so add a tiny alias helper to class-serialization.ts:
/** Register an additional stable id for an already-registered class * (skips the classId defineProperty). */exportfunctionaliasSerializationClass(classId: string,cls: Function){getRegistry().set(classId,cls);}
  1. Call it at module scope of runtime/run.ts (e.g. aliasSerializationClass('class//workflow//Run', Run)). Note what this buys over the symbol approach: per-context discipline for free. Evaluated inside the VM, it registers the VM's step-proxy Run on the VM's registry; evaluated on the host, it registers the host Run on the host's registry. Each global gets its own correct class — which means the if (globalThis[WORKFLOW_CREATE_HOOK]) guard and the ??= (both of which exist purely to keep the host Run from leaking into the wrong global) simply disappear, along with the module-scope conditional in create-hook.ts and the new symbol in symbols.ts.

  2. The consumer constructs through the pipeline's own hook instead of new RunClass(id):

functioncreateConflictingRun(ctx,conflictingRunId){if(typeofconflictingRunId!=='string')returnnull;constRunClass=getSerializationClass(RUN_STABLE_CLASS_ID,ctx.globalThis);if(!RunClass)returnnull;return(RunClassasany)[WORKFLOW_DESERIALIZE]({runId: conflictingRunId});}

Same degenerate-case semantics you already have (registry miss → null → awaiters reject with HookConflictError), but the construction takes the identical code path a serialized Run instance takes everywhere else. class-serialization.ts is deliberately dependency-light ("separate from private.ts to avoid pulling in Node.js-only dependencies"), so importing it from workflow/hook.ts is safe.

Side benefit worth banking: a stable classId hardens Run serialization generally. Today a Run persisted in the event log (e.g. a start()-in-workflow handle in a step result) embeds the path-derived id, which is only safe because version-skew protection pins replay to the same deployment whose bundles agree on the path. A stable alias removes that coupling — any future cross-deployment replay (or a bundler change mid-release) stops being able to break Run hydration with "Class not found".

I considered the fuller "interact with the serialization pipeline" variant — making getConflict() resolution a step so the standard step-return serde does everything — and I think your current design is better: piggybacking on hook_created/hook_conflict costs zero extra events and zero queue hops, where a step-based resolution would add one of each per call. The registry lookup keeps that property while deleting the bespoke channel.

Smaller notes

  • The v4 docs describe a { runId } return that no released 4.x SDK has (and getConflict itself isn't on stable). Same sequencing flag as #2015/#2011 — the v4 pages keep widening the documented-but-unreleased surface; fine if the backport chain is imminent, worth holding otherwise.
  • e56e6fbc25b3b88b0b's getter→method change (hook.getConflict()) is the right call — a property that allocates and enqueues new promise resolvers on every access was the same trap hasConflict had; a method at least signals effects.
  • Changeset correctly replaces the hasConflict one and is scoped to @workflow/core + workflow (minor) — and since hasConflict shipped in no stable release and at most one beta, replacing rather than deprecating is fine.

Happy to re-review quickly once the registry switch is in — everything else here is ready.

… registry
Replace the bespoke WORKFLOW_RUN_CLASS global with the registry the
serialization pipeline already uses to revive Run instances:
- The SWC plugin already auto-registers the workflow bundle's compiled
Run in globalThis[workflow-class-registry], but under a path-derived
classId the host cannot know statically. The workflow-mode create-hook
module now aliases it under a stable id (class//workflow//Run) via a
new aliasSerializationClass() helper (a plain registry entry —
registerSerializationClass cannot be reused since the plugin's IIFE
already defined the non-configurable classId property).
- createConflictingRun() looks the class up with
getSerializationClass(RUN_CLASS_ID, ctx.globalThis) and constructs
through its WORKFLOW_DESERIALIZE hook, exactly as the Instance reviver
would for a serialized Run crossing from a step into the workflow.
- Because the registry is keyed per-global, no environment guard is
needed: a stray host-side import registers the host Run on the host
registry, which is the correct class for that context. The
WORKFLOW_CREATE_HOOK guard, the ??=, and the WORKFLOW_RUN_CLASS symbol
are all deleted.
Verified: 1156 core unit tests; compiled workbench bundle contains the
stable alias alongside the plugin's path-derived registration with zero
WORKFLOW_RUN_CLASS references; all 5 hookGetConflict e2e tests pass
against a local nextjs-turbopack dev server, including conflict
resolution reading conflict.status through a durable step.

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approve — the registry switch from my previous review is now implemented (0aa3cb0)

I implemented the change I requested (after discussing with Nathan), so this approval covers Pranay's feature plus that refactor — Pranay, please give 0aa3cb0 a once-over since it rewrites the class-handoff mechanism you authored.

What changed:

  • WORKFLOW_RUN_CLASS is gone (symbol, the ??= registration, and the WORKFLOW_CREATE_HOOK guard). The workflow-mode create-hook module now aliases the bundle's Run into the existing serialization class registry under a stable id (class//workflow//Run) via a new aliasSerializationClass() helper — a plain registry entry, since registerSerializationClass's non-configurable defineProperty(classId) would throw on a class the SWC plugin already registered.
  • createConflictingRun resolves through the pipeline: getSerializationClass(RUN_CLASS_ID, ctx.globalThis) + construction via the class's WORKFLOW_DESERIALIZE hook — the identical path the Instance reviver takes for a serialized Run crossing from a step into the workflow. Degenerate-case semantics unchanged: registry miss or missing deserialize hook → null → awaiters reject with HookConflictError.
  • No environment guard needed: the registry is per-global by construction, so a stray host-side import of the module registers the host Run on the host registry — correct for that context.

Verification:

  • 1156 core unit tests pass; hook.test.ts / workflow.test.ts mocks updated to register through the registry (the VM-string test now mirrors the real bundle shape, including the WORKFLOW_DESERIALIZE hook)
  • Rebuilt workbench/example: compiled flow bundle contains the stable alias alongside the plugin's path-derived registration, with zero WORKFLOW_RUN_CLASS references
  • All 5 hookGetConflict* e2e tests pass against a local nextjs-turbopack dev server, including the conflict test that resolves the actual owner and reads await conflict.status through a durable step — the full host → VM-registry → WORKFLOW_DESERIALIZE → step-proxy chain
  • The two biome complexity findings in hook.ts are pre-existing on this branch (identical count before/after)

Remaining notes from my previous review stand but don't block: the v4 docs describe a surface not yet on stable (recurring sequencing flag), and the stable classId incidentally hardens Run event-log serialization against future cross-deployment replay.

@github-actions

Copy link
Copy Markdown
Contributor

Backport PR opened against stable: #2382. Merge conflicts were resolved by AI — please review carefully. (backport job run)

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

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

Replace hook.hasConflict with hook.getConflict() returning the conflicting Run - #2373

Merged
TooTallNate merged 5 commits into
mainfrom
pranaygp/hook-get-conflict
Jun 12, 2026
Merged

Replace hook.hasConflict with hook.getConflict() returning the conflicting Run#2373
TooTallNate merged 5 commits into
mainfrom
pranaygp/hook-get-conflict

Conversation

@pranaygp

@pranaygppranaygp commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #2015. hook.hasConflict's boolean told the workflow that a conflict exists but not which run owns the token, so the duplicate run couldn't act on it. This replaces it with hook.getConflict(), a promise that resolves with:

  • null once the hook registration is committed (hook_created recorded), or
  • a Run handle for the conflicting run when another active hook owns the token.

Like hasConflict, awaiting getConflict() suspends the workflow to commit the hook registration without waiting for payload data. But on conflict, the workflow now has the active owner in hand and can choose its own idempotency strategy in code:

using hook=createHook({token: `order:${orderId}`});constconflict=awaithook.getConflict();if(conflict){// any of:return{dedupedTo: conflict.runId};// route caller to the ownerconststatus=awaitconflict.status;// inspect before decidingreturnawaitconflict.returnValue;// adopt the owner's resultawaitconflict.cancel();// supersede the owner and continue}

This is deliberately code-driven rather than a fixed set of ID-reuse policies (cf. Temporal's WorkflowIdReusePolicy / WorkflowIdConflictPolicy).

Implementation

  • The workflow-mode create-hook module exposes the bundle's compiled Run class on a well-known global symbol (WORKFLOW_RUN_CLASS). The bundle's Run is the SWC-plugin-compiled variant whose accessors (status, returnValue, cancel(), …) are durable step proxies — safe and deterministic inside workflow code. The host-side hook event consumer constructs the conflicting Run from the hook_conflict event's conflictingRunId (Expose conflicting run id on hook conflicts #2012) using that class.
  • getConflict never resolves with anything but a real Run or null. In the degenerate cases where a Run cannot be constructed — a hook_conflict event persisted by an old world without conflictingRunId, or a context that never loaded the workflow-mode create-hook module — it rejects with HookConflictError (which still carries the token) instead of resolving with a { runId }-shaped impostor. Note: the { runId } shape remains the documented v4 return value, since v4 has no native Run serialization — v4 docs direct users to getRun(conflict.runId) inside a step.
  • Repeated await hook.getConflict() calls observe the same Run instance; both fast-paths settle through promiseQueue to keep resolution order aligned with the event log.
  • Awaiting the hook payload on a conflicted token still rejects with HookConflictError.

Docs

Updated createHook() / createWebhook() references, hooks foundations, and event-sourcing pages (v4 + v5; v5 documents the Run return, v4 documents { runId }). A follow-up to #2011 adds the full conflict-handling strategy guide to the run-idempotency docs.

Validation

  • cd packages/core && pnpm typecheck && pnpm vitest run src/ (1155 tests)
  • pnpm test:docs
  • Hook e2e suite against local nextjs-turbopack: all hookGetConflict* tests pass, including the conflict test asserting the duplicate run resolves the actual owner's runId and reads await conflict.status === 'running' via a durable step.

…ull>)
hasConflict's boolean didn't expose WHICH run owns the token, so the
duplicate run couldn't act on the conflict. getConflict resolves with
null once registration commits, or with a Run handle for the conflicting
run — letting the workflow return/log the owner's runId, inspect its
status, await its result, or cancel it and continue, all in code.
The workflow-mode create-hook module exposes the bundle's compiled Run
class (durable step-proxy methods) on a well-known symbol so the host-
side hook consumer can construct the conflicting run inside the VM.
Contexts without the class (plain unit tests) fall back to a { runId }
object, which is also the documented v4 shape (no native Run
serialization in v4).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings June 11, 2026 23:11
@pranaygp
pranaygp requested a review from a team as a code ownerJune 11, 2026 23:11
@changeset-bot

changeset-botBot commented Jun 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0aa3cb0

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

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

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production137602191595
✅ 💻 Local Development181102192030
✅ 📦 Local Production181102192030
✅ 🐘 Local Postgres179702332030
✅ 🪟 Windows14500145
✅ 📋 Other83701781015
Total7777010688845

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro119026
✅ example119026
✅ express119026
✅ fastify119026
✅ hono119026
✅ nextjs-turbopack14302
✅ nextjs-webpack14302
✅ nitro119026
✅ nuxt119026
✅ sveltekit13807
✅ vite119026
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable120025
✅ express-stable120025
✅ fastify-stable120025
✅ hono-stable120025
✅ nextjs-turbopack-canary126019
✅ nextjs-turbopack-stable-lazy-discovery-disabled14500
✅ nextjs-turbopack-stable-lazy-discovery-enabled14500
✅ nextjs-webpack-canary126019
✅ nextjs-webpack-stable-lazy-discovery-disabled14500
✅ nextjs-webpack-stable-lazy-discovery-enabled14500
✅ nitro-stable120025
✅ nuxt-stable120025
✅ sveltekit-stable13906
✅ vite-stable120025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable120025
✅ express-stable120025
✅ fastify-stable120025
✅ hono-stable120025
✅ nextjs-turbopack-canary126019
✅ nextjs-turbopack-stable-lazy-discovery-disabled14500
✅ nextjs-turbopack-stable-lazy-discovery-enabled14500
✅ nextjs-webpack-canary126019
✅ nextjs-webpack-stable-lazy-discovery-disabled14500
✅ nextjs-webpack-stable-lazy-discovery-enabled14500
✅ nitro-stable120025
✅ nuxt-stable120025
✅ sveltekit-stable13906
✅ vite-stable120025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable119026
✅ express-stable119026
✅ fastify-stable119026
✅ hono-stable119026
✅ nextjs-turbopack-canary125020
✅ nextjs-turbopack-stable-lazy-discovery-disabled14401
✅ nextjs-turbopack-stable-lazy-discovery-enabled14401
✅ nextjs-webpack-canary125020
✅ nextjs-webpack-stable-lazy-discovery-disabled14401
✅ nextjs-webpack-stable-lazy-discovery-enabled14401
✅ nitro-stable119026
✅ nuxt-stable119026
✅ sveltekit-stable13807
✅ vite-stable119026
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack14500
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable120025
✅ e2e-local-dev-tanstack-start-120025
✅ e2e-local-postgres-nest-stable119026
✅ e2e-local-postgres-tanstack-start-119026
✅ e2e-local-prod-nest-stable120025
✅ e2e-local-prod-tanstack-start-120025
✅ e2e-vercel-prod-tanstack-start119026

📋 View full workflow run

@github-actions

github-actionsBot commented Jun 11, 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.042s (-1.9%)1.006s (~)0.965s101.00x
💻 LocalNitro0.046s (+7.8% 🔺)1.006s (~)0.960s101.10x
🐘 PostgresNitro0.065s (-1.5%)1.013s (~)0.948s101.56x
💻 LocalNext.js (Turbopack)0.066s (+5.2% 🔺)1.006s (~)0.940s101.59x
🐘 PostgresNext.js (Turbopack)0.072s (-4.3%)1.012s (~)0.941s101.72x
🐘 PostgresExpress0.080s (+30.0% 🔺)1.032s (+2.0%)0.952s101.92x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.244s (-35.3% 🟢)2.122s (-12.6% 🟢)1.878s101.00x
▲ VercelNitro0.260s (-31.1% 🟢)2.099s (~)1.839s101.07x
▲ VercelExpress0.268s (-1.1%)2.117s (-18.0% 🟢)1.850s101.10x

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

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.089s (-2.4%)2.011s (~)0.922s101.00x
💻 LocalExpress1.096s (~)2.007s (~)0.911s101.01x
💻 LocalNitro1.101s (+0.9%)2.006s (~)0.905s101.01x
🐘 PostgresNitro1.108s (~)2.009s (~)0.900s101.02x
💻 LocalNext.js (Turbopack)1.139s (+1.0%)2.007s (~)0.867s101.05x
🐘 PostgresNext.js (Turbopack)1.185s (+3.7%)2.011s (~)0.826s101.09x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express1.616s (-7.8% 🟢)3.302s (-11.0% 🟢)1.686s101.00x
▲ VercelNitro1.696s (+1.2%)3.485s (+1.0%)1.790s101.05x
▲ VercelNext.js (Turbopack)1.769s (+5.4% 🔺)3.307s (-9.1% 🟢)1.539s101.09x

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

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express10.496s (-0.9%)11.031s (~)0.535s31.00x
💻 LocalExpress10.500s (~)11.021s (~)0.521s31.00x
💻 LocalNitro10.535s (~)11.023s (~)0.488s31.00x
🐘 PostgresNitro10.568s (~)11.017s (~)0.450s31.01x
💻 LocalNext.js (Turbopack)10.794s (~)11.023s (~)0.229s31.03x
🐘 PostgresNext.js (Turbopack)10.842s (-1.6%)11.017s (-5.7% 🟢)0.175s31.03x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro13.610s (+2.0%)15.211s (~)1.601s21.00x
▲ VercelExpress14.017s (+4.5%)15.119s (-3.9%)1.102s21.03x
▲ VercelNext.js (Turbopack)14.111s (~)15.512s (-4.1%)1.401s21.04x

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

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express13.731s (-0.8%)14.028s (~)0.297s51.00x
💻 LocalNitro13.787s (~)14.027s (-1.4%)0.240s51.00x
🐘 PostgresExpress13.956s (+0.9%)14.418s (+2.8%)0.462s51.02x
🐘 PostgresNitro13.986s (+1.2%)14.220s (+1.4%)0.234s51.02x
💻 LocalNext.js (Turbopack)14.348s (-1.0%)15.030s (~)0.681s41.05x
🐘 PostgresNext.js (Turbopack)14.458s (~)15.020s (~)0.562s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro20.475s (-8.7% 🟢)22.157s (-8.0% 🟢)1.681s31.00x
▲ VercelExpress20.531s (-4.4%)21.964s (-4.8%)1.434s31.00x
▲ VercelNext.js (Turbopack)21.986s (+1.8%)23.788s (+1.9%)1.801s31.07x

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

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express12.391s (-0.9%)13.024s (~)0.633s71.00x
🐘 PostgresExpress12.417s (-2.6%)13.017s (~)0.600s71.00x
💻 LocalNitro12.529s (~)13.024s (~)0.495s71.01x
🐘 PostgresNitro12.730s (+1.2%)13.162s (+1.1%)0.433s71.03x
💻 LocalNext.js (Turbopack)13.654s (~)14.026s (~)0.372s71.10x
🐘 PostgresNext.js (Turbopack)14.084s (+0.8%)14.450s (+1.0%)0.366s71.14x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro29.382s (-7.3% 🟢)31.701s (-4.8%)2.319s31.00x
▲ VercelExpress29.422s (-0.8%)31.369s (+0.7%)1.947s31.00x
▲ VercelNext.js (Turbopack)30.219s (+5.4% 🔺)32.193s (+6.0% 🔺)1.973s31.03x

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

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.204s (~)2.015s (~)0.811s151.00x
🐘 PostgresNitro1.217s (+1.1%)2.008s (~)0.791s151.01x
💻 LocalExpress1.228s (+3.2%)2.006s (~)0.778s151.02x
💻 LocalNitro1.250s (+2.2%)2.006s (~)0.756s151.04x
💻 LocalNext.js (Turbopack)1.275s (-5.4% 🟢)2.006s (~)0.731s151.06x
🐘 PostgresNext.js (Turbopack)1.294s (+2.8%)2.008s (~)0.714s151.07x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.316s (-37.4% 🟢)3.726s (-28.1% 🟢)1.410s91.00x
▲ VercelExpress2.794s (+9.5% 🔺)4.184s (-7.0% 🟢)1.391s81.21x
▲ VercelNext.js (Turbopack)3.004s (-1.3%)4.488s (-2.3%)1.484s71.30x

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

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.367s (+8.6% 🔺)2.508s (+25.0% 🔺)1.141s121.00x
🐘 PostgresExpress1.422s (+12.6% 🔺)2.172s (+8.2% 🔺)0.750s141.04x
💻 LocalExpress1.755s (-4.7%)2.006s (-6.7% 🟢)0.251s151.28x
💻 LocalNext.js (Turbopack)1.834s (-2.4%)2.008s (-6.6% 🟢)0.174s151.34x
💻 LocalNitro1.853s (+8.9% 🔺)2.151s (+7.2% 🔺)0.298s141.36x
🐘 PostgresNext.js (Turbopack)1.855s (+32.4% 🔺)2.396s (+19.4% 🔺)0.541s131.36x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.168s (-28.3% 🟢)4.565s (-25.4% 🟢)1.397s81.00x
▲ VercelNext.js (Turbopack)3.520s (-98.9% 🟢)5.051s (-98.4% 🟢)1.531s61.11x
▲ VercelNitro4.477s (-28.2% 🟢)6.029s (-22.2% 🟢)1.552s51.41x

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

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.578s (+12.1% 🔺)3.762s (+87.3% 🔺)2.184s81.00x
🐘 PostgresExpress1.758s (+25.4% 🔺)3.577s (+78.1% 🔺)1.819s91.11x
🐘 PostgresNext.js (Turbopack)3.622s (+100.8% 🔺)4.583s (+99.5% 🔺)0.962s72.30x
💻 LocalExpress4.738s (-12.0% 🟢)5.514s (-10.8% 🟢)0.775s63.00x
💻 LocalNitro5.252s (~)5.846s (-2.8%)0.595s63.33x
💻 LocalNext.js (Turbopack)5.763s (+1.0%)6.216s (~)0.453s53.65x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro4.006s (-76.4% 🟢)5.907s (-67.7% 🟢)1.902s61.00x
▲ VercelExpress4.380s (-52.0% 🟢)6.113s (-46.2% 🟢)1.733s61.09x
▲ VercelNext.js (Turbopack)6.477s (+7.7% 🔺)7.965s (-5.6% 🟢)1.488s41.62x

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

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.212s (+1.8%)2.007s (~)0.794s151.00x
🐘 PostgresExpress1.254s (+3.5%)2.027s (+1.0%)0.773s151.03x
🐘 PostgresNext.js (Turbopack)1.295s (+2.5%)2.007s (~)0.712s151.07x
💻 LocalNext.js (Turbopack)1.377s (+1.9%)2.006s (~)0.629s151.14x
💻 LocalExpress1.560s (-5.9% 🟢)2.006s (-3.3%)0.446s151.29x
💻 LocalNitro1.603s (+6.3% 🔺)2.007s (~)0.404s151.32x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.267s (-12.1% 🟢)3.589s (-15.3% 🟢)1.323s91.00x
▲ VercelExpress2.338s (-13.4% 🟢)3.777s (-19.9% 🟢)1.439s81.03x
▲ VercelNext.js (Turbopack)2.688s (-34.5% 🟢)4.300s (-23.7% 🟢)1.613s71.19x

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

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.374s (+6.0% 🔺)2.149s (+6.9% 🔺)0.775s151.00x
🐘 PostgresNitro1.378s (+8.4% 🔺)2.223s (+10.7% 🔺)0.845s141.00x
🐘 PostgresNext.js (Turbopack)1.535s (+9.3% 🔺)2.296s (+14.3% 🔺)0.761s141.12x
💻 LocalNext.js (Turbopack)2.019s (-1.3%)2.591s (-13.9% 🟢)0.572s121.47x
💻 LocalNitro2.118s (-2.4%)2.675s (+3.2%)0.557s121.54x
💻 LocalExpress2.183s (+1.1%)2.735s (+2.2%)0.552s111.59x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.708s (-21.0% 🟢)4.034s (-20.9% 🟢)1.327s81.00x
▲ VercelNext.js (Turbopack)2.904s (-20.4% 🟢)4.585s (-12.1% 🟢)1.681s71.07x
▲ VercelNitro3.259s (-32.5% 🟢)4.713s (-26.4% 🟢)1.455s71.20x

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

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.633s (+17.6% 🔺)4.015s (+99.9% 🔺)2.383s81.00x
🐘 PostgresExpress1.778s (+26.9% 🔺)3.764s (+87.2% 🔺)1.985s81.09x
🐘 PostgresNext.js (Turbopack)3.795s (+109.9% 🔺)4.588s (+106.3% 🔺)0.793s72.32x
💻 LocalExpress4.915s (-22.7% 🟢)5.515s (-21.4% 🟢)0.600s63.01x
💻 LocalNext.js (Turbopack)5.698s (-5.9% 🟢)6.217s (-8.8% 🟢)0.518s53.49x
💻 LocalNitro6.163s (+13.4% 🔺)6.417s (+6.7% 🔺)0.254s53.77x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.482s (-31.8% 🟢)5.220s (-27.9% 🟢)1.738s61.00x
▲ VercelNitro3.723s (-46.8% 🟢)5.600s (-34.9% 🟢)1.877s61.07x
▲ VercelNext.js (Turbopack)4.865s (-39.4% 🟢)6.332s (-35.3% 🟢)1.467s51.40x

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

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.594s (-3.0%)1.005s (~)0.411s601.00x
💻 LocalNitro0.599s (~)1.005s (~)0.406s601.01x
🐘 PostgresNitro0.609s (+5.1% 🔺)1.040s (+3.4%)0.432s581.03x
🐘 PostgresExpress0.671s (+11.7% 🔺)1.147s (+13.9% 🔺)0.476s531.13x
🐘 PostgresNext.js (Turbopack)0.849s (-0.6%)1.006s (-1.7%)0.157s601.43x
💻 LocalNext.js (Turbopack)0.912s (+5.7% 🔺)1.075s (+5.2% 🔺)0.163s571.54x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.786s (+16.6% 🔺)7.078s (+8.6% 🔺)1.292s91.00x
▲ VercelNext.js (Turbopack)5.799s (+11.1% 🔺)7.275s (+6.0% 🔺)1.476s91.00x
▲ VercelExpress6.123s (-39.6% 🟢)7.604s (-35.4% 🟢)1.481s81.06x

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

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.390s (-3.2%)2.029s (~)0.639s451.00x
🐘 PostgresExpress1.504s (+4.9%)2.110s (+5.0% 🔺)0.605s431.08x
💻 LocalExpress1.510s (-2.8%)2.006s (-1.1%)0.496s451.09x
💻 LocalNitro1.528s (+1.5%)2.006s (~)0.479s451.10x
🐘 PostgresNext.js (Turbopack)1.999s (-1.5%)2.308s (-13.1% 🟢)0.309s401.44x
💻 LocalNext.js (Turbopack)2.117s (+0.8%)3.008s (~)0.892s301.52x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express14.919s (+3.8%)16.513s (+2.2%)1.594s61.00x
▲ VercelNitro15.432s (+17.8% 🔺)17.056s (+17.9% 🔺)1.624s61.03x
▲ VercelNext.js (Turbopack)15.850s (+19.2% 🔺)17.815s (+18.0% 🔺)1.965s61.06x

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

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.755s (-1.5%)3.085s (-2.6%)0.330s391.00x
🐘 PostgresExpress2.768s (-1.5%)3.311s (+5.5% 🔺)0.543s371.00x
💻 LocalExpress3.336s (+1.5%)4.042s (+0.8%)0.707s301.21x
💻 LocalNitro3.340s (+1.3%)4.010s (~)0.670s301.21x
🐘 PostgresNext.js (Turbopack)3.913s (-1.8%)4.110s (-5.1% 🟢)0.197s301.42x
💻 LocalNext.js (Turbopack)4.360s (~)5.011s (~)0.650s241.58x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express27.778s (+5.9% 🔺)30.392s (+7.7% 🔺)2.614s51.00x
▲ VercelNitro27.783s (-4.6%)30.056s (-2.4%)2.274s41.00x
▲ VercelNext.js (Turbopack)29.676s (-16.9% 🟢)31.852s (-15.0% 🟢)2.176s41.07x

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

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.252s (+14.8% 🔺)1.006s (~)0.754s601.00x
🐘 PostgresExpress0.264s (+13.3% 🔺)1.009s (~)0.745s601.05x
🐘 PostgresNext.js (Turbopack)0.319s (+15.0% 🔺)1.006s (~)0.687s601.27x
💻 LocalNitro0.417s (-3.0%)1.005s (-1.7%)0.588s601.66x
💻 LocalExpress0.453s (+9.7% 🔺)1.021s (+1.6%)0.568s591.80x
💻 LocalNext.js (Turbopack)0.553s (+1.3%)1.005s (~)0.452s602.20x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.055s (-20.0% 🟢)3.438s (-16.3% 🟢)1.383s181.00x
▲ VercelNitro2.234s (-20.9% 🟢)3.836s (-11.1% 🟢)1.602s161.09x
▲ VercelNext.js (Turbopack)2.376s (-46.7% 🟢)3.893s (-37.5% 🟢)1.517s161.16x

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

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.393s (+5.1% 🔺)1.036s (+3.0%)0.643s871.00x
🐘 PostgresNitro0.421s (+17.3% 🔺)1.029s (+2.2%)0.608s881.07x
🐘 PostgresNext.js (Turbopack)0.619s (+24.1% 🔺)1.133s (+12.5% 🔺)0.513s801.57x
💻 LocalNitro2.207s (+1.2%)2.715s (~)0.508s345.61x
💻 LocalExpress2.208s (+8.4% 🔺)2.737s (+7.9% 🔺)0.529s335.61x
💻 LocalNext.js (Turbopack)2.513s (+2.6%)3.225s (+2.5%)0.712s286.39x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.890s (-46.6% 🟢)4.303s (-39.2% 🟢)1.414s211.00x
▲ VercelNitro3.175s (-65.7% 🟢)4.598s (-57.7% 🟢)1.424s211.10x
▲ VercelNext.js (Turbopack)3.728s (-51.6% 🟢)5.309s (-43.4% 🟢)1.581s181.29x

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

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.711s (+2.2%)1.190s (+18.3% 🔺)0.479s1011.00x
🐘 PostgresNitro0.808s (+18.6% 🔺)1.340s (+33.2% 🔺)0.532s901.14x
🐘 PostgresNext.js (Turbopack)3.015s (+199.9% 🔺)3.798s (+104.8% 🔺)0.782s324.24x
💻 LocalExpress9.681s (+3.9%)10.113s (+2.4%)0.431s1213.61x
💻 LocalNitro9.892s (+6.6% 🔺)10.362s (+5.8% 🔺)0.470s1213.90x
💻 LocalNext.js (Turbopack)10.774s (-1.5%)11.482s (~)0.708s1115.14x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express6.916s (-63.4% 🟢)8.385s (-59.1% 🟢)1.469s151.00x
▲ VercelNitro7.160s (-62.8% 🟢)8.614s (-61.1% 🟢)1.454s151.04x
▲ VercelNext.js (Turbopack)7.650s (-65.4% 🟢)9.225s (-62.2% 🟢)1.575s141.11x

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

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.161s (-0.6%)2.005s (~)0.011s (-10.3% 🟢)2.018s (~)0.857s101.00x
🐘 PostgresExpress1.171s (-0.7%)2.002s (~)0.001s (-25.0% 🟢)2.011s (~)0.840s101.01x
🐘 PostgresNitro1.173s (~)1.998s (~)0.001s (~)2.010s (~)0.838s101.01x
💻 LocalNitro1.188s (+1.9%)2.005s (~)0.013s (+20.2% 🔺)2.020s (~)0.832s101.02x
💻 LocalNext.js (Turbopack)1.210s (~)2.004s (~)0.013s (+1.6%)2.021s (~)0.811s101.04x
🐘 PostgresNext.js (Turbopack)1.252s (+0.5%)2.002s (~)0.001s (-7.7% 🟢)2.011s (~)0.759s101.08x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.283s (+5.3% 🔺)3.012s (-6.0% 🟢)1.285s (+41.4% 🔺)4.735s (+3.4%)2.452s101.00x
▲ VercelNext.js (Turbopack)2.429s (+8.2% 🔺)3.474s (+7.5% 🔺)1.219s (+6.8% 🔺)5.101s (+4.5%)2.672s101.06x
▲ VercelNitro2.460s (+0.5%)3.395s (+1.0%)1.287s (+35.6% 🔺)5.115s (+7.3% 🔺)2.654s101.08x

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

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.574s (~)2.011s (~)0.013s (+11.9% 🔺)2.026s (~)0.452s301.00x
🐘 PostgresNitro1.591s (-1.3%)2.004s (~)0.005s (-0.7%)2.027s (~)0.435s301.01x
💻 LocalNitro1.593s (~)2.010s (~)0.012s (-6.7% 🟢)2.024s (~)0.430s301.01x
🐘 PostgresExpress1.658s (+3.6%)2.071s (+3.2%)0.004s (-20.1% 🟢)2.099s (+3.5%)0.440s291.05x
💻 LocalNext.js (Turbopack)1.743s (~)2.011s (~)0.012s (-1.4%)2.026s (~)0.284s301.11x
🐘 PostgresNext.js (Turbopack)1.822s (+2.0%)2.043s (+1.6%)0.005s (+3.3%)2.061s (+1.6%)0.239s301.16x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.706s (-3.6%)6.557s (-6.8% 🟢)0.248s (-47.2% 🟢)7.186s (-10.0% 🟢)1.480s91.00x
▲ VercelNext.js (Turbopack)5.845s (-9.3% 🟢)6.951s (-9.8% 🟢)0.332s (-40.8% 🟢)7.707s (-13.2% 🟢)1.862s81.02x
▲ VercelExpress5.862s (-3.5%)6.751s (-7.5% 🟢)0.441s (+67.7% 🔺)7.547s (-5.6% 🟢)1.685s81.03x

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

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.809s (+14.2% 🔺)1.081s (+3.6%)0.000s (-32.1% 🟢)1.096s (+3.1%)0.287s561.00x
🐘 PostgresExpress0.948s (+34.0% 🔺)1.295s (+27.7% 🔺)0.000s (+28.3% 🔺)1.319s (+28.2% 🔺)0.371s461.17x
🐘 PostgresNext.js (Turbopack)1.035s (+20.1% 🔺)1.539s (+41.1% 🔺)0.000s (-29.5% 🟢)1.547s (+40.8% 🔺)0.512s391.28x
💻 LocalNitro1.399s (-2.9%)2.013s (~)0.000s (+60.0% 🔺)2.015s (~)0.616s301.73x
💻 LocalExpress1.415s (+4.8%)2.013s (~)0.000s (+75.0% 🔺)2.015s (~)0.600s301.75x
💻 LocalNext.js (Turbopack)1.481s (+1.0%)2.012s (~)0.000s (-13.3% 🟢)2.016s (~)0.535s301.83x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.721s (-28.4% 🟢)3.796s (-21.4% 🟢)0.000s (NaN%)4.171s (-20.1% 🟢)1.450s151.00x
▲ VercelExpress2.801s (-16.6% 🟢)4.062s (-11.2% 🟢)0.000s (-100.0% 🟢)4.442s (-11.9% 🟢)1.641s141.03x
▲ VercelNext.js (Turbopack)2.963s (-19.9% 🟢)4.093s (-17.7% 🟢)0.000s (-100.0% 🟢)4.499s (-17.8% 🟢)1.536s141.09x

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

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

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.560s (+2.3%)2.216s (~)0.000s (-50.0% 🟢)2.245s (~)0.685s271.00x
🐘 PostgresNitro1.701s (+27.5% 🔺)2.217s (+9.4% 🔺)0.000s (+Infinity% 🔺)2.231s (+8.1% 🔺)0.530s271.09x
🐘 PostgresNext.js (Turbopack)2.076s (+20.0% 🔺)2.503s (+10.7% 🔺)0.000s (+Infinity% 🔺)2.510s (+10.5% 🔺)0.434s241.33x
💻 LocalNext.js (Turbopack)2.946s (+3.3%)3.614s (+5.8% 🔺)0.001s (+111.8% 🔺)3.617s (+5.8% 🔺)0.671s171.89x
💻 LocalNitro3.128s (-1.2%)3.779s (-1.5%)0.001s (+400.0% 🔺)3.781s (-1.6%)0.653s162.01x
💻 LocalExpress3.189s (+3.0%)3.841s (+4.6%)0.001s (+6.2% 🔺)3.844s (+4.5%)0.654s162.04x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro4.214s (-42.9% 🟢)5.507s (-30.6% 🟢)0.000s (+Infinity% 🔺)5.899s (-34.7% 🟢)1.685s111.00x
▲ VercelNext.js (Turbopack)4.747s (-46.5% 🟢)5.769s (-51.6% 🟢)0.000s (-95.0% 🟢)6.151s (-50.8% 🟢)1.404s101.13x
▲ VercelExpress4.778s (-10.2% 🟢)6.123s (-6.9% 🟢)0.000s (NaN%)6.547s (-9.0% 🟢)1.769s101.13x

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

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalExpress15/21
🐘 PostgresNitro11/21
▲ VercelExpress10/21
Fastest World by Framework

Winner determined by most benchmark wins

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

Comment threaddocs/content/docs/v5/foundations/hooks.mdx Outdated

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR replaces the hook conflict detection API from hook.hasConflict: Promise<boolean> to hook.getConflict: Promise<Run | null>, so a duplicate run can learn which run currently owns a conflicting hook token (and optionally act on it via the returned Run handle). It also updates the runtime, tests, e2e workflows, and v4/v5 docs to reflect the new API.

Changes:

  • Replace hasConflict plumbing with getConflict, including resolving to a conflicting Run handle and reusing the same instance across awaits.
  • Expose the workflow-bundle Run class via a new WORKFLOW_RUN_CLASS symbol so the hook consumer can construct durable Run handles inside workflow code.
  • Update workbench workflows, unit/e2e tests, docs (v4 + v5), and changesets for the renamed API.

Reviewed changes

Copilot reviewed 23 out of 23 changed files in this pull request and generated 5 comments.

Show a summary per file
FileDescription
workbench/nextjs-turbopack/app/workflows/definitions.tsRenames e2e workflow entries from hookHasConflict* to hookGetConflict*.
workbench/example/workflows/99_e2e.tsUpdates example workflows to use hook.getConflict and return conflict run metadata.
packages/core/src/workflow/hook.tsImplements getConflict resolution, constructs conflicting Run via WORKFLOW_RUN_CLASS.
packages/core/src/workflow/hook.test.tsUpdates unit tests to assert getConflict semantics and stable instance identity.
packages/core/src/workflow/create-hook.tsRegisters workflow-bundle Run class on globalThis[WORKFLOW_RUN_CLASS].
packages/core/src/workflow.test.tsUpdates workflow runner tests for suspension + getConflict behavior.
packages/core/src/symbols.tsAdds WORKFLOW_RUN_CLASS symbol definition and rationale.
packages/core/src/runtime/suspension-handler.tsUpdates comment/semantics references from hasConflict to getConflict.
packages/core/src/runtime/suspension-handler.test.tsUpdates tests and descriptions to the new getConflict naming.
packages/core/src/runtime.tsUpdates inline-execution gating comments to reference hook.getConflict.
packages/core/src/global.tsUpdates hook invocation queue item comment to reflect getConflict.
packages/core/src/create-hook.tsPublic Hook interface now exposes getConflict and updated docs/examples.
packages/core/e2e/e2e.test.tsRenames e2e tests and asserts conflict returns owner runId + durable status.
docs/content/docs/v5/how-it-works/event-sourcing.mdxUpdates hook conflict behavior description to reference getConflict.
docs/content/docs/v5/foundations/hooks.mdxUpdates conflict-checking guidance and examples to use getConflict.
docs/content/docs/v5/api-reference/workflow/create-webhook.mdxUpdates webhook return shape docs to getConflict.
docs/content/docs/v5/api-reference/workflow/create-hook.mdxUpdates API reference + examples from hasConflict to getConflict.
docs/content/docs/v4/how-it-works/event-sourcing.mdxUpdates v4 event-sourcing docs to reference getConflict.
docs/content/docs/v4/foundations/hooks.mdxUpdates v4 conflict-checking docs to getConflict ({ runId } guidance).
docs/content/docs/v4/api-reference/workflow/create-webhook.mdxUpdates v4 webhook docs to getConflict returning { runId }/null.
docs/content/docs/v4/api-reference/workflow/create-hook.mdxUpdates v4 hook API reference to getConflict returning { runId }/null.
.changeset/hook-has-conflict.mdRemoves changeset for the superseded hasConflict API.
.changeset/hook-get-conflict.mdAdds changeset for getConflict.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

// executes inside the VM, so `Run` here is the plugin-compiled variant
// whose methods are durable step proxies. The host-side consumer uses it
// to construct the conflicting run resolved by `hook.getConflict`.
(globalThis as any)[WORKFLOW_RUN_CLASS] ??= Run;

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in 919bde5: the registration is now guarded on the workflow runtime being present (WORKFLOW_CREATE_HOOK is installed on the VM globalThis before the bundle is evaluated), so importing this module outside the VM neither mutates the host global nor exposes the host-side Run (whose methods are not step proxies). Verified via the hookGetConflict e2e suite against a real bundle.

Comment threadpackages/core/src/create-hook.ts Outdated
Comment on lines +34 to +36
* Resolves with the conflicting {@link Run} if another active hook
* already owns this hook's token, or `null` once the hook has been
* registered and is ready to receive payloads.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Already addressed in dec869d (this review appears to have run against the initial commit): createConflictingRun() no longer falls back to a { runId }-shaped object. When a real Run cannot be constructed — including hook_conflict events without conflictingRunIdgetConflict() rejects with HookConflictError instead, and the JSDoc documents exactly that.

Comment threadpackages/core/src/create-hook.ts Outdated
* ```
*/
readonly hasConflict: Promise<boolean>;
readonly getConflict: Promise<Run<unknown> | null>;

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Already addressed in dec869d: the runtime now strictly honors Promise<Run<unknown> | null>. The { runId } fallback was removed — degenerate cases (legacy events without conflictingRunId, contexts without the bundle Run class) reject with HookConflictError rather than resolving with a value that lacks Run accessors, so the public type no longer needs widening.

```

Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.hasConflict` suspends the workflow to commit the hook registration, then resolves with `false` once the hook is registered and ready to receive payloads, or `true` if another active hook already owns the same token (see [`HookConflictError`](/docs/errors/hook-conflict)).
Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.getConflict` suspends the workflow to commit the hook registration, then resolves with `null` once the hook is registered and ready to receive payloads, or with a `Run` handle for the run that owns the token if another active hook already claimed it (see [`HookConflictError`](/docs/errors/hook-conflict)). The conflicting run's accessors are durable steps, so the workflow can inspect `await conflict.status`, wait on `await conflict.returnValue`, or cancel the owner with `await conflict.cancel()` — see [Run idempotency](/docs/foundations/idempotency#run-idempotency) for these strategies.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Clarified in 919bde5: the hooks docs (v4 + v5) now state that for hook_conflict events persisted by older worlds without the owning run ID, getConflict() rejects with HookConflictError instead of resolving with an incomplete handle — so when it does resolve with a conflict, the durable accessors are always available.

Comment thread.changeset/hook-get-conflict.md Outdated
"workflow": minor
---

Add `hook.getConflict`, a promise that suspends the workflow to commit hook registration and resolves with the conflicting `Run` when another active hook owns the token (or `null` once the hook is registered), without waiting for hook payload data.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

that doesn't matter because we didn't ship a new release with the previousl documented API so it's a fast follow and change

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Updated in 919bde5: the changeset now explicitly calls out that this replaces hook.hasConflict and includes the migration (await hook.hasConflictconst conflict = await hook.getConflict(), branch on conflict !== null).

getConflict's contract is Promise<Run | null>. In the degenerate cases
where a real Run cannot be constructed — a hook_conflict event persisted
by an old world without conflictingRunId, or a context that never loaded
the workflow-mode create-hook module — reject with HookConflictError
instead of resolving with a { runId }-shaped impostor.
Test harnesses now register the Run class on the (VM) globalThis like
real bundles do.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Request Changes — the feature is right, but the WORKFLOW_RUN_CLASS global duplicates infrastructure the serialization pipeline already provides

First: the API direction is a clear improvement. A Run handle beats the boolean (hasConflict told you that, not who), code-driven conflict strategy beats a fixed policy enum, and the dec869d9e decision to reject with HookConflictError rather than resolve a { runId }-shaped impostor keeps the Run contract honest. The determinism story checks out too: both fast-paths settle through promiseQueue, conflictRunRef is shared so repeated awaits observe one instance, replay reconstructs the handle from the same event-log conflictingRunId, and the bundle-compiled Run's accessors are step proxies (I verified the compiled output — Object.defineProperty(Run.prototype, ...) with __step_Run$... proxies), so await conflict.status / conflict.cancel() from workflow code are durable. 1156 unit tests pass locally; the red express E2E lane is failing errorWorkflowNested/errorWorkflowCrossFile (not hook tests) — baseline flake.

The change I'm requesting: use the class registry instead of a bespoke global

The (globalThis as any)[WORKFLOW_RUN_CLASS] ??= Run registration in workflow-mode create-hook.ts re-implements, ad hoc, something the serialization pipeline already does systematically. I built workbench/example from this branch and inspected the compiled flow bundle:

(function(__wf_cls,__wf_id){var__wf_sym=Symbol.for("workflow-class-registry"), ...
})(Run,"class//./packages/core/dist/runtime/run//Run");

The SWC plugin already registers the workflow-compiled Run in globalThis[WORKFLOW_CLASS_REGISTRY] at bundle evaluation — because Run carries WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE. This is the exact channel a start()-returned Run already uses to cross from host to VM today: the Instance reducer emits { classId, data: { runId, resilientStart } }, and getClassRevivers(global).Instance looks the class up via getSerializationClass(classId, global) against the VM's global and calls its WORKFLOW_DESERIALIZE.

So the class this PR smuggles through a new symbol is already sitting in the VM's registry when the hook consumer runs. The only missing piece is the lookup key — the plugin derives it from the module path (class//./packages/core/dist/runtime/run//Run in the workbench; different prefix under npm-installed layouts / other bundlers), so it can't be hardcoded.

Proposal (~15 lines total):

  1. Register Run under a stable, well-known classId alongside the plugin's path-derived one. registerSerializationClass can't be called twice (its non-configurable defineProperty(cls, 'classId') throws once the plugin IIFE has run), so add a tiny alias helper to class-serialization.ts:
/** Register an additional stable id for an already-registered class * (skips the classId defineProperty). */exportfunctionaliasSerializationClass(classId: string,cls: Function){getRegistry().set(classId,cls);}
  1. Call it at module scope of runtime/run.ts (e.g. aliasSerializationClass('class//workflow//Run', Run)). Note what this buys over the symbol approach: per-context discipline for free. Evaluated inside the VM, it registers the VM's step-proxy Run on the VM's registry; evaluated on the host, it registers the host Run on the host's registry. Each global gets its own correct class — which means the if (globalThis[WORKFLOW_CREATE_HOOK]) guard and the ??= (both of which exist purely to keep the host Run from leaking into the wrong global) simply disappear, along with the module-scope conditional in create-hook.ts and the new symbol in symbols.ts.

  2. The consumer constructs through the pipeline's own hook instead of new RunClass(id):

functioncreateConflictingRun(ctx,conflictingRunId){if(typeofconflictingRunId!=='string')returnnull;constRunClass=getSerializationClass(RUN_STABLE_CLASS_ID,ctx.globalThis);if(!RunClass)returnnull;return(RunClassasany)[WORKFLOW_DESERIALIZE]({runId: conflictingRunId});}

Same degenerate-case semantics you already have (registry miss → null → awaiters reject with HookConflictError), but the construction takes the identical code path a serialized Run instance takes everywhere else. class-serialization.ts is deliberately dependency-light ("separate from private.ts to avoid pulling in Node.js-only dependencies"), so importing it from workflow/hook.ts is safe.

Side benefit worth banking: a stable classId hardens Run serialization generally. Today a Run persisted in the event log (e.g. a start()-in-workflow handle in a step result) embeds the path-derived id, which is only safe because version-skew protection pins replay to the same deployment whose bundles agree on the path. A stable alias removes that coupling — any future cross-deployment replay (or a bundler change mid-release) stops being able to break Run hydration with "Class not found".

I considered the fuller "interact with the serialization pipeline" variant — making getConflict() resolution a step so the standard step-return serde does everything — and I think your current design is better: piggybacking on hook_created/hook_conflict costs zero extra events and zero queue hops, where a step-based resolution would add one of each per call. The registry lookup keeps that property while deleting the bespoke channel.

Smaller notes

  • The v4 docs describe a { runId } return that no released 4.x SDK has (and getConflict itself isn't on stable). Same sequencing flag as #2015/#2011 — the v4 pages keep widening the documented-but-unreleased surface; fine if the backport chain is imminent, worth holding otherwise.
  • e56e6fbc25b3b88b0b's getter→method change (hook.getConflict()) is the right call — a property that allocates and enqueues new promise resolvers on every access was the same trap hasConflict had; a method at least signals effects.
  • Changeset correctly replaces the hasConflict one and is scoped to @workflow/core + workflow (minor) — and since hasConflict shipped in no stable release and at most one beta, replacing rather than deprecating is fine.

Happy to re-review quickly once the registry switch is in — everything else here is ready.

… registry
Replace the bespoke WORKFLOW_RUN_CLASS global with the registry the
serialization pipeline already uses to revive Run instances:
- The SWC plugin already auto-registers the workflow bundle's compiled
Run in globalThis[workflow-class-registry], but under a path-derived
classId the host cannot know statically. The workflow-mode create-hook
module now aliases it under a stable id (class//workflow//Run) via a
new aliasSerializationClass() helper (a plain registry entry —
registerSerializationClass cannot be reused since the plugin's IIFE
already defined the non-configurable classId property).
- createConflictingRun() looks the class up with
getSerializationClass(RUN_CLASS_ID, ctx.globalThis) and constructs
through its WORKFLOW_DESERIALIZE hook, exactly as the Instance reviver
would for a serialized Run crossing from a step into the workflow.
- Because the registry is keyed per-global, no environment guard is
needed: a stray host-side import registers the host Run on the host
registry, which is the correct class for that context. The
WORKFLOW_CREATE_HOOK guard, the ??=, and the WORKFLOW_RUN_CLASS symbol
are all deleted.
Verified: 1156 core unit tests; compiled workbench bundle contains the
stable alias alongside the plugin's path-derived registration with zero
WORKFLOW_RUN_CLASS references; all 5 hookGetConflict e2e tests pass
against a local nextjs-turbopack dev server, including conflict
resolution reading conflict.status through a durable step.

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approve — the registry switch from my previous review is now implemented (0aa3cb0)

I implemented the change I requested (after discussing with Nathan), so this approval covers Pranay's feature plus that refactor — Pranay, please give 0aa3cb0 a once-over since it rewrites the class-handoff mechanism you authored.

What changed:

  • WORKFLOW_RUN_CLASS is gone (symbol, the ??= registration, and the WORKFLOW_CREATE_HOOK guard). The workflow-mode create-hook module now aliases the bundle's Run into the existing serialization class registry under a stable id (class//workflow//Run) via a new aliasSerializationClass() helper — a plain registry entry, since registerSerializationClass's non-configurable defineProperty(classId) would throw on a class the SWC plugin already registered.
  • createConflictingRun resolves through the pipeline: getSerializationClass(RUN_CLASS_ID, ctx.globalThis) + construction via the class's WORKFLOW_DESERIALIZE hook — the identical path the Instance reviver takes for a serialized Run crossing from a step into the workflow. Degenerate-case semantics unchanged: registry miss or missing deserialize hook → null → awaiters reject with HookConflictError.
  • No environment guard needed: the registry is per-global by construction, so a stray host-side import of the module registers the host Run on the host registry — correct for that context.

Verification:

  • 1156 core unit tests pass; hook.test.ts / workflow.test.ts mocks updated to register through the registry (the VM-string test now mirrors the real bundle shape, including the WORKFLOW_DESERIALIZE hook)
  • Rebuilt workbench/example: compiled flow bundle contains the stable alias alongside the plugin's path-derived registration, with zero WORKFLOW_RUN_CLASS references
  • All 5 hookGetConflict* e2e tests pass against a local nextjs-turbopack dev server, including the conflict test that resolves the actual owner and reads await conflict.status through a durable step — the full host → VM-registry → WORKFLOW_DESERIALIZE → step-proxy chain
  • The two biome complexity findings in hook.ts are pre-existing on this branch (identical count before/after)

Remaining notes from my previous review stand but don't block: the v4 docs describe a surface not yet on stable (recurring sequencing flag), and the stable classId incidentally hardens Run event-log serialization against future cross-deployment replay.

@github-actions

Copy link
Copy Markdown
Contributor

Backport PR opened against stable: #2382. Merge conflicts were resolved by AI — please review carefully. (backport job run)

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

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

Replace hook.hasConflict with hook.getConflict() returning the conflicting Run - #2373

Merged
TooTallNate merged 5 commits into
mainfrom
pranaygp/hook-get-conflict
Jun 12, 2026
Merged

Replace hook.hasConflict with hook.getConflict() returning the conflicting Run#2373
TooTallNate merged 5 commits into
mainfrom
pranaygp/hook-get-conflict

Conversation

@pranaygp

@pranaygppranaygp commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #2015. hook.hasConflict's boolean told the workflow that a conflict exists but not which run owns the token, so the duplicate run couldn't act on it. This replaces it with hook.getConflict(), a promise that resolves with:

  • null once the hook registration is committed (hook_created recorded), or
  • a Run handle for the conflicting run when another active hook owns the token.

Like hasConflict, awaiting getConflict() suspends the workflow to commit the hook registration without waiting for payload data. But on conflict, the workflow now has the active owner in hand and can choose its own idempotency strategy in code:

using hook=createHook({token: `order:${orderId}`});constconflict=awaithook.getConflict();if(conflict){// any of:return{dedupedTo: conflict.runId};// route caller to the ownerconststatus=awaitconflict.status;// inspect before decidingreturnawaitconflict.returnValue;// adopt the owner's resultawaitconflict.cancel();// supersede the owner and continue}

This is deliberately code-driven rather than a fixed set of ID-reuse policies (cf. Temporal's WorkflowIdReusePolicy / WorkflowIdConflictPolicy).

Implementation

  • The workflow-mode create-hook module exposes the bundle's compiled Run class on a well-known global symbol (WORKFLOW_RUN_CLASS). The bundle's Run is the SWC-plugin-compiled variant whose accessors (status, returnValue, cancel(), …) are durable step proxies — safe and deterministic inside workflow code. The host-side hook event consumer constructs the conflicting Run from the hook_conflict event's conflictingRunId (Expose conflicting run id on hook conflicts #2012) using that class.
  • getConflict never resolves with anything but a real Run or null. In the degenerate cases where a Run cannot be constructed — a hook_conflict event persisted by an old world without conflictingRunId, or a context that never loaded the workflow-mode create-hook module — it rejects with HookConflictError (which still carries the token) instead of resolving with a { runId }-shaped impostor. Note: the { runId } shape remains the documented v4 return value, since v4 has no native Run serialization — v4 docs direct users to getRun(conflict.runId) inside a step.
  • Repeated await hook.getConflict() calls observe the same Run instance; both fast-paths settle through promiseQueue to keep resolution order aligned with the event log.
  • Awaiting the hook payload on a conflicted token still rejects with HookConflictError.

Docs

Updated createHook() / createWebhook() references, hooks foundations, and event-sourcing pages (v4 + v5; v5 documents the Run return, v4 documents { runId }). A follow-up to #2011 adds the full conflict-handling strategy guide to the run-idempotency docs.

Validation

  • cd packages/core && pnpm typecheck && pnpm vitest run src/ (1155 tests)
  • pnpm test:docs
  • Hook e2e suite against local nextjs-turbopack: all hookGetConflict* tests pass, including the conflict test asserting the duplicate run resolves the actual owner's runId and reads await conflict.status === 'running' via a durable step.

…ull>)
hasConflict's boolean didn't expose WHICH run owns the token, so the
duplicate run couldn't act on the conflict. getConflict resolves with
null once registration commits, or with a Run handle for the conflicting
run — letting the workflow return/log the owner's runId, inspect its
status, await its result, or cancel it and continue, all in code.
The workflow-mode create-hook module exposes the bundle's compiled Run
class (durable step-proxy methods) on a well-known symbol so the host-
side hook consumer can construct the conflicting run inside the VM.
Contexts without the class (plain unit tests) fall back to a { runId }
object, which is also the documented v4 shape (no native Run
serialization in v4).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings June 11, 2026 23:11
@pranaygp
pranaygp requested a review from a team as a code ownerJune 11, 2026 23:11
@changeset-bot

changeset-botBot commented Jun 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0aa3cb0

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

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

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production137602191595
✅ 💻 Local Development181102192030
✅ 📦 Local Production181102192030
✅ 🐘 Local Postgres179702332030
✅ 🪟 Windows14500145
✅ 📋 Other83701781015
Total7777010688845

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro119026
✅ example119026
✅ express119026
✅ fastify119026
✅ hono119026
✅ nextjs-turbopack14302
✅ nextjs-webpack14302
✅ nitro119026
✅ nuxt119026
✅ sveltekit13807
✅ vite119026
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable120025
✅ express-stable120025
✅ fastify-stable120025
✅ hono-stable120025
✅ nextjs-turbopack-canary126019
✅ nextjs-turbopack-stable-lazy-discovery-disabled14500
✅ nextjs-turbopack-stable-lazy-discovery-enabled14500
✅ nextjs-webpack-canary126019
✅ nextjs-webpack-stable-lazy-discovery-disabled14500
✅ nextjs-webpack-stable-lazy-discovery-enabled14500
✅ nitro-stable120025
✅ nuxt-stable120025
✅ sveltekit-stable13906
✅ vite-stable120025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable120025
✅ express-stable120025
✅ fastify-stable120025
✅ hono-stable120025
✅ nextjs-turbopack-canary126019
✅ nextjs-turbopack-stable-lazy-discovery-disabled14500
✅ nextjs-turbopack-stable-lazy-discovery-enabled14500
✅ nextjs-webpack-canary126019
✅ nextjs-webpack-stable-lazy-discovery-disabled14500
✅ nextjs-webpack-stable-lazy-discovery-enabled14500
✅ nitro-stable120025
✅ nuxt-stable120025
✅ sveltekit-stable13906
✅ vite-stable120025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable119026
✅ express-stable119026
✅ fastify-stable119026
✅ hono-stable119026
✅ nextjs-turbopack-canary125020
✅ nextjs-turbopack-stable-lazy-discovery-disabled14401
✅ nextjs-turbopack-stable-lazy-discovery-enabled14401
✅ nextjs-webpack-canary125020
✅ nextjs-webpack-stable-lazy-discovery-disabled14401
✅ nextjs-webpack-stable-lazy-discovery-enabled14401
✅ nitro-stable119026
✅ nuxt-stable119026
✅ sveltekit-stable13807
✅ vite-stable119026
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack14500
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable120025
✅ e2e-local-dev-tanstack-start-120025
✅ e2e-local-postgres-nest-stable119026
✅ e2e-local-postgres-tanstack-start-119026
✅ e2e-local-prod-nest-stable120025
✅ e2e-local-prod-tanstack-start-120025
✅ e2e-vercel-prod-tanstack-start119026

📋 View full workflow run

@github-actions

github-actionsBot commented Jun 11, 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.042s (-1.9%)1.006s (~)0.965s101.00x
💻 LocalNitro0.046s (+7.8% 🔺)1.006s (~)0.960s101.10x
🐘 PostgresNitro0.065s (-1.5%)1.013s (~)0.948s101.56x
💻 LocalNext.js (Turbopack)0.066s (+5.2% 🔺)1.006s (~)0.940s101.59x
🐘 PostgresNext.js (Turbopack)0.072s (-4.3%)1.012s (~)0.941s101.72x
🐘 PostgresExpress0.080s (+30.0% 🔺)1.032s (+2.0%)0.952s101.92x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.244s (-35.3% 🟢)2.122s (-12.6% 🟢)1.878s101.00x
▲ VercelNitro0.260s (-31.1% 🟢)2.099s (~)1.839s101.07x
▲ VercelExpress0.268s (-1.1%)2.117s (-18.0% 🟢)1.850s101.10x

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

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.089s (-2.4%)2.011s (~)0.922s101.00x
💻 LocalExpress1.096s (~)2.007s (~)0.911s101.01x
💻 LocalNitro1.101s (+0.9%)2.006s (~)0.905s101.01x
🐘 PostgresNitro1.108s (~)2.009s (~)0.900s101.02x
💻 LocalNext.js (Turbopack)1.139s (+1.0%)2.007s (~)0.867s101.05x
🐘 PostgresNext.js (Turbopack)1.185s (+3.7%)2.011s (~)0.826s101.09x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express1.616s (-7.8% 🟢)3.302s (-11.0% 🟢)1.686s101.00x
▲ VercelNitro1.696s (+1.2%)3.485s (+1.0%)1.790s101.05x
▲ VercelNext.js (Turbopack)1.769s (+5.4% 🔺)3.307s (-9.1% 🟢)1.539s101.09x

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

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express10.496s (-0.9%)11.031s (~)0.535s31.00x
💻 LocalExpress10.500s (~)11.021s (~)0.521s31.00x
💻 LocalNitro10.535s (~)11.023s (~)0.488s31.00x
🐘 PostgresNitro10.568s (~)11.017s (~)0.450s31.01x
💻 LocalNext.js (Turbopack)10.794s (~)11.023s (~)0.229s31.03x
🐘 PostgresNext.js (Turbopack)10.842s (-1.6%)11.017s (-5.7% 🟢)0.175s31.03x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro13.610s (+2.0%)15.211s (~)1.601s21.00x
▲ VercelExpress14.017s (+4.5%)15.119s (-3.9%)1.102s21.03x
▲ VercelNext.js (Turbopack)14.111s (~)15.512s (-4.1%)1.401s21.04x

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

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express13.731s (-0.8%)14.028s (~)0.297s51.00x
💻 LocalNitro13.787s (~)14.027s (-1.4%)0.240s51.00x
🐘 PostgresExpress13.956s (+0.9%)14.418s (+2.8%)0.462s51.02x
🐘 PostgresNitro13.986s (+1.2%)14.220s (+1.4%)0.234s51.02x
💻 LocalNext.js (Turbopack)14.348s (-1.0%)15.030s (~)0.681s41.05x
🐘 PostgresNext.js (Turbopack)14.458s (~)15.020s (~)0.562s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro20.475s (-8.7% 🟢)22.157s (-8.0% 🟢)1.681s31.00x
▲ VercelExpress20.531s (-4.4%)21.964s (-4.8%)1.434s31.00x
▲ VercelNext.js (Turbopack)21.986s (+1.8%)23.788s (+1.9%)1.801s31.07x

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

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express12.391s (-0.9%)13.024s (~)0.633s71.00x
🐘 PostgresExpress12.417s (-2.6%)13.017s (~)0.600s71.00x
💻 LocalNitro12.529s (~)13.024s (~)0.495s71.01x
🐘 PostgresNitro12.730s (+1.2%)13.162s (+1.1%)0.433s71.03x
💻 LocalNext.js (Turbopack)13.654s (~)14.026s (~)0.372s71.10x
🐘 PostgresNext.js (Turbopack)14.084s (+0.8%)14.450s (+1.0%)0.366s71.14x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro29.382s (-7.3% 🟢)31.701s (-4.8%)2.319s31.00x
▲ VercelExpress29.422s (-0.8%)31.369s (+0.7%)1.947s31.00x
▲ VercelNext.js (Turbopack)30.219s (+5.4% 🔺)32.193s (+6.0% 🔺)1.973s31.03x

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

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.204s (~)2.015s (~)0.811s151.00x
🐘 PostgresNitro1.217s (+1.1%)2.008s (~)0.791s151.01x
💻 LocalExpress1.228s (+3.2%)2.006s (~)0.778s151.02x
💻 LocalNitro1.250s (+2.2%)2.006s (~)0.756s151.04x
💻 LocalNext.js (Turbopack)1.275s (-5.4% 🟢)2.006s (~)0.731s151.06x
🐘 PostgresNext.js (Turbopack)1.294s (+2.8%)2.008s (~)0.714s151.07x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.316s (-37.4% 🟢)3.726s (-28.1% 🟢)1.410s91.00x
▲ VercelExpress2.794s (+9.5% 🔺)4.184s (-7.0% 🟢)1.391s81.21x
▲ VercelNext.js (Turbopack)3.004s (-1.3%)4.488s (-2.3%)1.484s71.30x

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

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.367s (+8.6% 🔺)2.508s (+25.0% 🔺)1.141s121.00x
🐘 PostgresExpress1.422s (+12.6% 🔺)2.172s (+8.2% 🔺)0.750s141.04x
💻 LocalExpress1.755s (-4.7%)2.006s (-6.7% 🟢)0.251s151.28x
💻 LocalNext.js (Turbopack)1.834s (-2.4%)2.008s (-6.6% 🟢)0.174s151.34x
💻 LocalNitro1.853s (+8.9% 🔺)2.151s (+7.2% 🔺)0.298s141.36x
🐘 PostgresNext.js (Turbopack)1.855s (+32.4% 🔺)2.396s (+19.4% 🔺)0.541s131.36x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.168s (-28.3% 🟢)4.565s (-25.4% 🟢)1.397s81.00x
▲ VercelNext.js (Turbopack)3.520s (-98.9% 🟢)5.051s (-98.4% 🟢)1.531s61.11x
▲ VercelNitro4.477s (-28.2% 🟢)6.029s (-22.2% 🟢)1.552s51.41x

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

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.578s (+12.1% 🔺)3.762s (+87.3% 🔺)2.184s81.00x
🐘 PostgresExpress1.758s (+25.4% 🔺)3.577s (+78.1% 🔺)1.819s91.11x
🐘 PostgresNext.js (Turbopack)3.622s (+100.8% 🔺)4.583s (+99.5% 🔺)0.962s72.30x
💻 LocalExpress4.738s (-12.0% 🟢)5.514s (-10.8% 🟢)0.775s63.00x
💻 LocalNitro5.252s (~)5.846s (-2.8%)0.595s63.33x
💻 LocalNext.js (Turbopack)5.763s (+1.0%)6.216s (~)0.453s53.65x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro4.006s (-76.4% 🟢)5.907s (-67.7% 🟢)1.902s61.00x
▲ VercelExpress4.380s (-52.0% 🟢)6.113s (-46.2% 🟢)1.733s61.09x
▲ VercelNext.js (Turbopack)6.477s (+7.7% 🔺)7.965s (-5.6% 🟢)1.488s41.62x

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

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.212s (+1.8%)2.007s (~)0.794s151.00x
🐘 PostgresExpress1.254s (+3.5%)2.027s (+1.0%)0.773s151.03x
🐘 PostgresNext.js (Turbopack)1.295s (+2.5%)2.007s (~)0.712s151.07x
💻 LocalNext.js (Turbopack)1.377s (+1.9%)2.006s (~)0.629s151.14x
💻 LocalExpress1.560s (-5.9% 🟢)2.006s (-3.3%)0.446s151.29x
💻 LocalNitro1.603s (+6.3% 🔺)2.007s (~)0.404s151.32x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.267s (-12.1% 🟢)3.589s (-15.3% 🟢)1.323s91.00x
▲ VercelExpress2.338s (-13.4% 🟢)3.777s (-19.9% 🟢)1.439s81.03x
▲ VercelNext.js (Turbopack)2.688s (-34.5% 🟢)4.300s (-23.7% 🟢)1.613s71.19x

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

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.374s (+6.0% 🔺)2.149s (+6.9% 🔺)0.775s151.00x
🐘 PostgresNitro1.378s (+8.4% 🔺)2.223s (+10.7% 🔺)0.845s141.00x
🐘 PostgresNext.js (Turbopack)1.535s (+9.3% 🔺)2.296s (+14.3% 🔺)0.761s141.12x
💻 LocalNext.js (Turbopack)2.019s (-1.3%)2.591s (-13.9% 🟢)0.572s121.47x
💻 LocalNitro2.118s (-2.4%)2.675s (+3.2%)0.557s121.54x
💻 LocalExpress2.183s (+1.1%)2.735s (+2.2%)0.552s111.59x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.708s (-21.0% 🟢)4.034s (-20.9% 🟢)1.327s81.00x
▲ VercelNext.js (Turbopack)2.904s (-20.4% 🟢)4.585s (-12.1% 🟢)1.681s71.07x
▲ VercelNitro3.259s (-32.5% 🟢)4.713s (-26.4% 🟢)1.455s71.20x

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

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.633s (+17.6% 🔺)4.015s (+99.9% 🔺)2.383s81.00x
🐘 PostgresExpress1.778s (+26.9% 🔺)3.764s (+87.2% 🔺)1.985s81.09x
🐘 PostgresNext.js (Turbopack)3.795s (+109.9% 🔺)4.588s (+106.3% 🔺)0.793s72.32x
💻 LocalExpress4.915s (-22.7% 🟢)5.515s (-21.4% 🟢)0.600s63.01x
💻 LocalNext.js (Turbopack)5.698s (-5.9% 🟢)6.217s (-8.8% 🟢)0.518s53.49x
💻 LocalNitro6.163s (+13.4% 🔺)6.417s (+6.7% 🔺)0.254s53.77x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.482s (-31.8% 🟢)5.220s (-27.9% 🟢)1.738s61.00x
▲ VercelNitro3.723s (-46.8% 🟢)5.600s (-34.9% 🟢)1.877s61.07x
▲ VercelNext.js (Turbopack)4.865s (-39.4% 🟢)6.332s (-35.3% 🟢)1.467s51.40x

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

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.594s (-3.0%)1.005s (~)0.411s601.00x
💻 LocalNitro0.599s (~)1.005s (~)0.406s601.01x
🐘 PostgresNitro0.609s (+5.1% 🔺)1.040s (+3.4%)0.432s581.03x
🐘 PostgresExpress0.671s (+11.7% 🔺)1.147s (+13.9% 🔺)0.476s531.13x
🐘 PostgresNext.js (Turbopack)0.849s (-0.6%)1.006s (-1.7%)0.157s601.43x
💻 LocalNext.js (Turbopack)0.912s (+5.7% 🔺)1.075s (+5.2% 🔺)0.163s571.54x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.786s (+16.6% 🔺)7.078s (+8.6% 🔺)1.292s91.00x
▲ VercelNext.js (Turbopack)5.799s (+11.1% 🔺)7.275s (+6.0% 🔺)1.476s91.00x
▲ VercelExpress6.123s (-39.6% 🟢)7.604s (-35.4% 🟢)1.481s81.06x

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

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.390s (-3.2%)2.029s (~)0.639s451.00x
🐘 PostgresExpress1.504s (+4.9%)2.110s (+5.0% 🔺)0.605s431.08x
💻 LocalExpress1.510s (-2.8%)2.006s (-1.1%)0.496s451.09x
💻 LocalNitro1.528s (+1.5%)2.006s (~)0.479s451.10x
🐘 PostgresNext.js (Turbopack)1.999s (-1.5%)2.308s (-13.1% 🟢)0.309s401.44x
💻 LocalNext.js (Turbopack)2.117s (+0.8%)3.008s (~)0.892s301.52x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express14.919s (+3.8%)16.513s (+2.2%)1.594s61.00x
▲ VercelNitro15.432s (+17.8% 🔺)17.056s (+17.9% 🔺)1.624s61.03x
▲ VercelNext.js (Turbopack)15.850s (+19.2% 🔺)17.815s (+18.0% 🔺)1.965s61.06x

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

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.755s (-1.5%)3.085s (-2.6%)0.330s391.00x
🐘 PostgresExpress2.768s (-1.5%)3.311s (+5.5% 🔺)0.543s371.00x
💻 LocalExpress3.336s (+1.5%)4.042s (+0.8%)0.707s301.21x
💻 LocalNitro3.340s (+1.3%)4.010s (~)0.670s301.21x
🐘 PostgresNext.js (Turbopack)3.913s (-1.8%)4.110s (-5.1% 🟢)0.197s301.42x
💻 LocalNext.js (Turbopack)4.360s (~)5.011s (~)0.650s241.58x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express27.778s (+5.9% 🔺)30.392s (+7.7% 🔺)2.614s51.00x
▲ VercelNitro27.783s (-4.6%)30.056s (-2.4%)2.274s41.00x
▲ VercelNext.js (Turbopack)29.676s (-16.9% 🟢)31.852s (-15.0% 🟢)2.176s41.07x

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

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.252s (+14.8% 🔺)1.006s (~)0.754s601.00x
🐘 PostgresExpress0.264s (+13.3% 🔺)1.009s (~)0.745s601.05x
🐘 PostgresNext.js (Turbopack)0.319s (+15.0% 🔺)1.006s (~)0.687s601.27x
💻 LocalNitro0.417s (-3.0%)1.005s (-1.7%)0.588s601.66x
💻 LocalExpress0.453s (+9.7% 🔺)1.021s (+1.6%)0.568s591.80x
💻 LocalNext.js (Turbopack)0.553s (+1.3%)1.005s (~)0.452s602.20x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.055s (-20.0% 🟢)3.438s (-16.3% 🟢)1.383s181.00x
▲ VercelNitro2.234s (-20.9% 🟢)3.836s (-11.1% 🟢)1.602s161.09x
▲ VercelNext.js (Turbopack)2.376s (-46.7% 🟢)3.893s (-37.5% 🟢)1.517s161.16x

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

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.393s (+5.1% 🔺)1.036s (+3.0%)0.643s871.00x
🐘 PostgresNitro0.421s (+17.3% 🔺)1.029s (+2.2%)0.608s881.07x
🐘 PostgresNext.js (Turbopack)0.619s (+24.1% 🔺)1.133s (+12.5% 🔺)0.513s801.57x
💻 LocalNitro2.207s (+1.2%)2.715s (~)0.508s345.61x
💻 LocalExpress2.208s (+8.4% 🔺)2.737s (+7.9% 🔺)0.529s335.61x
💻 LocalNext.js (Turbopack)2.513s (+2.6%)3.225s (+2.5%)0.712s286.39x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.890s (-46.6% 🟢)4.303s (-39.2% 🟢)1.414s211.00x
▲ VercelNitro3.175s (-65.7% 🟢)4.598s (-57.7% 🟢)1.424s211.10x
▲ VercelNext.js (Turbopack)3.728s (-51.6% 🟢)5.309s (-43.4% 🟢)1.581s181.29x

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

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.711s (+2.2%)1.190s (+18.3% 🔺)0.479s1011.00x
🐘 PostgresNitro0.808s (+18.6% 🔺)1.340s (+33.2% 🔺)0.532s901.14x
🐘 PostgresNext.js (Turbopack)3.015s (+199.9% 🔺)3.798s (+104.8% 🔺)0.782s324.24x
💻 LocalExpress9.681s (+3.9%)10.113s (+2.4%)0.431s1213.61x
💻 LocalNitro9.892s (+6.6% 🔺)10.362s (+5.8% 🔺)0.470s1213.90x
💻 LocalNext.js (Turbopack)10.774s (-1.5%)11.482s (~)0.708s1115.14x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express6.916s (-63.4% 🟢)8.385s (-59.1% 🟢)1.469s151.00x
▲ VercelNitro7.160s (-62.8% 🟢)8.614s (-61.1% 🟢)1.454s151.04x
▲ VercelNext.js (Turbopack)7.650s (-65.4% 🟢)9.225s (-62.2% 🟢)1.575s141.11x

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

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.161s (-0.6%)2.005s (~)0.011s (-10.3% 🟢)2.018s (~)0.857s101.00x
🐘 PostgresExpress1.171s (-0.7%)2.002s (~)0.001s (-25.0% 🟢)2.011s (~)0.840s101.01x
🐘 PostgresNitro1.173s (~)1.998s (~)0.001s (~)2.010s (~)0.838s101.01x
💻 LocalNitro1.188s (+1.9%)2.005s (~)0.013s (+20.2% 🔺)2.020s (~)0.832s101.02x
💻 LocalNext.js (Turbopack)1.210s (~)2.004s (~)0.013s (+1.6%)2.021s (~)0.811s101.04x
🐘 PostgresNext.js (Turbopack)1.252s (+0.5%)2.002s (~)0.001s (-7.7% 🟢)2.011s (~)0.759s101.08x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.283s (+5.3% 🔺)3.012s (-6.0% 🟢)1.285s (+41.4% 🔺)4.735s (+3.4%)2.452s101.00x
▲ VercelNext.js (Turbopack)2.429s (+8.2% 🔺)3.474s (+7.5% 🔺)1.219s (+6.8% 🔺)5.101s (+4.5%)2.672s101.06x
▲ VercelNitro2.460s (+0.5%)3.395s (+1.0%)1.287s (+35.6% 🔺)5.115s (+7.3% 🔺)2.654s101.08x

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

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.574s (~)2.011s (~)0.013s (+11.9% 🔺)2.026s (~)0.452s301.00x
🐘 PostgresNitro1.591s (-1.3%)2.004s (~)0.005s (-0.7%)2.027s (~)0.435s301.01x
💻 LocalNitro1.593s (~)2.010s (~)0.012s (-6.7% 🟢)2.024s (~)0.430s301.01x
🐘 PostgresExpress1.658s (+3.6%)2.071s (+3.2%)0.004s (-20.1% 🟢)2.099s (+3.5%)0.440s291.05x
💻 LocalNext.js (Turbopack)1.743s (~)2.011s (~)0.012s (-1.4%)2.026s (~)0.284s301.11x
🐘 PostgresNext.js (Turbopack)1.822s (+2.0%)2.043s (+1.6%)0.005s (+3.3%)2.061s (+1.6%)0.239s301.16x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.706s (-3.6%)6.557s (-6.8% 🟢)0.248s (-47.2% 🟢)7.186s (-10.0% 🟢)1.480s91.00x
▲ VercelNext.js (Turbopack)5.845s (-9.3% 🟢)6.951s (-9.8% 🟢)0.332s (-40.8% 🟢)7.707s (-13.2% 🟢)1.862s81.02x
▲ VercelExpress5.862s (-3.5%)6.751s (-7.5% 🟢)0.441s (+67.7% 🔺)7.547s (-5.6% 🟢)1.685s81.03x

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

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.809s (+14.2% 🔺)1.081s (+3.6%)0.000s (-32.1% 🟢)1.096s (+3.1%)0.287s561.00x
🐘 PostgresExpress0.948s (+34.0% 🔺)1.295s (+27.7% 🔺)0.000s (+28.3% 🔺)1.319s (+28.2% 🔺)0.371s461.17x
🐘 PostgresNext.js (Turbopack)1.035s (+20.1% 🔺)1.539s (+41.1% 🔺)0.000s (-29.5% 🟢)1.547s (+40.8% 🔺)0.512s391.28x
💻 LocalNitro1.399s (-2.9%)2.013s (~)0.000s (+60.0% 🔺)2.015s (~)0.616s301.73x
💻 LocalExpress1.415s (+4.8%)2.013s (~)0.000s (+75.0% 🔺)2.015s (~)0.600s301.75x
💻 LocalNext.js (Turbopack)1.481s (+1.0%)2.012s (~)0.000s (-13.3% 🟢)2.016s (~)0.535s301.83x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.721s (-28.4% 🟢)3.796s (-21.4% 🟢)0.000s (NaN%)4.171s (-20.1% 🟢)1.450s151.00x
▲ VercelExpress2.801s (-16.6% 🟢)4.062s (-11.2% 🟢)0.000s (-100.0% 🟢)4.442s (-11.9% 🟢)1.641s141.03x
▲ VercelNext.js (Turbopack)2.963s (-19.9% 🟢)4.093s (-17.7% 🟢)0.000s (-100.0% 🟢)4.499s (-17.8% 🟢)1.536s141.09x

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

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

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.560s (+2.3%)2.216s (~)0.000s (-50.0% 🟢)2.245s (~)0.685s271.00x
🐘 PostgresNitro1.701s (+27.5% 🔺)2.217s (+9.4% 🔺)0.000s (+Infinity% 🔺)2.231s (+8.1% 🔺)0.530s271.09x
🐘 PostgresNext.js (Turbopack)2.076s (+20.0% 🔺)2.503s (+10.7% 🔺)0.000s (+Infinity% 🔺)2.510s (+10.5% 🔺)0.434s241.33x
💻 LocalNext.js (Turbopack)2.946s (+3.3%)3.614s (+5.8% 🔺)0.001s (+111.8% 🔺)3.617s (+5.8% 🔺)0.671s171.89x
💻 LocalNitro3.128s (-1.2%)3.779s (-1.5%)0.001s (+400.0% 🔺)3.781s (-1.6%)0.653s162.01x
💻 LocalExpress3.189s (+3.0%)3.841s (+4.6%)0.001s (+6.2% 🔺)3.844s (+4.5%)0.654s162.04x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro4.214s (-42.9% 🟢)5.507s (-30.6% 🟢)0.000s (+Infinity% 🔺)5.899s (-34.7% 🟢)1.685s111.00x
▲ VercelNext.js (Turbopack)4.747s (-46.5% 🟢)5.769s (-51.6% 🟢)0.000s (-95.0% 🟢)6.151s (-50.8% 🟢)1.404s101.13x
▲ VercelExpress4.778s (-10.2% 🟢)6.123s (-6.9% 🟢)0.000s (NaN%)6.547s (-9.0% 🟢)1.769s101.13x

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

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalExpress15/21
🐘 PostgresNitro11/21
▲ VercelExpress10/21
Fastest World by Framework

Winner determined by most benchmark wins

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

Comment threaddocs/content/docs/v5/foundations/hooks.mdx Outdated

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR replaces the hook conflict detection API from hook.hasConflict: Promise<boolean> to hook.getConflict: Promise<Run | null>, so a duplicate run can learn which run currently owns a conflicting hook token (and optionally act on it via the returned Run handle). It also updates the runtime, tests, e2e workflows, and v4/v5 docs to reflect the new API.

Changes:

  • Replace hasConflict plumbing with getConflict, including resolving to a conflicting Run handle and reusing the same instance across awaits.
  • Expose the workflow-bundle Run class via a new WORKFLOW_RUN_CLASS symbol so the hook consumer can construct durable Run handles inside workflow code.
  • Update workbench workflows, unit/e2e tests, docs (v4 + v5), and changesets for the renamed API.

Reviewed changes

Copilot reviewed 23 out of 23 changed files in this pull request and generated 5 comments.

Show a summary per file
FileDescription
workbench/nextjs-turbopack/app/workflows/definitions.tsRenames e2e workflow entries from hookHasConflict* to hookGetConflict*.
workbench/example/workflows/99_e2e.tsUpdates example workflows to use hook.getConflict and return conflict run metadata.
packages/core/src/workflow/hook.tsImplements getConflict resolution, constructs conflicting Run via WORKFLOW_RUN_CLASS.
packages/core/src/workflow/hook.test.tsUpdates unit tests to assert getConflict semantics and stable instance identity.
packages/core/src/workflow/create-hook.tsRegisters workflow-bundle Run class on globalThis[WORKFLOW_RUN_CLASS].
packages/core/src/workflow.test.tsUpdates workflow runner tests for suspension + getConflict behavior.
packages/core/src/symbols.tsAdds WORKFLOW_RUN_CLASS symbol definition and rationale.
packages/core/src/runtime/suspension-handler.tsUpdates comment/semantics references from hasConflict to getConflict.
packages/core/src/runtime/suspension-handler.test.tsUpdates tests and descriptions to the new getConflict naming.
packages/core/src/runtime.tsUpdates inline-execution gating comments to reference hook.getConflict.
packages/core/src/global.tsUpdates hook invocation queue item comment to reflect getConflict.
packages/core/src/create-hook.tsPublic Hook interface now exposes getConflict and updated docs/examples.
packages/core/e2e/e2e.test.tsRenames e2e tests and asserts conflict returns owner runId + durable status.
docs/content/docs/v5/how-it-works/event-sourcing.mdxUpdates hook conflict behavior description to reference getConflict.
docs/content/docs/v5/foundations/hooks.mdxUpdates conflict-checking guidance and examples to use getConflict.
docs/content/docs/v5/api-reference/workflow/create-webhook.mdxUpdates webhook return shape docs to getConflict.
docs/content/docs/v5/api-reference/workflow/create-hook.mdxUpdates API reference + examples from hasConflict to getConflict.
docs/content/docs/v4/how-it-works/event-sourcing.mdxUpdates v4 event-sourcing docs to reference getConflict.
docs/content/docs/v4/foundations/hooks.mdxUpdates v4 conflict-checking docs to getConflict ({ runId } guidance).
docs/content/docs/v4/api-reference/workflow/create-webhook.mdxUpdates v4 webhook docs to getConflict returning { runId }/null.
docs/content/docs/v4/api-reference/workflow/create-hook.mdxUpdates v4 hook API reference to getConflict returning { runId }/null.
.changeset/hook-has-conflict.mdRemoves changeset for the superseded hasConflict API.
.changeset/hook-get-conflict.mdAdds changeset for getConflict.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

// executes inside the VM, so `Run` here is the plugin-compiled variant
// whose methods are durable step proxies. The host-side consumer uses it
// to construct the conflicting run resolved by `hook.getConflict`.
(globalThis as any)[WORKFLOW_RUN_CLASS] ??= Run;

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in 919bde5: the registration is now guarded on the workflow runtime being present (WORKFLOW_CREATE_HOOK is installed on the VM globalThis before the bundle is evaluated), so importing this module outside the VM neither mutates the host global nor exposes the host-side Run (whose methods are not step proxies). Verified via the hookGetConflict e2e suite against a real bundle.

Comment threadpackages/core/src/create-hook.ts Outdated
Comment on lines +34 to +36
* Resolves with the conflicting {@link Run} if another active hook
* already owns this hook's token, or `null` once the hook has been
* registered and is ready to receive payloads.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Already addressed in dec869d (this review appears to have run against the initial commit): createConflictingRun() no longer falls back to a { runId }-shaped object. When a real Run cannot be constructed — including hook_conflict events without conflictingRunIdgetConflict() rejects with HookConflictError instead, and the JSDoc documents exactly that.

Comment threadpackages/core/src/create-hook.ts Outdated
* ```
*/
readonly hasConflict: Promise<boolean>;
readonly getConflict: Promise<Run<unknown> | null>;

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Already addressed in dec869d: the runtime now strictly honors Promise<Run<unknown> | null>. The { runId } fallback was removed — degenerate cases (legacy events without conflictingRunId, contexts without the bundle Run class) reject with HookConflictError rather than resolving with a value that lacks Run accessors, so the public type no longer needs widening.

```

Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.hasConflict` suspends the workflow to commit the hook registration, then resolves with `false` once the hook is registered and ready to receive payloads, or `true` if another active hook already owns the same token (see [`HookConflictError`](/docs/errors/hook-conflict)).
Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.getConflict` suspends the workflow to commit the hook registration, then resolves with `null` once the hook is registered and ready to receive payloads, or with a `Run` handle for the run that owns the token if another active hook already claimed it (see [`HookConflictError`](/docs/errors/hook-conflict)). The conflicting run's accessors are durable steps, so the workflow can inspect `await conflict.status`, wait on `await conflict.returnValue`, or cancel the owner with `await conflict.cancel()` — see [Run idempotency](/docs/foundations/idempotency#run-idempotency) for these strategies.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Clarified in 919bde5: the hooks docs (v4 + v5) now state that for hook_conflict events persisted by older worlds without the owning run ID, getConflict() rejects with HookConflictError instead of resolving with an incomplete handle — so when it does resolve with a conflict, the durable accessors are always available.

Comment thread.changeset/hook-get-conflict.md Outdated
"workflow": minor
---

Add `hook.getConflict`, a promise that suspends the workflow to commit hook registration and resolves with the conflicting `Run` when another active hook owns the token (or `null` once the hook is registered), without waiting for hook payload data.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

that doesn't matter because we didn't ship a new release with the previousl documented API so it's a fast follow and change

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Updated in 919bde5: the changeset now explicitly calls out that this replaces hook.hasConflict and includes the migration (await hook.hasConflictconst conflict = await hook.getConflict(), branch on conflict !== null).

getConflict's contract is Promise<Run | null>. In the degenerate cases
where a real Run cannot be constructed — a hook_conflict event persisted
by an old world without conflictingRunId, or a context that never loaded
the workflow-mode create-hook module — reject with HookConflictError
instead of resolving with a { runId }-shaped impostor.
Test harnesses now register the Run class on the (VM) globalThis like
real bundles do.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Request Changes — the feature is right, but the WORKFLOW_RUN_CLASS global duplicates infrastructure the serialization pipeline already provides

First: the API direction is a clear improvement. A Run handle beats the boolean (hasConflict told you that, not who), code-driven conflict strategy beats a fixed policy enum, and the dec869d9e decision to reject with HookConflictError rather than resolve a { runId }-shaped impostor keeps the Run contract honest. The determinism story checks out too: both fast-paths settle through promiseQueue, conflictRunRef is shared so repeated awaits observe one instance, replay reconstructs the handle from the same event-log conflictingRunId, and the bundle-compiled Run's accessors are step proxies (I verified the compiled output — Object.defineProperty(Run.prototype, ...) with __step_Run$... proxies), so await conflict.status / conflict.cancel() from workflow code are durable. 1156 unit tests pass locally; the red express E2E lane is failing errorWorkflowNested/errorWorkflowCrossFile (not hook tests) — baseline flake.

The change I'm requesting: use the class registry instead of a bespoke global

The (globalThis as any)[WORKFLOW_RUN_CLASS] ??= Run registration in workflow-mode create-hook.ts re-implements, ad hoc, something the serialization pipeline already does systematically. I built workbench/example from this branch and inspected the compiled flow bundle:

(function(__wf_cls,__wf_id){var__wf_sym=Symbol.for("workflow-class-registry"), ...
})(Run,"class//./packages/core/dist/runtime/run//Run");

The SWC plugin already registers the workflow-compiled Run in globalThis[WORKFLOW_CLASS_REGISTRY] at bundle evaluation — because Run carries WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE. This is the exact channel a start()-returned Run already uses to cross from host to VM today: the Instance reducer emits { classId, data: { runId, resilientStart } }, and getClassRevivers(global).Instance looks the class up via getSerializationClass(classId, global) against the VM's global and calls its WORKFLOW_DESERIALIZE.

So the class this PR smuggles through a new symbol is already sitting in the VM's registry when the hook consumer runs. The only missing piece is the lookup key — the plugin derives it from the module path (class//./packages/core/dist/runtime/run//Run in the workbench; different prefix under npm-installed layouts / other bundlers), so it can't be hardcoded.

Proposal (~15 lines total):

  1. Register Run under a stable, well-known classId alongside the plugin's path-derived one. registerSerializationClass can't be called twice (its non-configurable defineProperty(cls, 'classId') throws once the plugin IIFE has run), so add a tiny alias helper to class-serialization.ts:
/** Register an additional stable id for an already-registered class * (skips the classId defineProperty). */exportfunctionaliasSerializationClass(classId: string,cls: Function){getRegistry().set(classId,cls);}
  1. Call it at module scope of runtime/run.ts (e.g. aliasSerializationClass('class//workflow//Run', Run)). Note what this buys over the symbol approach: per-context discipline for free. Evaluated inside the VM, it registers the VM's step-proxy Run on the VM's registry; evaluated on the host, it registers the host Run on the host's registry. Each global gets its own correct class — which means the if (globalThis[WORKFLOW_CREATE_HOOK]) guard and the ??= (both of which exist purely to keep the host Run from leaking into the wrong global) simply disappear, along with the module-scope conditional in create-hook.ts and the new symbol in symbols.ts.

  2. The consumer constructs through the pipeline's own hook instead of new RunClass(id):

functioncreateConflictingRun(ctx,conflictingRunId){if(typeofconflictingRunId!=='string')returnnull;constRunClass=getSerializationClass(RUN_STABLE_CLASS_ID,ctx.globalThis);if(!RunClass)returnnull;return(RunClassasany)[WORKFLOW_DESERIALIZE]({runId: conflictingRunId});}

Same degenerate-case semantics you already have (registry miss → null → awaiters reject with HookConflictError), but the construction takes the identical code path a serialized Run instance takes everywhere else. class-serialization.ts is deliberately dependency-light ("separate from private.ts to avoid pulling in Node.js-only dependencies"), so importing it from workflow/hook.ts is safe.

Side benefit worth banking: a stable classId hardens Run serialization generally. Today a Run persisted in the event log (e.g. a start()-in-workflow handle in a step result) embeds the path-derived id, which is only safe because version-skew protection pins replay to the same deployment whose bundles agree on the path. A stable alias removes that coupling — any future cross-deployment replay (or a bundler change mid-release) stops being able to break Run hydration with "Class not found".

I considered the fuller "interact with the serialization pipeline" variant — making getConflict() resolution a step so the standard step-return serde does everything — and I think your current design is better: piggybacking on hook_created/hook_conflict costs zero extra events and zero queue hops, where a step-based resolution would add one of each per call. The registry lookup keeps that property while deleting the bespoke channel.

Smaller notes

  • The v4 docs describe a { runId } return that no released 4.x SDK has (and getConflict itself isn't on stable). Same sequencing flag as #2015/#2011 — the v4 pages keep widening the documented-but-unreleased surface; fine if the backport chain is imminent, worth holding otherwise.
  • e56e6fbc25b3b88b0b's getter→method change (hook.getConflict()) is the right call — a property that allocates and enqueues new promise resolvers on every access was the same trap hasConflict had; a method at least signals effects.
  • Changeset correctly replaces the hasConflict one and is scoped to @workflow/core + workflow (minor) — and since hasConflict shipped in no stable release and at most one beta, replacing rather than deprecating is fine.

Happy to re-review quickly once the registry switch is in — everything else here is ready.

… registry
Replace the bespoke WORKFLOW_RUN_CLASS global with the registry the
serialization pipeline already uses to revive Run instances:
- The SWC plugin already auto-registers the workflow bundle's compiled
Run in globalThis[workflow-class-registry], but under a path-derived
classId the host cannot know statically. The workflow-mode create-hook
module now aliases it under a stable id (class//workflow//Run) via a
new aliasSerializationClass() helper (a plain registry entry —
registerSerializationClass cannot be reused since the plugin's IIFE
already defined the non-configurable classId property).
- createConflictingRun() looks the class up with
getSerializationClass(RUN_CLASS_ID, ctx.globalThis) and constructs
through its WORKFLOW_DESERIALIZE hook, exactly as the Instance reviver
would for a serialized Run crossing from a step into the workflow.
- Because the registry is keyed per-global, no environment guard is
needed: a stray host-side import registers the host Run on the host
registry, which is the correct class for that context. The
WORKFLOW_CREATE_HOOK guard, the ??=, and the WORKFLOW_RUN_CLASS symbol
are all deleted.
Verified: 1156 core unit tests; compiled workbench bundle contains the
stable alias alongside the plugin's path-derived registration with zero
WORKFLOW_RUN_CLASS references; all 5 hookGetConflict e2e tests pass
against a local nextjs-turbopack dev server, including conflict
resolution reading conflict.status through a durable step.

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approve — the registry switch from my previous review is now implemented (0aa3cb0)

I implemented the change I requested (after discussing with Nathan), so this approval covers Pranay's feature plus that refactor — Pranay, please give 0aa3cb0 a once-over since it rewrites the class-handoff mechanism you authored.

What changed:

  • WORKFLOW_RUN_CLASS is gone (symbol, the ??= registration, and the WORKFLOW_CREATE_HOOK guard). The workflow-mode create-hook module now aliases the bundle's Run into the existing serialization class registry under a stable id (class//workflow//Run) via a new aliasSerializationClass() helper — a plain registry entry, since registerSerializationClass's non-configurable defineProperty(classId) would throw on a class the SWC plugin already registered.
  • createConflictingRun resolves through the pipeline: getSerializationClass(RUN_CLASS_ID, ctx.globalThis) + construction via the class's WORKFLOW_DESERIALIZE hook — the identical path the Instance reviver takes for a serialized Run crossing from a step into the workflow. Degenerate-case semantics unchanged: registry miss or missing deserialize hook → null → awaiters reject with HookConflictError.
  • No environment guard needed: the registry is per-global by construction, so a stray host-side import of the module registers the host Run on the host registry — correct for that context.

Verification:

  • 1156 core unit tests pass; hook.test.ts / workflow.test.ts mocks updated to register through the registry (the VM-string test now mirrors the real bundle shape, including the WORKFLOW_DESERIALIZE hook)
  • Rebuilt workbench/example: compiled flow bundle contains the stable alias alongside the plugin's path-derived registration, with zero WORKFLOW_RUN_CLASS references
  • All 5 hookGetConflict* e2e tests pass against a local nextjs-turbopack dev server, including the conflict test that resolves the actual owner and reads await conflict.status through a durable step — the full host → VM-registry → WORKFLOW_DESERIALIZE → step-proxy chain
  • The two biome complexity findings in hook.ts are pre-existing on this branch (identical count before/after)

Remaining notes from my previous review stand but don't block: the v4 docs describe a surface not yet on stable (recurring sequencing flag), and the stable classId incidentally hardens Run event-log serialization against future cross-deployment replay.

@github-actions

Copy link
Copy Markdown
Contributor

Backport PR opened against stable: #2382. Merge conflicts were resolved by AI — please review carefully. (backport job run)

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

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

Replace hook.hasConflict with hook.getConflict() returning the conflicting Run - #2373

Merged
TooTallNate merged 5 commits into
mainfrom
pranaygp/hook-get-conflict
Jun 12, 2026
Merged

Replace hook.hasConflict with hook.getConflict() returning the conflicting Run#2373
TooTallNate merged 5 commits into
mainfrom
pranaygp/hook-get-conflict

Conversation

@pranaygp

@pranaygppranaygp commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #2015. hook.hasConflict's boolean told the workflow that a conflict exists but not which run owns the token, so the duplicate run couldn't act on it. This replaces it with hook.getConflict(), a promise that resolves with:

  • null once the hook registration is committed (hook_created recorded), or
  • a Run handle for the conflicting run when another active hook owns the token.

Like hasConflict, awaiting getConflict() suspends the workflow to commit the hook registration without waiting for payload data. But on conflict, the workflow now has the active owner in hand and can choose its own idempotency strategy in code:

using hook=createHook({token: `order:${orderId}`});constconflict=awaithook.getConflict();if(conflict){// any of:return{dedupedTo: conflict.runId};// route caller to the ownerconststatus=awaitconflict.status;// inspect before decidingreturnawaitconflict.returnValue;// adopt the owner's resultawaitconflict.cancel();// supersede the owner and continue}

This is deliberately code-driven rather than a fixed set of ID-reuse policies (cf. Temporal's WorkflowIdReusePolicy / WorkflowIdConflictPolicy).

Implementation

  • The workflow-mode create-hook module exposes the bundle's compiled Run class on a well-known global symbol (WORKFLOW_RUN_CLASS). The bundle's Run is the SWC-plugin-compiled variant whose accessors (status, returnValue, cancel(), …) are durable step proxies — safe and deterministic inside workflow code. The host-side hook event consumer constructs the conflicting Run from the hook_conflict event's conflictingRunId (Expose conflicting run id on hook conflicts #2012) using that class.
  • getConflict never resolves with anything but a real Run or null. In the degenerate cases where a Run cannot be constructed — a hook_conflict event persisted by an old world without conflictingRunId, or a context that never loaded the workflow-mode create-hook module — it rejects with HookConflictError (which still carries the token) instead of resolving with a { runId }-shaped impostor. Note: the { runId } shape remains the documented v4 return value, since v4 has no native Run serialization — v4 docs direct users to getRun(conflict.runId) inside a step.
  • Repeated await hook.getConflict() calls observe the same Run instance; both fast-paths settle through promiseQueue to keep resolution order aligned with the event log.
  • Awaiting the hook payload on a conflicted token still rejects with HookConflictError.

Docs

Updated createHook() / createWebhook() references, hooks foundations, and event-sourcing pages (v4 + v5; v5 documents the Run return, v4 documents { runId }). A follow-up to #2011 adds the full conflict-handling strategy guide to the run-idempotency docs.

Validation

  • cd packages/core && pnpm typecheck && pnpm vitest run src/ (1155 tests)
  • pnpm test:docs
  • Hook e2e suite against local nextjs-turbopack: all hookGetConflict* tests pass, including the conflict test asserting the duplicate run resolves the actual owner's runId and reads await conflict.status === 'running' via a durable step.

…ull>)
hasConflict's boolean didn't expose WHICH run owns the token, so the
duplicate run couldn't act on the conflict. getConflict resolves with
null once registration commits, or with a Run handle for the conflicting
run — letting the workflow return/log the owner's runId, inspect its
status, await its result, or cancel it and continue, all in code.
The workflow-mode create-hook module exposes the bundle's compiled Run
class (durable step-proxy methods) on a well-known symbol so the host-
side hook consumer can construct the conflicting run inside the VM.
Contexts without the class (plain unit tests) fall back to a { runId }
object, which is also the documented v4 shape (no native Run
serialization in v4).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings June 11, 2026 23:11
@pranaygp
pranaygp requested a review from a team as a code ownerJune 11, 2026 23:11
@changeset-bot

changeset-botBot commented Jun 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0aa3cb0

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

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

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production137602191595
✅ 💻 Local Development181102192030
✅ 📦 Local Production181102192030
✅ 🐘 Local Postgres179702332030
✅ 🪟 Windows14500145
✅ 📋 Other83701781015
Total7777010688845

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro119026
✅ example119026
✅ express119026
✅ fastify119026
✅ hono119026
✅ nextjs-turbopack14302
✅ nextjs-webpack14302
✅ nitro119026
✅ nuxt119026
✅ sveltekit13807
✅ vite119026
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable120025
✅ express-stable120025
✅ fastify-stable120025
✅ hono-stable120025
✅ nextjs-turbopack-canary126019
✅ nextjs-turbopack-stable-lazy-discovery-disabled14500
✅ nextjs-turbopack-stable-lazy-discovery-enabled14500
✅ nextjs-webpack-canary126019
✅ nextjs-webpack-stable-lazy-discovery-disabled14500
✅ nextjs-webpack-stable-lazy-discovery-enabled14500
✅ nitro-stable120025
✅ nuxt-stable120025
✅ sveltekit-stable13906
✅ vite-stable120025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable120025
✅ express-stable120025
✅ fastify-stable120025
✅ hono-stable120025
✅ nextjs-turbopack-canary126019
✅ nextjs-turbopack-stable-lazy-discovery-disabled14500
✅ nextjs-turbopack-stable-lazy-discovery-enabled14500
✅ nextjs-webpack-canary126019
✅ nextjs-webpack-stable-lazy-discovery-disabled14500
✅ nextjs-webpack-stable-lazy-discovery-enabled14500
✅ nitro-stable120025
✅ nuxt-stable120025
✅ sveltekit-stable13906
✅ vite-stable120025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable119026
✅ express-stable119026
✅ fastify-stable119026
✅ hono-stable119026
✅ nextjs-turbopack-canary125020
✅ nextjs-turbopack-stable-lazy-discovery-disabled14401
✅ nextjs-turbopack-stable-lazy-discovery-enabled14401
✅ nextjs-webpack-canary125020
✅ nextjs-webpack-stable-lazy-discovery-disabled14401
✅ nextjs-webpack-stable-lazy-discovery-enabled14401
✅ nitro-stable119026
✅ nuxt-stable119026
✅ sveltekit-stable13807
✅ vite-stable119026
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack14500
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable120025
✅ e2e-local-dev-tanstack-start-120025
✅ e2e-local-postgres-nest-stable119026
✅ e2e-local-postgres-tanstack-start-119026
✅ e2e-local-prod-nest-stable120025
✅ e2e-local-prod-tanstack-start-120025
✅ e2e-vercel-prod-tanstack-start119026

📋 View full workflow run

@github-actions

github-actionsBot commented Jun 11, 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.042s (-1.9%)1.006s (~)0.965s101.00x
💻 LocalNitro0.046s (+7.8% 🔺)1.006s (~)0.960s101.10x
🐘 PostgresNitro0.065s (-1.5%)1.013s (~)0.948s101.56x
💻 LocalNext.js (Turbopack)0.066s (+5.2% 🔺)1.006s (~)0.940s101.59x
🐘 PostgresNext.js (Turbopack)0.072s (-4.3%)1.012s (~)0.941s101.72x
🐘 PostgresExpress0.080s (+30.0% 🔺)1.032s (+2.0%)0.952s101.92x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.244s (-35.3% 🟢)2.122s (-12.6% 🟢)1.878s101.00x
▲ VercelNitro0.260s (-31.1% 🟢)2.099s (~)1.839s101.07x
▲ VercelExpress0.268s (-1.1%)2.117s (-18.0% 🟢)1.850s101.10x

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

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.089s (-2.4%)2.011s (~)0.922s101.00x
💻 LocalExpress1.096s (~)2.007s (~)0.911s101.01x
💻 LocalNitro1.101s (+0.9%)2.006s (~)0.905s101.01x
🐘 PostgresNitro1.108s (~)2.009s (~)0.900s101.02x
💻 LocalNext.js (Turbopack)1.139s (+1.0%)2.007s (~)0.867s101.05x
🐘 PostgresNext.js (Turbopack)1.185s (+3.7%)2.011s (~)0.826s101.09x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express1.616s (-7.8% 🟢)3.302s (-11.0% 🟢)1.686s101.00x
▲ VercelNitro1.696s (+1.2%)3.485s (+1.0%)1.790s101.05x
▲ VercelNext.js (Turbopack)1.769s (+5.4% 🔺)3.307s (-9.1% 🟢)1.539s101.09x

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

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express10.496s (-0.9%)11.031s (~)0.535s31.00x
💻 LocalExpress10.500s (~)11.021s (~)0.521s31.00x
💻 LocalNitro10.535s (~)11.023s (~)0.488s31.00x
🐘 PostgresNitro10.568s (~)11.017s (~)0.450s31.01x
💻 LocalNext.js (Turbopack)10.794s (~)11.023s (~)0.229s31.03x
🐘 PostgresNext.js (Turbopack)10.842s (-1.6%)11.017s (-5.7% 🟢)0.175s31.03x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro13.610s (+2.0%)15.211s (~)1.601s21.00x
▲ VercelExpress14.017s (+4.5%)15.119s (-3.9%)1.102s21.03x
▲ VercelNext.js (Turbopack)14.111s (~)15.512s (-4.1%)1.401s21.04x

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

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express13.731s (-0.8%)14.028s (~)0.297s51.00x
💻 LocalNitro13.787s (~)14.027s (-1.4%)0.240s51.00x
🐘 PostgresExpress13.956s (+0.9%)14.418s (+2.8%)0.462s51.02x
🐘 PostgresNitro13.986s (+1.2%)14.220s (+1.4%)0.234s51.02x
💻 LocalNext.js (Turbopack)14.348s (-1.0%)15.030s (~)0.681s41.05x
🐘 PostgresNext.js (Turbopack)14.458s (~)15.020s (~)0.562s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro20.475s (-8.7% 🟢)22.157s (-8.0% 🟢)1.681s31.00x
▲ VercelExpress20.531s (-4.4%)21.964s (-4.8%)1.434s31.00x
▲ VercelNext.js (Turbopack)21.986s (+1.8%)23.788s (+1.9%)1.801s31.07x

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

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express12.391s (-0.9%)13.024s (~)0.633s71.00x
🐘 PostgresExpress12.417s (-2.6%)13.017s (~)0.600s71.00x
💻 LocalNitro12.529s (~)13.024s (~)0.495s71.01x
🐘 PostgresNitro12.730s (+1.2%)13.162s (+1.1%)0.433s71.03x
💻 LocalNext.js (Turbopack)13.654s (~)14.026s (~)0.372s71.10x
🐘 PostgresNext.js (Turbopack)14.084s (+0.8%)14.450s (+1.0%)0.366s71.14x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro29.382s (-7.3% 🟢)31.701s (-4.8%)2.319s31.00x
▲ VercelExpress29.422s (-0.8%)31.369s (+0.7%)1.947s31.00x
▲ VercelNext.js (Turbopack)30.219s (+5.4% 🔺)32.193s (+6.0% 🔺)1.973s31.03x

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

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.204s (~)2.015s (~)0.811s151.00x
🐘 PostgresNitro1.217s (+1.1%)2.008s (~)0.791s151.01x
💻 LocalExpress1.228s (+3.2%)2.006s (~)0.778s151.02x
💻 LocalNitro1.250s (+2.2%)2.006s (~)0.756s151.04x
💻 LocalNext.js (Turbopack)1.275s (-5.4% 🟢)2.006s (~)0.731s151.06x
🐘 PostgresNext.js (Turbopack)1.294s (+2.8%)2.008s (~)0.714s151.07x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.316s (-37.4% 🟢)3.726s (-28.1% 🟢)1.410s91.00x
▲ VercelExpress2.794s (+9.5% 🔺)4.184s (-7.0% 🟢)1.391s81.21x
▲ VercelNext.js (Turbopack)3.004s (-1.3%)4.488s (-2.3%)1.484s71.30x

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

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.367s (+8.6% 🔺)2.508s (+25.0% 🔺)1.141s121.00x
🐘 PostgresExpress1.422s (+12.6% 🔺)2.172s (+8.2% 🔺)0.750s141.04x
💻 LocalExpress1.755s (-4.7%)2.006s (-6.7% 🟢)0.251s151.28x
💻 LocalNext.js (Turbopack)1.834s (-2.4%)2.008s (-6.6% 🟢)0.174s151.34x
💻 LocalNitro1.853s (+8.9% 🔺)2.151s (+7.2% 🔺)0.298s141.36x
🐘 PostgresNext.js (Turbopack)1.855s (+32.4% 🔺)2.396s (+19.4% 🔺)0.541s131.36x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.168s (-28.3% 🟢)4.565s (-25.4% 🟢)1.397s81.00x
▲ VercelNext.js (Turbopack)3.520s (-98.9% 🟢)5.051s (-98.4% 🟢)1.531s61.11x
▲ VercelNitro4.477s (-28.2% 🟢)6.029s (-22.2% 🟢)1.552s51.41x

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

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.578s (+12.1% 🔺)3.762s (+87.3% 🔺)2.184s81.00x
🐘 PostgresExpress1.758s (+25.4% 🔺)3.577s (+78.1% 🔺)1.819s91.11x
🐘 PostgresNext.js (Turbopack)3.622s (+100.8% 🔺)4.583s (+99.5% 🔺)0.962s72.30x
💻 LocalExpress4.738s (-12.0% 🟢)5.514s (-10.8% 🟢)0.775s63.00x
💻 LocalNitro5.252s (~)5.846s (-2.8%)0.595s63.33x
💻 LocalNext.js (Turbopack)5.763s (+1.0%)6.216s (~)0.453s53.65x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro4.006s (-76.4% 🟢)5.907s (-67.7% 🟢)1.902s61.00x
▲ VercelExpress4.380s (-52.0% 🟢)6.113s (-46.2% 🟢)1.733s61.09x
▲ VercelNext.js (Turbopack)6.477s (+7.7% 🔺)7.965s (-5.6% 🟢)1.488s41.62x

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

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.212s (+1.8%)2.007s (~)0.794s151.00x
🐘 PostgresExpress1.254s (+3.5%)2.027s (+1.0%)0.773s151.03x
🐘 PostgresNext.js (Turbopack)1.295s (+2.5%)2.007s (~)0.712s151.07x
💻 LocalNext.js (Turbopack)1.377s (+1.9%)2.006s (~)0.629s151.14x
💻 LocalExpress1.560s (-5.9% 🟢)2.006s (-3.3%)0.446s151.29x
💻 LocalNitro1.603s (+6.3% 🔺)2.007s (~)0.404s151.32x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.267s (-12.1% 🟢)3.589s (-15.3% 🟢)1.323s91.00x
▲ VercelExpress2.338s (-13.4% 🟢)3.777s (-19.9% 🟢)1.439s81.03x
▲ VercelNext.js (Turbopack)2.688s (-34.5% 🟢)4.300s (-23.7% 🟢)1.613s71.19x

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

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.374s (+6.0% 🔺)2.149s (+6.9% 🔺)0.775s151.00x
🐘 PostgresNitro1.378s (+8.4% 🔺)2.223s (+10.7% 🔺)0.845s141.00x
🐘 PostgresNext.js (Turbopack)1.535s (+9.3% 🔺)2.296s (+14.3% 🔺)0.761s141.12x
💻 LocalNext.js (Turbopack)2.019s (-1.3%)2.591s (-13.9% 🟢)0.572s121.47x
💻 LocalNitro2.118s (-2.4%)2.675s (+3.2%)0.557s121.54x
💻 LocalExpress2.183s (+1.1%)2.735s (+2.2%)0.552s111.59x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.708s (-21.0% 🟢)4.034s (-20.9% 🟢)1.327s81.00x
▲ VercelNext.js (Turbopack)2.904s (-20.4% 🟢)4.585s (-12.1% 🟢)1.681s71.07x
▲ VercelNitro3.259s (-32.5% 🟢)4.713s (-26.4% 🟢)1.455s71.20x

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

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.633s (+17.6% 🔺)4.015s (+99.9% 🔺)2.383s81.00x
🐘 PostgresExpress1.778s (+26.9% 🔺)3.764s (+87.2% 🔺)1.985s81.09x
🐘 PostgresNext.js (Turbopack)3.795s (+109.9% 🔺)4.588s (+106.3% 🔺)0.793s72.32x
💻 LocalExpress4.915s (-22.7% 🟢)5.515s (-21.4% 🟢)0.600s63.01x
💻 LocalNext.js (Turbopack)5.698s (-5.9% 🟢)6.217s (-8.8% 🟢)0.518s53.49x
💻 LocalNitro6.163s (+13.4% 🔺)6.417s (+6.7% 🔺)0.254s53.77x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.482s (-31.8% 🟢)5.220s (-27.9% 🟢)1.738s61.00x
▲ VercelNitro3.723s (-46.8% 🟢)5.600s (-34.9% 🟢)1.877s61.07x
▲ VercelNext.js (Turbopack)4.865s (-39.4% 🟢)6.332s (-35.3% 🟢)1.467s51.40x

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

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.594s (-3.0%)1.005s (~)0.411s601.00x
💻 LocalNitro0.599s (~)1.005s (~)0.406s601.01x
🐘 PostgresNitro0.609s (+5.1% 🔺)1.040s (+3.4%)0.432s581.03x
🐘 PostgresExpress0.671s (+11.7% 🔺)1.147s (+13.9% 🔺)0.476s531.13x
🐘 PostgresNext.js (Turbopack)0.849s (-0.6%)1.006s (-1.7%)0.157s601.43x
💻 LocalNext.js (Turbopack)0.912s (+5.7% 🔺)1.075s (+5.2% 🔺)0.163s571.54x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.786s (+16.6% 🔺)7.078s (+8.6% 🔺)1.292s91.00x
▲ VercelNext.js (Turbopack)5.799s (+11.1% 🔺)7.275s (+6.0% 🔺)1.476s91.00x
▲ VercelExpress6.123s (-39.6% 🟢)7.604s (-35.4% 🟢)1.481s81.06x

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

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.390s (-3.2%)2.029s (~)0.639s451.00x
🐘 PostgresExpress1.504s (+4.9%)2.110s (+5.0% 🔺)0.605s431.08x
💻 LocalExpress1.510s (-2.8%)2.006s (-1.1%)0.496s451.09x
💻 LocalNitro1.528s (+1.5%)2.006s (~)0.479s451.10x
🐘 PostgresNext.js (Turbopack)1.999s (-1.5%)2.308s (-13.1% 🟢)0.309s401.44x
💻 LocalNext.js (Turbopack)2.117s (+0.8%)3.008s (~)0.892s301.52x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express14.919s (+3.8%)16.513s (+2.2%)1.594s61.00x
▲ VercelNitro15.432s (+17.8% 🔺)17.056s (+17.9% 🔺)1.624s61.03x
▲ VercelNext.js (Turbopack)15.850s (+19.2% 🔺)17.815s (+18.0% 🔺)1.965s61.06x

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

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.755s (-1.5%)3.085s (-2.6%)0.330s391.00x
🐘 PostgresExpress2.768s (-1.5%)3.311s (+5.5% 🔺)0.543s371.00x
💻 LocalExpress3.336s (+1.5%)4.042s (+0.8%)0.707s301.21x
💻 LocalNitro3.340s (+1.3%)4.010s (~)0.670s301.21x
🐘 PostgresNext.js (Turbopack)3.913s (-1.8%)4.110s (-5.1% 🟢)0.197s301.42x
💻 LocalNext.js (Turbopack)4.360s (~)5.011s (~)0.650s241.58x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express27.778s (+5.9% 🔺)30.392s (+7.7% 🔺)2.614s51.00x
▲ VercelNitro27.783s (-4.6%)30.056s (-2.4%)2.274s41.00x
▲ VercelNext.js (Turbopack)29.676s (-16.9% 🟢)31.852s (-15.0% 🟢)2.176s41.07x

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

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.252s (+14.8% 🔺)1.006s (~)0.754s601.00x
🐘 PostgresExpress0.264s (+13.3% 🔺)1.009s (~)0.745s601.05x
🐘 PostgresNext.js (Turbopack)0.319s (+15.0% 🔺)1.006s (~)0.687s601.27x
💻 LocalNitro0.417s (-3.0%)1.005s (-1.7%)0.588s601.66x
💻 LocalExpress0.453s (+9.7% 🔺)1.021s (+1.6%)0.568s591.80x
💻 LocalNext.js (Turbopack)0.553s (+1.3%)1.005s (~)0.452s602.20x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.055s (-20.0% 🟢)3.438s (-16.3% 🟢)1.383s181.00x
▲ VercelNitro2.234s (-20.9% 🟢)3.836s (-11.1% 🟢)1.602s161.09x
▲ VercelNext.js (Turbopack)2.376s (-46.7% 🟢)3.893s (-37.5% 🟢)1.517s161.16x

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

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.393s (+5.1% 🔺)1.036s (+3.0%)0.643s871.00x
🐘 PostgresNitro0.421s (+17.3% 🔺)1.029s (+2.2%)0.608s881.07x
🐘 PostgresNext.js (Turbopack)0.619s (+24.1% 🔺)1.133s (+12.5% 🔺)0.513s801.57x
💻 LocalNitro2.207s (+1.2%)2.715s (~)0.508s345.61x
💻 LocalExpress2.208s (+8.4% 🔺)2.737s (+7.9% 🔺)0.529s335.61x
💻 LocalNext.js (Turbopack)2.513s (+2.6%)3.225s (+2.5%)0.712s286.39x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.890s (-46.6% 🟢)4.303s (-39.2% 🟢)1.414s211.00x
▲ VercelNitro3.175s (-65.7% 🟢)4.598s (-57.7% 🟢)1.424s211.10x
▲ VercelNext.js (Turbopack)3.728s (-51.6% 🟢)5.309s (-43.4% 🟢)1.581s181.29x

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

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.711s (+2.2%)1.190s (+18.3% 🔺)0.479s1011.00x
🐘 PostgresNitro0.808s (+18.6% 🔺)1.340s (+33.2% 🔺)0.532s901.14x
🐘 PostgresNext.js (Turbopack)3.015s (+199.9% 🔺)3.798s (+104.8% 🔺)0.782s324.24x
💻 LocalExpress9.681s (+3.9%)10.113s (+2.4%)0.431s1213.61x
💻 LocalNitro9.892s (+6.6% 🔺)10.362s (+5.8% 🔺)0.470s1213.90x
💻 LocalNext.js (Turbopack)10.774s (-1.5%)11.482s (~)0.708s1115.14x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express6.916s (-63.4% 🟢)8.385s (-59.1% 🟢)1.469s151.00x
▲ VercelNitro7.160s (-62.8% 🟢)8.614s (-61.1% 🟢)1.454s151.04x
▲ VercelNext.js (Turbopack)7.650s (-65.4% 🟢)9.225s (-62.2% 🟢)1.575s141.11x

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

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.161s (-0.6%)2.005s (~)0.011s (-10.3% 🟢)2.018s (~)0.857s101.00x
🐘 PostgresExpress1.171s (-0.7%)2.002s (~)0.001s (-25.0% 🟢)2.011s (~)0.840s101.01x
🐘 PostgresNitro1.173s (~)1.998s (~)0.001s (~)2.010s (~)0.838s101.01x
💻 LocalNitro1.188s (+1.9%)2.005s (~)0.013s (+20.2% 🔺)2.020s (~)0.832s101.02x
💻 LocalNext.js (Turbopack)1.210s (~)2.004s (~)0.013s (+1.6%)2.021s (~)0.811s101.04x
🐘 PostgresNext.js (Turbopack)1.252s (+0.5%)2.002s (~)0.001s (-7.7% 🟢)2.011s (~)0.759s101.08x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.283s (+5.3% 🔺)3.012s (-6.0% 🟢)1.285s (+41.4% 🔺)4.735s (+3.4%)2.452s101.00x
▲ VercelNext.js (Turbopack)2.429s (+8.2% 🔺)3.474s (+7.5% 🔺)1.219s (+6.8% 🔺)5.101s (+4.5%)2.672s101.06x
▲ VercelNitro2.460s (+0.5%)3.395s (+1.0%)1.287s (+35.6% 🔺)5.115s (+7.3% 🔺)2.654s101.08x

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

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.574s (~)2.011s (~)0.013s (+11.9% 🔺)2.026s (~)0.452s301.00x
🐘 PostgresNitro1.591s (-1.3%)2.004s (~)0.005s (-0.7%)2.027s (~)0.435s301.01x
💻 LocalNitro1.593s (~)2.010s (~)0.012s (-6.7% 🟢)2.024s (~)0.430s301.01x
🐘 PostgresExpress1.658s (+3.6%)2.071s (+3.2%)0.004s (-20.1% 🟢)2.099s (+3.5%)0.440s291.05x
💻 LocalNext.js (Turbopack)1.743s (~)2.011s (~)0.012s (-1.4%)2.026s (~)0.284s301.11x
🐘 PostgresNext.js (Turbopack)1.822s (+2.0%)2.043s (+1.6%)0.005s (+3.3%)2.061s (+1.6%)0.239s301.16x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.706s (-3.6%)6.557s (-6.8% 🟢)0.248s (-47.2% 🟢)7.186s (-10.0% 🟢)1.480s91.00x
▲ VercelNext.js (Turbopack)5.845s (-9.3% 🟢)6.951s (-9.8% 🟢)0.332s (-40.8% 🟢)7.707s (-13.2% 🟢)1.862s81.02x
▲ VercelExpress5.862s (-3.5%)6.751s (-7.5% 🟢)0.441s (+67.7% 🔺)7.547s (-5.6% 🟢)1.685s81.03x

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

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.809s (+14.2% 🔺)1.081s (+3.6%)0.000s (-32.1% 🟢)1.096s (+3.1%)0.287s561.00x
🐘 PostgresExpress0.948s (+34.0% 🔺)1.295s (+27.7% 🔺)0.000s (+28.3% 🔺)1.319s (+28.2% 🔺)0.371s461.17x
🐘 PostgresNext.js (Turbopack)1.035s (+20.1% 🔺)1.539s (+41.1% 🔺)0.000s (-29.5% 🟢)1.547s (+40.8% 🔺)0.512s391.28x
💻 LocalNitro1.399s (-2.9%)2.013s (~)0.000s (+60.0% 🔺)2.015s (~)0.616s301.73x
💻 LocalExpress1.415s (+4.8%)2.013s (~)0.000s (+75.0% 🔺)2.015s (~)0.600s301.75x
💻 LocalNext.js (Turbopack)1.481s (+1.0%)2.012s (~)0.000s (-13.3% 🟢)2.016s (~)0.535s301.83x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.721s (-28.4% 🟢)3.796s (-21.4% 🟢)0.000s (NaN%)4.171s (-20.1% 🟢)1.450s151.00x
▲ VercelExpress2.801s (-16.6% 🟢)4.062s (-11.2% 🟢)0.000s (-100.0% 🟢)4.442s (-11.9% 🟢)1.641s141.03x
▲ VercelNext.js (Turbopack)2.963s (-19.9% 🟢)4.093s (-17.7% 🟢)0.000s (-100.0% 🟢)4.499s (-17.8% 🟢)1.536s141.09x

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

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

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.560s (+2.3%)2.216s (~)0.000s (-50.0% 🟢)2.245s (~)0.685s271.00x
🐘 PostgresNitro1.701s (+27.5% 🔺)2.217s (+9.4% 🔺)0.000s (+Infinity% 🔺)2.231s (+8.1% 🔺)0.530s271.09x
🐘 PostgresNext.js (Turbopack)2.076s (+20.0% 🔺)2.503s (+10.7% 🔺)0.000s (+Infinity% 🔺)2.510s (+10.5% 🔺)0.434s241.33x
💻 LocalNext.js (Turbopack)2.946s (+3.3%)3.614s (+5.8% 🔺)0.001s (+111.8% 🔺)3.617s (+5.8% 🔺)0.671s171.89x
💻 LocalNitro3.128s (-1.2%)3.779s (-1.5%)0.001s (+400.0% 🔺)3.781s (-1.6%)0.653s162.01x
💻 LocalExpress3.189s (+3.0%)3.841s (+4.6%)0.001s (+6.2% 🔺)3.844s (+4.5%)0.654s162.04x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro4.214s (-42.9% 🟢)5.507s (-30.6% 🟢)0.000s (+Infinity% 🔺)5.899s (-34.7% 🟢)1.685s111.00x
▲ VercelNext.js (Turbopack)4.747s (-46.5% 🟢)5.769s (-51.6% 🟢)0.000s (-95.0% 🟢)6.151s (-50.8% 🟢)1.404s101.13x
▲ VercelExpress4.778s (-10.2% 🟢)6.123s (-6.9% 🟢)0.000s (NaN%)6.547s (-9.0% 🟢)1.769s101.13x

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

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalExpress15/21
🐘 PostgresNitro11/21
▲ VercelExpress10/21
Fastest World by Framework

Winner determined by most benchmark wins

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

Comment threaddocs/content/docs/v5/foundations/hooks.mdx Outdated

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR replaces the hook conflict detection API from hook.hasConflict: Promise<boolean> to hook.getConflict: Promise<Run | null>, so a duplicate run can learn which run currently owns a conflicting hook token (and optionally act on it via the returned Run handle). It also updates the runtime, tests, e2e workflows, and v4/v5 docs to reflect the new API.

Changes:

  • Replace hasConflict plumbing with getConflict, including resolving to a conflicting Run handle and reusing the same instance across awaits.
  • Expose the workflow-bundle Run class via a new WORKFLOW_RUN_CLASS symbol so the hook consumer can construct durable Run handles inside workflow code.
  • Update workbench workflows, unit/e2e tests, docs (v4 + v5), and changesets for the renamed API.

Reviewed changes

Copilot reviewed 23 out of 23 changed files in this pull request and generated 5 comments.

Show a summary per file
FileDescription
workbench/nextjs-turbopack/app/workflows/definitions.tsRenames e2e workflow entries from hookHasConflict* to hookGetConflict*.
workbench/example/workflows/99_e2e.tsUpdates example workflows to use hook.getConflict and return conflict run metadata.
packages/core/src/workflow/hook.tsImplements getConflict resolution, constructs conflicting Run via WORKFLOW_RUN_CLASS.
packages/core/src/workflow/hook.test.tsUpdates unit tests to assert getConflict semantics and stable instance identity.
packages/core/src/workflow/create-hook.tsRegisters workflow-bundle Run class on globalThis[WORKFLOW_RUN_CLASS].
packages/core/src/workflow.test.tsUpdates workflow runner tests for suspension + getConflict behavior.
packages/core/src/symbols.tsAdds WORKFLOW_RUN_CLASS symbol definition and rationale.
packages/core/src/runtime/suspension-handler.tsUpdates comment/semantics references from hasConflict to getConflict.
packages/core/src/runtime/suspension-handler.test.tsUpdates tests and descriptions to the new getConflict naming.
packages/core/src/runtime.tsUpdates inline-execution gating comments to reference hook.getConflict.
packages/core/src/global.tsUpdates hook invocation queue item comment to reflect getConflict.
packages/core/src/create-hook.tsPublic Hook interface now exposes getConflict and updated docs/examples.
packages/core/e2e/e2e.test.tsRenames e2e tests and asserts conflict returns owner runId + durable status.
docs/content/docs/v5/how-it-works/event-sourcing.mdxUpdates hook conflict behavior description to reference getConflict.
docs/content/docs/v5/foundations/hooks.mdxUpdates conflict-checking guidance and examples to use getConflict.
docs/content/docs/v5/api-reference/workflow/create-webhook.mdxUpdates webhook return shape docs to getConflict.
docs/content/docs/v5/api-reference/workflow/create-hook.mdxUpdates API reference + examples from hasConflict to getConflict.
docs/content/docs/v4/how-it-works/event-sourcing.mdxUpdates v4 event-sourcing docs to reference getConflict.
docs/content/docs/v4/foundations/hooks.mdxUpdates v4 conflict-checking docs to getConflict ({ runId } guidance).
docs/content/docs/v4/api-reference/workflow/create-webhook.mdxUpdates v4 webhook docs to getConflict returning { runId }/null.
docs/content/docs/v4/api-reference/workflow/create-hook.mdxUpdates v4 hook API reference to getConflict returning { runId }/null.
.changeset/hook-has-conflict.mdRemoves changeset for the superseded hasConflict API.
.changeset/hook-get-conflict.mdAdds changeset for getConflict.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

// executes inside the VM, so `Run` here is the plugin-compiled variant
// whose methods are durable step proxies. The host-side consumer uses it
// to construct the conflicting run resolved by `hook.getConflict`.
(globalThis as any)[WORKFLOW_RUN_CLASS] ??= Run;

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in 919bde5: the registration is now guarded on the workflow runtime being present (WORKFLOW_CREATE_HOOK is installed on the VM globalThis before the bundle is evaluated), so importing this module outside the VM neither mutates the host global nor exposes the host-side Run (whose methods are not step proxies). Verified via the hookGetConflict e2e suite against a real bundle.

Comment threadpackages/core/src/create-hook.ts Outdated
Comment on lines +34 to +36
* Resolves with the conflicting {@link Run} if another active hook
* already owns this hook's token, or `null` once the hook has been
* registered and is ready to receive payloads.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Already addressed in dec869d (this review appears to have run against the initial commit): createConflictingRun() no longer falls back to a { runId }-shaped object. When a real Run cannot be constructed — including hook_conflict events without conflictingRunIdgetConflict() rejects with HookConflictError instead, and the JSDoc documents exactly that.

Comment threadpackages/core/src/create-hook.ts Outdated
* ```
*/
readonly hasConflict: Promise<boolean>;
readonly getConflict: Promise<Run<unknown> | null>;

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Already addressed in dec869d: the runtime now strictly honors Promise<Run<unknown> | null>. The { runId } fallback was removed — degenerate cases (legacy events without conflictingRunId, contexts without the bundle Run class) reject with HookConflictError rather than resolving with a value that lacks Run accessors, so the public type no longer needs widening.

```

Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.hasConflict` suspends the workflow to commit the hook registration, then resolves with `false` once the hook is registered and ready to receive payloads, or `true` if another active hook already owns the same token (see [`HookConflictError`](/docs/errors/hook-conflict)).
Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.getConflict` suspends the workflow to commit the hook registration, then resolves with `null` once the hook is registered and ready to receive payloads, or with a `Run` handle for the run that owns the token if another active hook already claimed it (see [`HookConflictError`](/docs/errors/hook-conflict)). The conflicting run's accessors are durable steps, so the workflow can inspect `await conflict.status`, wait on `await conflict.returnValue`, or cancel the owner with `await conflict.cancel()` — see [Run idempotency](/docs/foundations/idempotency#run-idempotency) for these strategies.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Clarified in 919bde5: the hooks docs (v4 + v5) now state that for hook_conflict events persisted by older worlds without the owning run ID, getConflict() rejects with HookConflictError instead of resolving with an incomplete handle — so when it does resolve with a conflict, the durable accessors are always available.

Comment thread.changeset/hook-get-conflict.md Outdated
"workflow": minor
---

Add `hook.getConflict`, a promise that suspends the workflow to commit hook registration and resolves with the conflicting `Run` when another active hook owns the token (or `null` once the hook is registered), without waiting for hook payload data.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

that doesn't matter because we didn't ship a new release with the previousl documented API so it's a fast follow and change

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Updated in 919bde5: the changeset now explicitly calls out that this replaces hook.hasConflict and includes the migration (await hook.hasConflictconst conflict = await hook.getConflict(), branch on conflict !== null).

getConflict's contract is Promise<Run | null>. In the degenerate cases
where a real Run cannot be constructed — a hook_conflict event persisted
by an old world without conflictingRunId, or a context that never loaded
the workflow-mode create-hook module — reject with HookConflictError
instead of resolving with a { runId }-shaped impostor.
Test harnesses now register the Run class on the (VM) globalThis like
real bundles do.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Request Changes — the feature is right, but the WORKFLOW_RUN_CLASS global duplicates infrastructure the serialization pipeline already provides

First: the API direction is a clear improvement. A Run handle beats the boolean (hasConflict told you that, not who), code-driven conflict strategy beats a fixed policy enum, and the dec869d9e decision to reject with HookConflictError rather than resolve a { runId }-shaped impostor keeps the Run contract honest. The determinism story checks out too: both fast-paths settle through promiseQueue, conflictRunRef is shared so repeated awaits observe one instance, replay reconstructs the handle from the same event-log conflictingRunId, and the bundle-compiled Run's accessors are step proxies (I verified the compiled output — Object.defineProperty(Run.prototype, ...) with __step_Run$... proxies), so await conflict.status / conflict.cancel() from workflow code are durable. 1156 unit tests pass locally; the red express E2E lane is failing errorWorkflowNested/errorWorkflowCrossFile (not hook tests) — baseline flake.

The change I'm requesting: use the class registry instead of a bespoke global

The (globalThis as any)[WORKFLOW_RUN_CLASS] ??= Run registration in workflow-mode create-hook.ts re-implements, ad hoc, something the serialization pipeline already does systematically. I built workbench/example from this branch and inspected the compiled flow bundle:

(function(__wf_cls,__wf_id){var__wf_sym=Symbol.for("workflow-class-registry"), ...
})(Run,"class//./packages/core/dist/runtime/run//Run");

The SWC plugin already registers the workflow-compiled Run in globalThis[WORKFLOW_CLASS_REGISTRY] at bundle evaluation — because Run carries WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE. This is the exact channel a start()-returned Run already uses to cross from host to VM today: the Instance reducer emits { classId, data: { runId, resilientStart } }, and getClassRevivers(global).Instance looks the class up via getSerializationClass(classId, global) against the VM's global and calls its WORKFLOW_DESERIALIZE.

So the class this PR smuggles through a new symbol is already sitting in the VM's registry when the hook consumer runs. The only missing piece is the lookup key — the plugin derives it from the module path (class//./packages/core/dist/runtime/run//Run in the workbench; different prefix under npm-installed layouts / other bundlers), so it can't be hardcoded.

Proposal (~15 lines total):

  1. Register Run under a stable, well-known classId alongside the plugin's path-derived one. registerSerializationClass can't be called twice (its non-configurable defineProperty(cls, 'classId') throws once the plugin IIFE has run), so add a tiny alias helper to class-serialization.ts:
/** Register an additional stable id for an already-registered class * (skips the classId defineProperty). */exportfunctionaliasSerializationClass(classId: string,cls: Function){getRegistry().set(classId,cls);}
  1. Call it at module scope of runtime/run.ts (e.g. aliasSerializationClass('class//workflow//Run', Run)). Note what this buys over the symbol approach: per-context discipline for free. Evaluated inside the VM, it registers the VM's step-proxy Run on the VM's registry; evaluated on the host, it registers the host Run on the host's registry. Each global gets its own correct class — which means the if (globalThis[WORKFLOW_CREATE_HOOK]) guard and the ??= (both of which exist purely to keep the host Run from leaking into the wrong global) simply disappear, along with the module-scope conditional in create-hook.ts and the new symbol in symbols.ts.

  2. The consumer constructs through the pipeline's own hook instead of new RunClass(id):

functioncreateConflictingRun(ctx,conflictingRunId){if(typeofconflictingRunId!=='string')returnnull;constRunClass=getSerializationClass(RUN_STABLE_CLASS_ID,ctx.globalThis);if(!RunClass)returnnull;return(RunClassasany)[WORKFLOW_DESERIALIZE]({runId: conflictingRunId});}

Same degenerate-case semantics you already have (registry miss → null → awaiters reject with HookConflictError), but the construction takes the identical code path a serialized Run instance takes everywhere else. class-serialization.ts is deliberately dependency-light ("separate from private.ts to avoid pulling in Node.js-only dependencies"), so importing it from workflow/hook.ts is safe.

Side benefit worth banking: a stable classId hardens Run serialization generally. Today a Run persisted in the event log (e.g. a start()-in-workflow handle in a step result) embeds the path-derived id, which is only safe because version-skew protection pins replay to the same deployment whose bundles agree on the path. A stable alias removes that coupling — any future cross-deployment replay (or a bundler change mid-release) stops being able to break Run hydration with "Class not found".

I considered the fuller "interact with the serialization pipeline" variant — making getConflict() resolution a step so the standard step-return serde does everything — and I think your current design is better: piggybacking on hook_created/hook_conflict costs zero extra events and zero queue hops, where a step-based resolution would add one of each per call. The registry lookup keeps that property while deleting the bespoke channel.

Smaller notes

  • The v4 docs describe a { runId } return that no released 4.x SDK has (and getConflict itself isn't on stable). Same sequencing flag as #2015/#2011 — the v4 pages keep widening the documented-but-unreleased surface; fine if the backport chain is imminent, worth holding otherwise.
  • e56e6fbc25b3b88b0b's getter→method change (hook.getConflict()) is the right call — a property that allocates and enqueues new promise resolvers on every access was the same trap hasConflict had; a method at least signals effects.
  • Changeset correctly replaces the hasConflict one and is scoped to @workflow/core + workflow (minor) — and since hasConflict shipped in no stable release and at most one beta, replacing rather than deprecating is fine.

Happy to re-review quickly once the registry switch is in — everything else here is ready.

… registry
Replace the bespoke WORKFLOW_RUN_CLASS global with the registry the
serialization pipeline already uses to revive Run instances:
- The SWC plugin already auto-registers the workflow bundle's compiled
Run in globalThis[workflow-class-registry], but under a path-derived
classId the host cannot know statically. The workflow-mode create-hook
module now aliases it under a stable id (class//workflow//Run) via a
new aliasSerializationClass() helper (a plain registry entry —
registerSerializationClass cannot be reused since the plugin's IIFE
already defined the non-configurable classId property).
- createConflictingRun() looks the class up with
getSerializationClass(RUN_CLASS_ID, ctx.globalThis) and constructs
through its WORKFLOW_DESERIALIZE hook, exactly as the Instance reviver
would for a serialized Run crossing from a step into the workflow.
- Because the registry is keyed per-global, no environment guard is
needed: a stray host-side import registers the host Run on the host
registry, which is the correct class for that context. The
WORKFLOW_CREATE_HOOK guard, the ??=, and the WORKFLOW_RUN_CLASS symbol
are all deleted.
Verified: 1156 core unit tests; compiled workbench bundle contains the
stable alias alongside the plugin's path-derived registration with zero
WORKFLOW_RUN_CLASS references; all 5 hookGetConflict e2e tests pass
against a local nextjs-turbopack dev server, including conflict
resolution reading conflict.status through a durable step.

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approve — the registry switch from my previous review is now implemented (0aa3cb0)

I implemented the change I requested (after discussing with Nathan), so this approval covers Pranay's feature plus that refactor — Pranay, please give 0aa3cb0 a once-over since it rewrites the class-handoff mechanism you authored.

What changed:

  • WORKFLOW_RUN_CLASS is gone (symbol, the ??= registration, and the WORKFLOW_CREATE_HOOK guard). The workflow-mode create-hook module now aliases the bundle's Run into the existing serialization class registry under a stable id (class//workflow//Run) via a new aliasSerializationClass() helper — a plain registry entry, since registerSerializationClass's non-configurable defineProperty(classId) would throw on a class the SWC plugin already registered.
  • createConflictingRun resolves through the pipeline: getSerializationClass(RUN_CLASS_ID, ctx.globalThis) + construction via the class's WORKFLOW_DESERIALIZE hook — the identical path the Instance reviver takes for a serialized Run crossing from a step into the workflow. Degenerate-case semantics unchanged: registry miss or missing deserialize hook → null → awaiters reject with HookConflictError.
  • No environment guard needed: the registry is per-global by construction, so a stray host-side import of the module registers the host Run on the host registry — correct for that context.

Verification:

  • 1156 core unit tests pass; hook.test.ts / workflow.test.ts mocks updated to register through the registry (the VM-string test now mirrors the real bundle shape, including the WORKFLOW_DESERIALIZE hook)
  • Rebuilt workbench/example: compiled flow bundle contains the stable alias alongside the plugin's path-derived registration, with zero WORKFLOW_RUN_CLASS references
  • All 5 hookGetConflict* e2e tests pass against a local nextjs-turbopack dev server, including the conflict test that resolves the actual owner and reads await conflict.status through a durable step — the full host → VM-registry → WORKFLOW_DESERIALIZE → step-proxy chain
  • The two biome complexity findings in hook.ts are pre-existing on this branch (identical count before/after)

Remaining notes from my previous review stand but don't block: the v4 docs describe a surface not yet on stable (recurring sequencing flag), and the stable classId incidentally hardens Run event-log serialization against future cross-deployment replay.

@github-actions

Copy link
Copy Markdown
Contributor

Backport PR opened against stable: #2382. Merge conflicts were resolved by AI — please review carefully. (backport job run)

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

@pranaygp@TooTallNate
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Replace hook.hasConflict with hook.getConflict() returning the conflicting Run by pranaygp · Pull Request #2373 · vercel/workflow · GitHub
Skip to content

Replace hook.hasConflict with hook.getConflict() returning the conflicting Run - #2373

Merged
TooTallNate merged 5 commits into
mainfrom
pranaygp/hook-get-conflict
Jun 12, 2026
Merged

Replace hook.hasConflict with hook.getConflict() returning the conflicting Run#2373
TooTallNate merged 5 commits into
mainfrom
pranaygp/hook-get-conflict

Conversation

@pranaygp

@pranaygppranaygp commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #2015. hook.hasConflict's boolean told the workflow that a conflict exists but not which run owns the token, so the duplicate run couldn't act on it. This replaces it with hook.getConflict(), a promise that resolves with:

  • null once the hook registration is committed (hook_created recorded), or
  • a Run handle for the conflicting run when another active hook owns the token.

Like hasConflict, awaiting getConflict() suspends the workflow to commit the hook registration without waiting for payload data. But on conflict, the workflow now has the active owner in hand and can choose its own idempotency strategy in code:

using hook=createHook({token: `order:${orderId}`});constconflict=awaithook.getConflict();if(conflict){// any of:return{dedupedTo: conflict.runId};// route caller to the ownerconststatus=awaitconflict.status;// inspect before decidingreturnawaitconflict.returnValue;// adopt the owner's resultawaitconflict.cancel();// supersede the owner and continue}

This is deliberately code-driven rather than a fixed set of ID-reuse policies (cf. Temporal's WorkflowIdReusePolicy / WorkflowIdConflictPolicy).

Implementation

  • The workflow-mode create-hook module exposes the bundle's compiled Run class on a well-known global symbol (WORKFLOW_RUN_CLASS). The bundle's Run is the SWC-plugin-compiled variant whose accessors (status, returnValue, cancel(), …) are durable step proxies — safe and deterministic inside workflow code. The host-side hook event consumer constructs the conflicting Run from the hook_conflict event's conflictingRunId (Expose conflicting run id on hook conflicts #2012) using that class.
  • getConflict never resolves with anything but a real Run or null. In the degenerate cases where a Run cannot be constructed — a hook_conflict event persisted by an old world without conflictingRunId, or a context that never loaded the workflow-mode create-hook module — it rejects with HookConflictError (which still carries the token) instead of resolving with a { runId }-shaped impostor. Note: the { runId } shape remains the documented v4 return value, since v4 has no native Run serialization — v4 docs direct users to getRun(conflict.runId) inside a step.
  • Repeated await hook.getConflict() calls observe the same Run instance; both fast-paths settle through promiseQueue to keep resolution order aligned with the event log.
  • Awaiting the hook payload on a conflicted token still rejects with HookConflictError.

Docs

Updated createHook() / createWebhook() references, hooks foundations, and event-sourcing pages (v4 + v5; v5 documents the Run return, v4 documents { runId }). A follow-up to #2011 adds the full conflict-handling strategy guide to the run-idempotency docs.

Validation

  • cd packages/core && pnpm typecheck && pnpm vitest run src/ (1155 tests)
  • pnpm test:docs
  • Hook e2e suite against local nextjs-turbopack: all hookGetConflict* tests pass, including the conflict test asserting the duplicate run resolves the actual owner's runId and reads await conflict.status === 'running' via a durable step.

…ull>)
hasConflict's boolean didn't expose WHICH run owns the token, so the
duplicate run couldn't act on the conflict. getConflict resolves with
null once registration commits, or with a Run handle for the conflicting
run — letting the workflow return/log the owner's runId, inspect its
status, await its result, or cancel it and continue, all in code.
The workflow-mode create-hook module exposes the bundle's compiled Run
class (durable step-proxy methods) on a well-known symbol so the host-
side hook consumer can construct the conflicting run inside the VM.
Contexts without the class (plain unit tests) fall back to a { runId }
object, which is also the documented v4 shape (no native Run
serialization in v4).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings June 11, 2026 23:11
@pranaygp
pranaygp requested a review from a team as a code ownerJune 11, 2026 23:11
@changeset-bot

changeset-botBot commented Jun 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0aa3cb0

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

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

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production137602191595
✅ 💻 Local Development181102192030
✅ 📦 Local Production181102192030
✅ 🐘 Local Postgres179702332030
✅ 🪟 Windows14500145
✅ 📋 Other83701781015
Total7777010688845

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro119026
✅ example119026
✅ express119026
✅ fastify119026
✅ hono119026
✅ nextjs-turbopack14302
✅ nextjs-webpack14302
✅ nitro119026
✅ nuxt119026
✅ sveltekit13807
✅ vite119026
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable120025
✅ express-stable120025
✅ fastify-stable120025
✅ hono-stable120025
✅ nextjs-turbopack-canary126019
✅ nextjs-turbopack-stable-lazy-discovery-disabled14500
✅ nextjs-turbopack-stable-lazy-discovery-enabled14500
✅ nextjs-webpack-canary126019
✅ nextjs-webpack-stable-lazy-discovery-disabled14500
✅ nextjs-webpack-stable-lazy-discovery-enabled14500
✅ nitro-stable120025
✅ nuxt-stable120025
✅ sveltekit-stable13906
✅ vite-stable120025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable120025
✅ express-stable120025
✅ fastify-stable120025
✅ hono-stable120025
✅ nextjs-turbopack-canary126019
✅ nextjs-turbopack-stable-lazy-discovery-disabled14500
✅ nextjs-turbopack-stable-lazy-discovery-enabled14500
✅ nextjs-webpack-canary126019
✅ nextjs-webpack-stable-lazy-discovery-disabled14500
✅ nextjs-webpack-stable-lazy-discovery-enabled14500
✅ nitro-stable120025
✅ nuxt-stable120025
✅ sveltekit-stable13906
✅ vite-stable120025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable119026
✅ express-stable119026
✅ fastify-stable119026
✅ hono-stable119026
✅ nextjs-turbopack-canary125020
✅ nextjs-turbopack-stable-lazy-discovery-disabled14401
✅ nextjs-turbopack-stable-lazy-discovery-enabled14401
✅ nextjs-webpack-canary125020
✅ nextjs-webpack-stable-lazy-discovery-disabled14401
✅ nextjs-webpack-stable-lazy-discovery-enabled14401
✅ nitro-stable119026
✅ nuxt-stable119026
✅ sveltekit-stable13807
✅ vite-stable119026
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack14500
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable120025
✅ e2e-local-dev-tanstack-start-120025
✅ e2e-local-postgres-nest-stable119026
✅ e2e-local-postgres-tanstack-start-119026
✅ e2e-local-prod-nest-stable120025
✅ e2e-local-prod-tanstack-start-120025
✅ e2e-vercel-prod-tanstack-start119026

📋 View full workflow run

@github-actions

github-actionsBot commented Jun 11, 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.042s (-1.9%)1.006s (~)0.965s101.00x
💻 LocalNitro0.046s (+7.8% 🔺)1.006s (~)0.960s101.10x
🐘 PostgresNitro0.065s (-1.5%)1.013s (~)0.948s101.56x
💻 LocalNext.js (Turbopack)0.066s (+5.2% 🔺)1.006s (~)0.940s101.59x
🐘 PostgresNext.js (Turbopack)0.072s (-4.3%)1.012s (~)0.941s101.72x
🐘 PostgresExpress0.080s (+30.0% 🔺)1.032s (+2.0%)0.952s101.92x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.244s (-35.3% 🟢)2.122s (-12.6% 🟢)1.878s101.00x
▲ VercelNitro0.260s (-31.1% 🟢)2.099s (~)1.839s101.07x
▲ VercelExpress0.268s (-1.1%)2.117s (-18.0% 🟢)1.850s101.10x

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

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.089s (-2.4%)2.011s (~)0.922s101.00x
💻 LocalExpress1.096s (~)2.007s (~)0.911s101.01x
💻 LocalNitro1.101s (+0.9%)2.006s (~)0.905s101.01x
🐘 PostgresNitro1.108s (~)2.009s (~)0.900s101.02x
💻 LocalNext.js (Turbopack)1.139s (+1.0%)2.007s (~)0.867s101.05x
🐘 PostgresNext.js (Turbopack)1.185s (+3.7%)2.011s (~)0.826s101.09x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express1.616s (-7.8% 🟢)3.302s (-11.0% 🟢)1.686s101.00x
▲ VercelNitro1.696s (+1.2%)3.485s (+1.0%)1.790s101.05x
▲ VercelNext.js (Turbopack)1.769s (+5.4% 🔺)3.307s (-9.1% 🟢)1.539s101.09x

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

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express10.496s (-0.9%)11.031s (~)0.535s31.00x
💻 LocalExpress10.500s (~)11.021s (~)0.521s31.00x
💻 LocalNitro10.535s (~)11.023s (~)0.488s31.00x
🐘 PostgresNitro10.568s (~)11.017s (~)0.450s31.01x
💻 LocalNext.js (Turbopack)10.794s (~)11.023s (~)0.229s31.03x
🐘 PostgresNext.js (Turbopack)10.842s (-1.6%)11.017s (-5.7% 🟢)0.175s31.03x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro13.610s (+2.0%)15.211s (~)1.601s21.00x
▲ VercelExpress14.017s (+4.5%)15.119s (-3.9%)1.102s21.03x
▲ VercelNext.js (Turbopack)14.111s (~)15.512s (-4.1%)1.401s21.04x

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

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express13.731s (-0.8%)14.028s (~)0.297s51.00x
💻 LocalNitro13.787s (~)14.027s (-1.4%)0.240s51.00x
🐘 PostgresExpress13.956s (+0.9%)14.418s (+2.8%)0.462s51.02x
🐘 PostgresNitro13.986s (+1.2%)14.220s (+1.4%)0.234s51.02x
💻 LocalNext.js (Turbopack)14.348s (-1.0%)15.030s (~)0.681s41.05x
🐘 PostgresNext.js (Turbopack)14.458s (~)15.020s (~)0.562s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro20.475s (-8.7% 🟢)22.157s (-8.0% 🟢)1.681s31.00x
▲ VercelExpress20.531s (-4.4%)21.964s (-4.8%)1.434s31.00x
▲ VercelNext.js (Turbopack)21.986s (+1.8%)23.788s (+1.9%)1.801s31.07x

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

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express12.391s (-0.9%)13.024s (~)0.633s71.00x
🐘 PostgresExpress12.417s (-2.6%)13.017s (~)0.600s71.00x
💻 LocalNitro12.529s (~)13.024s (~)0.495s71.01x
🐘 PostgresNitro12.730s (+1.2%)13.162s (+1.1%)0.433s71.03x
💻 LocalNext.js (Turbopack)13.654s (~)14.026s (~)0.372s71.10x
🐘 PostgresNext.js (Turbopack)14.084s (+0.8%)14.450s (+1.0%)0.366s71.14x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro29.382s (-7.3% 🟢)31.701s (-4.8%)2.319s31.00x
▲ VercelExpress29.422s (-0.8%)31.369s (+0.7%)1.947s31.00x
▲ VercelNext.js (Turbopack)30.219s (+5.4% 🔺)32.193s (+6.0% 🔺)1.973s31.03x

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

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.204s (~)2.015s (~)0.811s151.00x
🐘 PostgresNitro1.217s (+1.1%)2.008s (~)0.791s151.01x
💻 LocalExpress1.228s (+3.2%)2.006s (~)0.778s151.02x
💻 LocalNitro1.250s (+2.2%)2.006s (~)0.756s151.04x
💻 LocalNext.js (Turbopack)1.275s (-5.4% 🟢)2.006s (~)0.731s151.06x
🐘 PostgresNext.js (Turbopack)1.294s (+2.8%)2.008s (~)0.714s151.07x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.316s (-37.4% 🟢)3.726s (-28.1% 🟢)1.410s91.00x
▲ VercelExpress2.794s (+9.5% 🔺)4.184s (-7.0% 🟢)1.391s81.21x
▲ VercelNext.js (Turbopack)3.004s (-1.3%)4.488s (-2.3%)1.484s71.30x

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

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.367s (+8.6% 🔺)2.508s (+25.0% 🔺)1.141s121.00x
🐘 PostgresExpress1.422s (+12.6% 🔺)2.172s (+8.2% 🔺)0.750s141.04x
💻 LocalExpress1.755s (-4.7%)2.006s (-6.7% 🟢)0.251s151.28x
💻 LocalNext.js (Turbopack)1.834s (-2.4%)2.008s (-6.6% 🟢)0.174s151.34x
💻 LocalNitro1.853s (+8.9% 🔺)2.151s (+7.2% 🔺)0.298s141.36x
🐘 PostgresNext.js (Turbopack)1.855s (+32.4% 🔺)2.396s (+19.4% 🔺)0.541s131.36x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.168s (-28.3% 🟢)4.565s (-25.4% 🟢)1.397s81.00x
▲ VercelNext.js (Turbopack)3.520s (-98.9% 🟢)5.051s (-98.4% 🟢)1.531s61.11x
▲ VercelNitro4.477s (-28.2% 🟢)6.029s (-22.2% 🟢)1.552s51.41x

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

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.578s (+12.1% 🔺)3.762s (+87.3% 🔺)2.184s81.00x
🐘 PostgresExpress1.758s (+25.4% 🔺)3.577s (+78.1% 🔺)1.819s91.11x
🐘 PostgresNext.js (Turbopack)3.622s (+100.8% 🔺)4.583s (+99.5% 🔺)0.962s72.30x
💻 LocalExpress4.738s (-12.0% 🟢)5.514s (-10.8% 🟢)0.775s63.00x
💻 LocalNitro5.252s (~)5.846s (-2.8%)0.595s63.33x
💻 LocalNext.js (Turbopack)5.763s (+1.0%)6.216s (~)0.453s53.65x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro4.006s (-76.4% 🟢)5.907s (-67.7% 🟢)1.902s61.00x
▲ VercelExpress4.380s (-52.0% 🟢)6.113s (-46.2% 🟢)1.733s61.09x
▲ VercelNext.js (Turbopack)6.477s (+7.7% 🔺)7.965s (-5.6% 🟢)1.488s41.62x

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

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.212s (+1.8%)2.007s (~)0.794s151.00x
🐘 PostgresExpress1.254s (+3.5%)2.027s (+1.0%)0.773s151.03x
🐘 PostgresNext.js (Turbopack)1.295s (+2.5%)2.007s (~)0.712s151.07x
💻 LocalNext.js (Turbopack)1.377s (+1.9%)2.006s (~)0.629s151.14x
💻 LocalExpress1.560s (-5.9% 🟢)2.006s (-3.3%)0.446s151.29x
💻 LocalNitro1.603s (+6.3% 🔺)2.007s (~)0.404s151.32x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.267s (-12.1% 🟢)3.589s (-15.3% 🟢)1.323s91.00x
▲ VercelExpress2.338s (-13.4% 🟢)3.777s (-19.9% 🟢)1.439s81.03x
▲ VercelNext.js (Turbopack)2.688s (-34.5% 🟢)4.300s (-23.7% 🟢)1.613s71.19x

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

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.374s (+6.0% 🔺)2.149s (+6.9% 🔺)0.775s151.00x
🐘 PostgresNitro1.378s (+8.4% 🔺)2.223s (+10.7% 🔺)0.845s141.00x
🐘 PostgresNext.js (Turbopack)1.535s (+9.3% 🔺)2.296s (+14.3% 🔺)0.761s141.12x
💻 LocalNext.js (Turbopack)2.019s (-1.3%)2.591s (-13.9% 🟢)0.572s121.47x
💻 LocalNitro2.118s (-2.4%)2.675s (+3.2%)0.557s121.54x
💻 LocalExpress2.183s (+1.1%)2.735s (+2.2%)0.552s111.59x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.708s (-21.0% 🟢)4.034s (-20.9% 🟢)1.327s81.00x
▲ VercelNext.js (Turbopack)2.904s (-20.4% 🟢)4.585s (-12.1% 🟢)1.681s71.07x
▲ VercelNitro3.259s (-32.5% 🟢)4.713s (-26.4% 🟢)1.455s71.20x

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

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.633s (+17.6% 🔺)4.015s (+99.9% 🔺)2.383s81.00x
🐘 PostgresExpress1.778s (+26.9% 🔺)3.764s (+87.2% 🔺)1.985s81.09x
🐘 PostgresNext.js (Turbopack)3.795s (+109.9% 🔺)4.588s (+106.3% 🔺)0.793s72.32x
💻 LocalExpress4.915s (-22.7% 🟢)5.515s (-21.4% 🟢)0.600s63.01x
💻 LocalNext.js (Turbopack)5.698s (-5.9% 🟢)6.217s (-8.8% 🟢)0.518s53.49x
💻 LocalNitro6.163s (+13.4% 🔺)6.417s (+6.7% 🔺)0.254s53.77x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.482s (-31.8% 🟢)5.220s (-27.9% 🟢)1.738s61.00x
▲ VercelNitro3.723s (-46.8% 🟢)5.600s (-34.9% 🟢)1.877s61.07x
▲ VercelNext.js (Turbopack)4.865s (-39.4% 🟢)6.332s (-35.3% 🟢)1.467s51.40x

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

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.594s (-3.0%)1.005s (~)0.411s601.00x
💻 LocalNitro0.599s (~)1.005s (~)0.406s601.01x
🐘 PostgresNitro0.609s (+5.1% 🔺)1.040s (+3.4%)0.432s581.03x
🐘 PostgresExpress0.671s (+11.7% 🔺)1.147s (+13.9% 🔺)0.476s531.13x
🐘 PostgresNext.js (Turbopack)0.849s (-0.6%)1.006s (-1.7%)0.157s601.43x
💻 LocalNext.js (Turbopack)0.912s (+5.7% 🔺)1.075s (+5.2% 🔺)0.163s571.54x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.786s (+16.6% 🔺)7.078s (+8.6% 🔺)1.292s91.00x
▲ VercelNext.js (Turbopack)5.799s (+11.1% 🔺)7.275s (+6.0% 🔺)1.476s91.00x
▲ VercelExpress6.123s (-39.6% 🟢)7.604s (-35.4% 🟢)1.481s81.06x

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

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.390s (-3.2%)2.029s (~)0.639s451.00x
🐘 PostgresExpress1.504s (+4.9%)2.110s (+5.0% 🔺)0.605s431.08x
💻 LocalExpress1.510s (-2.8%)2.006s (-1.1%)0.496s451.09x
💻 LocalNitro1.528s (+1.5%)2.006s (~)0.479s451.10x
🐘 PostgresNext.js (Turbopack)1.999s (-1.5%)2.308s (-13.1% 🟢)0.309s401.44x
💻 LocalNext.js (Turbopack)2.117s (+0.8%)3.008s (~)0.892s301.52x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express14.919s (+3.8%)16.513s (+2.2%)1.594s61.00x
▲ VercelNitro15.432s (+17.8% 🔺)17.056s (+17.9% 🔺)1.624s61.03x
▲ VercelNext.js (Turbopack)15.850s (+19.2% 🔺)17.815s (+18.0% 🔺)1.965s61.06x

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

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.755s (-1.5%)3.085s (-2.6%)0.330s391.00x
🐘 PostgresExpress2.768s (-1.5%)3.311s (+5.5% 🔺)0.543s371.00x
💻 LocalExpress3.336s (+1.5%)4.042s (+0.8%)0.707s301.21x
💻 LocalNitro3.340s (+1.3%)4.010s (~)0.670s301.21x
🐘 PostgresNext.js (Turbopack)3.913s (-1.8%)4.110s (-5.1% 🟢)0.197s301.42x
💻 LocalNext.js (Turbopack)4.360s (~)5.011s (~)0.650s241.58x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express27.778s (+5.9% 🔺)30.392s (+7.7% 🔺)2.614s51.00x
▲ VercelNitro27.783s (-4.6%)30.056s (-2.4%)2.274s41.00x
▲ VercelNext.js (Turbopack)29.676s (-16.9% 🟢)31.852s (-15.0% 🟢)2.176s41.07x

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

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.252s (+14.8% 🔺)1.006s (~)0.754s601.00x
🐘 PostgresExpress0.264s (+13.3% 🔺)1.009s (~)0.745s601.05x
🐘 PostgresNext.js (Turbopack)0.319s (+15.0% 🔺)1.006s (~)0.687s601.27x
💻 LocalNitro0.417s (-3.0%)1.005s (-1.7%)0.588s601.66x
💻 LocalExpress0.453s (+9.7% 🔺)1.021s (+1.6%)0.568s591.80x
💻 LocalNext.js (Turbopack)0.553s (+1.3%)1.005s (~)0.452s602.20x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.055s (-20.0% 🟢)3.438s (-16.3% 🟢)1.383s181.00x
▲ VercelNitro2.234s (-20.9% 🟢)3.836s (-11.1% 🟢)1.602s161.09x
▲ VercelNext.js (Turbopack)2.376s (-46.7% 🟢)3.893s (-37.5% 🟢)1.517s161.16x

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

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.393s (+5.1% 🔺)1.036s (+3.0%)0.643s871.00x
🐘 PostgresNitro0.421s (+17.3% 🔺)1.029s (+2.2%)0.608s881.07x
🐘 PostgresNext.js (Turbopack)0.619s (+24.1% 🔺)1.133s (+12.5% 🔺)0.513s801.57x
💻 LocalNitro2.207s (+1.2%)2.715s (~)0.508s345.61x
💻 LocalExpress2.208s (+8.4% 🔺)2.737s (+7.9% 🔺)0.529s335.61x
💻 LocalNext.js (Turbopack)2.513s (+2.6%)3.225s (+2.5%)0.712s286.39x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.890s (-46.6% 🟢)4.303s (-39.2% 🟢)1.414s211.00x
▲ VercelNitro3.175s (-65.7% 🟢)4.598s (-57.7% 🟢)1.424s211.10x
▲ VercelNext.js (Turbopack)3.728s (-51.6% 🟢)5.309s (-43.4% 🟢)1.581s181.29x

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

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.711s (+2.2%)1.190s (+18.3% 🔺)0.479s1011.00x
🐘 PostgresNitro0.808s (+18.6% 🔺)1.340s (+33.2% 🔺)0.532s901.14x
🐘 PostgresNext.js (Turbopack)3.015s (+199.9% 🔺)3.798s (+104.8% 🔺)0.782s324.24x
💻 LocalExpress9.681s (+3.9%)10.113s (+2.4%)0.431s1213.61x
💻 LocalNitro9.892s (+6.6% 🔺)10.362s (+5.8% 🔺)0.470s1213.90x
💻 LocalNext.js (Turbopack)10.774s (-1.5%)11.482s (~)0.708s1115.14x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express6.916s (-63.4% 🟢)8.385s (-59.1% 🟢)1.469s151.00x
▲ VercelNitro7.160s (-62.8% 🟢)8.614s (-61.1% 🟢)1.454s151.04x
▲ VercelNext.js (Turbopack)7.650s (-65.4% 🟢)9.225s (-62.2% 🟢)1.575s141.11x

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

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.161s (-0.6%)2.005s (~)0.011s (-10.3% 🟢)2.018s (~)0.857s101.00x
🐘 PostgresExpress1.171s (-0.7%)2.002s (~)0.001s (-25.0% 🟢)2.011s (~)0.840s101.01x
🐘 PostgresNitro1.173s (~)1.998s (~)0.001s (~)2.010s (~)0.838s101.01x
💻 LocalNitro1.188s (+1.9%)2.005s (~)0.013s (+20.2% 🔺)2.020s (~)0.832s101.02x
💻 LocalNext.js (Turbopack)1.210s (~)2.004s (~)0.013s (+1.6%)2.021s (~)0.811s101.04x
🐘 PostgresNext.js (Turbopack)1.252s (+0.5%)2.002s (~)0.001s (-7.7% 🟢)2.011s (~)0.759s101.08x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.283s (+5.3% 🔺)3.012s (-6.0% 🟢)1.285s (+41.4% 🔺)4.735s (+3.4%)2.452s101.00x
▲ VercelNext.js (Turbopack)2.429s (+8.2% 🔺)3.474s (+7.5% 🔺)1.219s (+6.8% 🔺)5.101s (+4.5%)2.672s101.06x
▲ VercelNitro2.460s (+0.5%)3.395s (+1.0%)1.287s (+35.6% 🔺)5.115s (+7.3% 🔺)2.654s101.08x

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

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.574s (~)2.011s (~)0.013s (+11.9% 🔺)2.026s (~)0.452s301.00x
🐘 PostgresNitro1.591s (-1.3%)2.004s (~)0.005s (-0.7%)2.027s (~)0.435s301.01x
💻 LocalNitro1.593s (~)2.010s (~)0.012s (-6.7% 🟢)2.024s (~)0.430s301.01x
🐘 PostgresExpress1.658s (+3.6%)2.071s (+3.2%)0.004s (-20.1% 🟢)2.099s (+3.5%)0.440s291.05x
💻 LocalNext.js (Turbopack)1.743s (~)2.011s (~)0.012s (-1.4%)2.026s (~)0.284s301.11x
🐘 PostgresNext.js (Turbopack)1.822s (+2.0%)2.043s (+1.6%)0.005s (+3.3%)2.061s (+1.6%)0.239s301.16x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.706s (-3.6%)6.557s (-6.8% 🟢)0.248s (-47.2% 🟢)7.186s (-10.0% 🟢)1.480s91.00x
▲ VercelNext.js (Turbopack)5.845s (-9.3% 🟢)6.951s (-9.8% 🟢)0.332s (-40.8% 🟢)7.707s (-13.2% 🟢)1.862s81.02x
▲ VercelExpress5.862s (-3.5%)6.751s (-7.5% 🟢)0.441s (+67.7% 🔺)7.547s (-5.6% 🟢)1.685s81.03x

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

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.809s (+14.2% 🔺)1.081s (+3.6%)0.000s (-32.1% 🟢)1.096s (+3.1%)0.287s561.00x
🐘 PostgresExpress0.948s (+34.0% 🔺)1.295s (+27.7% 🔺)0.000s (+28.3% 🔺)1.319s (+28.2% 🔺)0.371s461.17x
🐘 PostgresNext.js (Turbopack)1.035s (+20.1% 🔺)1.539s (+41.1% 🔺)0.000s (-29.5% 🟢)1.547s (+40.8% 🔺)0.512s391.28x
💻 LocalNitro1.399s (-2.9%)2.013s (~)0.000s (+60.0% 🔺)2.015s (~)0.616s301.73x
💻 LocalExpress1.415s (+4.8%)2.013s (~)0.000s (+75.0% 🔺)2.015s (~)0.600s301.75x
💻 LocalNext.js (Turbopack)1.481s (+1.0%)2.012s (~)0.000s (-13.3% 🟢)2.016s (~)0.535s301.83x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.721s (-28.4% 🟢)3.796s (-21.4% 🟢)0.000s (NaN%)4.171s (-20.1% 🟢)1.450s151.00x
▲ VercelExpress2.801s (-16.6% 🟢)4.062s (-11.2% 🟢)0.000s (-100.0% 🟢)4.442s (-11.9% 🟢)1.641s141.03x
▲ VercelNext.js (Turbopack)2.963s (-19.9% 🟢)4.093s (-17.7% 🟢)0.000s (-100.0% 🟢)4.499s (-17.8% 🟢)1.536s141.09x

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

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

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.560s (+2.3%)2.216s (~)0.000s (-50.0% 🟢)2.245s (~)0.685s271.00x
🐘 PostgresNitro1.701s (+27.5% 🔺)2.217s (+9.4% 🔺)0.000s (+Infinity% 🔺)2.231s (+8.1% 🔺)0.530s271.09x
🐘 PostgresNext.js (Turbopack)2.076s (+20.0% 🔺)2.503s (+10.7% 🔺)0.000s (+Infinity% 🔺)2.510s (+10.5% 🔺)0.434s241.33x
💻 LocalNext.js (Turbopack)2.946s (+3.3%)3.614s (+5.8% 🔺)0.001s (+111.8% 🔺)3.617s (+5.8% 🔺)0.671s171.89x
💻 LocalNitro3.128s (-1.2%)3.779s (-1.5%)0.001s (+400.0% 🔺)3.781s (-1.6%)0.653s162.01x
💻 LocalExpress3.189s (+3.0%)3.841s (+4.6%)0.001s (+6.2% 🔺)3.844s (+4.5%)0.654s162.04x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro4.214s (-42.9% 🟢)5.507s (-30.6% 🟢)0.000s (+Infinity% 🔺)5.899s (-34.7% 🟢)1.685s111.00x
▲ VercelNext.js (Turbopack)4.747s (-46.5% 🟢)5.769s (-51.6% 🟢)0.000s (-95.0% 🟢)6.151s (-50.8% 🟢)1.404s101.13x
▲ VercelExpress4.778s (-10.2% 🟢)6.123s (-6.9% 🟢)0.000s (NaN%)6.547s (-9.0% 🟢)1.769s101.13x

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

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalExpress15/21
🐘 PostgresNitro11/21
▲ VercelExpress10/21
Fastest World by Framework

Winner determined by most benchmark wins

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

Comment threaddocs/content/docs/v5/foundations/hooks.mdx Outdated

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR replaces the hook conflict detection API from hook.hasConflict: Promise<boolean> to hook.getConflict: Promise<Run | null>, so a duplicate run can learn which run currently owns a conflicting hook token (and optionally act on it via the returned Run handle). It also updates the runtime, tests, e2e workflows, and v4/v5 docs to reflect the new API.

Changes:

  • Replace hasConflict plumbing with getConflict, including resolving to a conflicting Run handle and reusing the same instance across awaits.
  • Expose the workflow-bundle Run class via a new WORKFLOW_RUN_CLASS symbol so the hook consumer can construct durable Run handles inside workflow code.
  • Update workbench workflows, unit/e2e tests, docs (v4 + v5), and changesets for the renamed API.

Reviewed changes

Copilot reviewed 23 out of 23 changed files in this pull request and generated 5 comments.

Show a summary per file
FileDescription
workbench/nextjs-turbopack/app/workflows/definitions.tsRenames e2e workflow entries from hookHasConflict* to hookGetConflict*.
workbench/example/workflows/99_e2e.tsUpdates example workflows to use hook.getConflict and return conflict run metadata.
packages/core/src/workflow/hook.tsImplements getConflict resolution, constructs conflicting Run via WORKFLOW_RUN_CLASS.
packages/core/src/workflow/hook.test.tsUpdates unit tests to assert getConflict semantics and stable instance identity.
packages/core/src/workflow/create-hook.tsRegisters workflow-bundle Run class on globalThis[WORKFLOW_RUN_CLASS].
packages/core/src/workflow.test.tsUpdates workflow runner tests for suspension + getConflict behavior.
packages/core/src/symbols.tsAdds WORKFLOW_RUN_CLASS symbol definition and rationale.
packages/core/src/runtime/suspension-handler.tsUpdates comment/semantics references from hasConflict to getConflict.
packages/core/src/runtime/suspension-handler.test.tsUpdates tests and descriptions to the new getConflict naming.
packages/core/src/runtime.tsUpdates inline-execution gating comments to reference hook.getConflict.
packages/core/src/global.tsUpdates hook invocation queue item comment to reflect getConflict.
packages/core/src/create-hook.tsPublic Hook interface now exposes getConflict and updated docs/examples.
packages/core/e2e/e2e.test.tsRenames e2e tests and asserts conflict returns owner runId + durable status.
docs/content/docs/v5/how-it-works/event-sourcing.mdxUpdates hook conflict behavior description to reference getConflict.
docs/content/docs/v5/foundations/hooks.mdxUpdates conflict-checking guidance and examples to use getConflict.
docs/content/docs/v5/api-reference/workflow/create-webhook.mdxUpdates webhook return shape docs to getConflict.
docs/content/docs/v5/api-reference/workflow/create-hook.mdxUpdates API reference + examples from hasConflict to getConflict.
docs/content/docs/v4/how-it-works/event-sourcing.mdxUpdates v4 event-sourcing docs to reference getConflict.
docs/content/docs/v4/foundations/hooks.mdxUpdates v4 conflict-checking docs to getConflict ({ runId } guidance).
docs/content/docs/v4/api-reference/workflow/create-webhook.mdxUpdates v4 webhook docs to getConflict returning { runId }/null.
docs/content/docs/v4/api-reference/workflow/create-hook.mdxUpdates v4 hook API reference to getConflict returning { runId }/null.
.changeset/hook-has-conflict.mdRemoves changeset for the superseded hasConflict API.
.changeset/hook-get-conflict.mdAdds changeset for getConflict.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

// executes inside the VM, so `Run` here is the plugin-compiled variant
// whose methods are durable step proxies. The host-side consumer uses it
// to construct the conflicting run resolved by `hook.getConflict`.
(globalThis as any)[WORKFLOW_RUN_CLASS] ??= Run;

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in 919bde5: the registration is now guarded on the workflow runtime being present (WORKFLOW_CREATE_HOOK is installed on the VM globalThis before the bundle is evaluated), so importing this module outside the VM neither mutates the host global nor exposes the host-side Run (whose methods are not step proxies). Verified via the hookGetConflict e2e suite against a real bundle.

Comment threadpackages/core/src/create-hook.ts Outdated
Comment on lines +34 to +36
* Resolves with the conflicting {@link Run} if another active hook
* already owns this hook's token, or `null` once the hook has been
* registered and is ready to receive payloads.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Already addressed in dec869d (this review appears to have run against the initial commit): createConflictingRun() no longer falls back to a { runId }-shaped object. When a real Run cannot be constructed — including hook_conflict events without conflictingRunIdgetConflict() rejects with HookConflictError instead, and the JSDoc documents exactly that.

Comment threadpackages/core/src/create-hook.ts Outdated
* ```
*/
readonly hasConflict: Promise<boolean>;
readonly getConflict: Promise<Run<unknown> | null>;

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Already addressed in dec869d: the runtime now strictly honors Promise<Run<unknown> | null>. The { runId } fallback was removed — degenerate cases (legacy events without conflictingRunId, contexts without the bundle Run class) reject with HookConflictError rather than resolving with a value that lacks Run accessors, so the public type no longer needs widening.

```

Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.hasConflict` suspends the workflow to commit the hook registration, then resolves with `false` once the hook is registered and ready to receive payloads, or `true` if another active hook already owns the same token (see [`HookConflictError`](/docs/errors/hook-conflict)).
Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.getConflict` suspends the workflow to commit the hook registration, then resolves with `null` once the hook is registered and ready to receive payloads, or with a `Run` handle for the run that owns the token if another active hook already claimed it (see [`HookConflictError`](/docs/errors/hook-conflict)). The conflicting run's accessors are durable steps, so the workflow can inspect `await conflict.status`, wait on `await conflict.returnValue`, or cancel the owner with `await conflict.cancel()` — see [Run idempotency](/docs/foundations/idempotency#run-idempotency) for these strategies.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Clarified in 919bde5: the hooks docs (v4 + v5) now state that for hook_conflict events persisted by older worlds without the owning run ID, getConflict() rejects with HookConflictError instead of resolving with an incomplete handle — so when it does resolve with a conflict, the durable accessors are always available.

Comment thread.changeset/hook-get-conflict.md Outdated
"workflow": minor
---

Add `hook.getConflict`, a promise that suspends the workflow to commit hook registration and resolves with the conflicting `Run` when another active hook owns the token (or `null` once the hook is registered), without waiting for hook payload data.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

that doesn't matter because we didn't ship a new release with the previousl documented API so it's a fast follow and change

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Updated in 919bde5: the changeset now explicitly calls out that this replaces hook.hasConflict and includes the migration (await hook.hasConflictconst conflict = await hook.getConflict(), branch on conflict !== null).

getConflict's contract is Promise<Run | null>. In the degenerate cases
where a real Run cannot be constructed — a hook_conflict event persisted
by an old world without conflictingRunId, or a context that never loaded
the workflow-mode create-hook module — reject with HookConflictError
instead of resolving with a { runId }-shaped impostor.
Test harnesses now register the Run class on the (VM) globalThis like
real bundles do.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Request Changes — the feature is right, but the WORKFLOW_RUN_CLASS global duplicates infrastructure the serialization pipeline already provides

First: the API direction is a clear improvement. A Run handle beats the boolean (hasConflict told you that, not who), code-driven conflict strategy beats a fixed policy enum, and the dec869d9e decision to reject with HookConflictError rather than resolve a { runId }-shaped impostor keeps the Run contract honest. The determinism story checks out too: both fast-paths settle through promiseQueue, conflictRunRef is shared so repeated awaits observe one instance, replay reconstructs the handle from the same event-log conflictingRunId, and the bundle-compiled Run's accessors are step proxies (I verified the compiled output — Object.defineProperty(Run.prototype, ...) with __step_Run$... proxies), so await conflict.status / conflict.cancel() from workflow code are durable. 1156 unit tests pass locally; the red express E2E lane is failing errorWorkflowNested/errorWorkflowCrossFile (not hook tests) — baseline flake.

The change I'm requesting: use the class registry instead of a bespoke global

The (globalThis as any)[WORKFLOW_RUN_CLASS] ??= Run registration in workflow-mode create-hook.ts re-implements, ad hoc, something the serialization pipeline already does systematically. I built workbench/example from this branch and inspected the compiled flow bundle:

(function(__wf_cls,__wf_id){var__wf_sym=Symbol.for("workflow-class-registry"), ...
})(Run,"class//./packages/core/dist/runtime/run//Run");

The SWC plugin already registers the workflow-compiled Run in globalThis[WORKFLOW_CLASS_REGISTRY] at bundle evaluation — because Run carries WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE. This is the exact channel a start()-returned Run already uses to cross from host to VM today: the Instance reducer emits { classId, data: { runId, resilientStart } }, and getClassRevivers(global).Instance looks the class up via getSerializationClass(classId, global) against the VM's global and calls its WORKFLOW_DESERIALIZE.

So the class this PR smuggles through a new symbol is already sitting in the VM's registry when the hook consumer runs. The only missing piece is the lookup key — the plugin derives it from the module path (class//./packages/core/dist/runtime/run//Run in the workbench; different prefix under npm-installed layouts / other bundlers), so it can't be hardcoded.

Proposal (~15 lines total):

  1. Register Run under a stable, well-known classId alongside the plugin's path-derived one. registerSerializationClass can't be called twice (its non-configurable defineProperty(cls, 'classId') throws once the plugin IIFE has run), so add a tiny alias helper to class-serialization.ts:
/** Register an additional stable id for an already-registered class * (skips the classId defineProperty). */exportfunctionaliasSerializationClass(classId: string,cls: Function){getRegistry().set(classId,cls);}
  1. Call it at module scope of runtime/run.ts (e.g. aliasSerializationClass('class//workflow//Run', Run)). Note what this buys over the symbol approach: per-context discipline for free. Evaluated inside the VM, it registers the VM's step-proxy Run on the VM's registry; evaluated on the host, it registers the host Run on the host's registry. Each global gets its own correct class — which means the if (globalThis[WORKFLOW_CREATE_HOOK]) guard and the ??= (both of which exist purely to keep the host Run from leaking into the wrong global) simply disappear, along with the module-scope conditional in create-hook.ts and the new symbol in symbols.ts.

  2. The consumer constructs through the pipeline's own hook instead of new RunClass(id):

functioncreateConflictingRun(ctx,conflictingRunId){if(typeofconflictingRunId!=='string')returnnull;constRunClass=getSerializationClass(RUN_STABLE_CLASS_ID,ctx.globalThis);if(!RunClass)returnnull;return(RunClassasany)[WORKFLOW_DESERIALIZE]({runId: conflictingRunId});}

Same degenerate-case semantics you already have (registry miss → null → awaiters reject with HookConflictError), but the construction takes the identical code path a serialized Run instance takes everywhere else. class-serialization.ts is deliberately dependency-light ("separate from private.ts to avoid pulling in Node.js-only dependencies"), so importing it from workflow/hook.ts is safe.

Side benefit worth banking: a stable classId hardens Run serialization generally. Today a Run persisted in the event log (e.g. a start()-in-workflow handle in a step result) embeds the path-derived id, which is only safe because version-skew protection pins replay to the same deployment whose bundles agree on the path. A stable alias removes that coupling — any future cross-deployment replay (or a bundler change mid-release) stops being able to break Run hydration with "Class not found".

I considered the fuller "interact with the serialization pipeline" variant — making getConflict() resolution a step so the standard step-return serde does everything — and I think your current design is better: piggybacking on hook_created/hook_conflict costs zero extra events and zero queue hops, where a step-based resolution would add one of each per call. The registry lookup keeps that property while deleting the bespoke channel.

Smaller notes

  • The v4 docs describe a { runId } return that no released 4.x SDK has (and getConflict itself isn't on stable). Same sequencing flag as #2015/#2011 — the v4 pages keep widening the documented-but-unreleased surface; fine if the backport chain is imminent, worth holding otherwise.
  • e56e6fbc25b3b88b0b's getter→method change (hook.getConflict()) is the right call — a property that allocates and enqueues new promise resolvers on every access was the same trap hasConflict had; a method at least signals effects.
  • Changeset correctly replaces the hasConflict one and is scoped to @workflow/core + workflow (minor) — and since hasConflict shipped in no stable release and at most one beta, replacing rather than deprecating is fine.

Happy to re-review quickly once the registry switch is in — everything else here is ready.

… registry
Replace the bespoke WORKFLOW_RUN_CLASS global with the registry the
serialization pipeline already uses to revive Run instances:
- The SWC plugin already auto-registers the workflow bundle's compiled
Run in globalThis[workflow-class-registry], but under a path-derived
classId the host cannot know statically. The workflow-mode create-hook
module now aliases it under a stable id (class//workflow//Run) via a
new aliasSerializationClass() helper (a plain registry entry —
registerSerializationClass cannot be reused since the plugin's IIFE
already defined the non-configurable classId property).
- createConflictingRun() looks the class up with
getSerializationClass(RUN_CLASS_ID, ctx.globalThis) and constructs
through its WORKFLOW_DESERIALIZE hook, exactly as the Instance reviver
would for a serialized Run crossing from a step into the workflow.
- Because the registry is keyed per-global, no environment guard is
needed: a stray host-side import registers the host Run on the host
registry, which is the correct class for that context. The
WORKFLOW_CREATE_HOOK guard, the ??=, and the WORKFLOW_RUN_CLASS symbol
are all deleted.
Verified: 1156 core unit tests; compiled workbench bundle contains the
stable alias alongside the plugin's path-derived registration with zero
WORKFLOW_RUN_CLASS references; all 5 hookGetConflict e2e tests pass
against a local nextjs-turbopack dev server, including conflict
resolution reading conflict.status through a durable step.

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approve — the registry switch from my previous review is now implemented (0aa3cb0)

I implemented the change I requested (after discussing with Nathan), so this approval covers Pranay's feature plus that refactor — Pranay, please give 0aa3cb0 a once-over since it rewrites the class-handoff mechanism you authored.

What changed:

  • WORKFLOW_RUN_CLASS is gone (symbol, the ??= registration, and the WORKFLOW_CREATE_HOOK guard). The workflow-mode create-hook module now aliases the bundle's Run into the existing serialization class registry under a stable id (class//workflow//Run) via a new aliasSerializationClass() helper — a plain registry entry, since registerSerializationClass's non-configurable defineProperty(classId) would throw on a class the SWC plugin already registered.
  • createConflictingRun resolves through the pipeline: getSerializationClass(RUN_CLASS_ID, ctx.globalThis) + construction via the class's WORKFLOW_DESERIALIZE hook — the identical path the Instance reviver takes for a serialized Run crossing from a step into the workflow. Degenerate-case semantics unchanged: registry miss or missing deserialize hook → null → awaiters reject with HookConflictError.
  • No environment guard needed: the registry is per-global by construction, so a stray host-side import of the module registers the host Run on the host registry — correct for that context.

Verification:

  • 1156 core unit tests pass; hook.test.ts / workflow.test.ts mocks updated to register through the registry (the VM-string test now mirrors the real bundle shape, including the WORKFLOW_DESERIALIZE hook)
  • Rebuilt workbench/example: compiled flow bundle contains the stable alias alongside the plugin's path-derived registration, with zero WORKFLOW_RUN_CLASS references
  • All 5 hookGetConflict* e2e tests pass against a local nextjs-turbopack dev server, including the conflict test that resolves the actual owner and reads await conflict.status through a durable step — the full host → VM-registry → WORKFLOW_DESERIALIZE → step-proxy chain
  • The two biome complexity findings in hook.ts are pre-existing on this branch (identical count before/after)

Remaining notes from my previous review stand but don't block: the v4 docs describe a surface not yet on stable (recurring sequencing flag), and the stable classId incidentally hardens Run event-log serialization against future cross-deployment replay.

@github-actions

Copy link
Copy Markdown
Contributor

Backport PR opened against stable: #2382. Merge conflicts were resolved by AI — please review carefully. (backport job run)

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

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

Replace hook.hasConflict with hook.getConflict() returning the conflicting Run - #2373

Merged
TooTallNate merged 5 commits into
mainfrom
pranaygp/hook-get-conflict
Jun 12, 2026
Merged

Replace hook.hasConflict with hook.getConflict() returning the conflicting Run#2373
TooTallNate merged 5 commits into
mainfrom
pranaygp/hook-get-conflict

Conversation

@pranaygp

@pranaygppranaygp commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #2015. hook.hasConflict's boolean told the workflow that a conflict exists but not which run owns the token, so the duplicate run couldn't act on it. This replaces it with hook.getConflict(), a promise that resolves with:

  • null once the hook registration is committed (hook_created recorded), or
  • a Run handle for the conflicting run when another active hook owns the token.

Like hasConflict, awaiting getConflict() suspends the workflow to commit the hook registration without waiting for payload data. But on conflict, the workflow now has the active owner in hand and can choose its own idempotency strategy in code:

using hook=createHook({token: `order:${orderId}`});constconflict=awaithook.getConflict();if(conflict){// any of:return{dedupedTo: conflict.runId};// route caller to the ownerconststatus=awaitconflict.status;// inspect before decidingreturnawaitconflict.returnValue;// adopt the owner's resultawaitconflict.cancel();// supersede the owner and continue}

This is deliberately code-driven rather than a fixed set of ID-reuse policies (cf. Temporal's WorkflowIdReusePolicy / WorkflowIdConflictPolicy).

Implementation

  • The workflow-mode create-hook module exposes the bundle's compiled Run class on a well-known global symbol (WORKFLOW_RUN_CLASS). The bundle's Run is the SWC-plugin-compiled variant whose accessors (status, returnValue, cancel(), …) are durable step proxies — safe and deterministic inside workflow code. The host-side hook event consumer constructs the conflicting Run from the hook_conflict event's conflictingRunId (Expose conflicting run id on hook conflicts #2012) using that class.
  • getConflict never resolves with anything but a real Run or null. In the degenerate cases where a Run cannot be constructed — a hook_conflict event persisted by an old world without conflictingRunId, or a context that never loaded the workflow-mode create-hook module — it rejects with HookConflictError (which still carries the token) instead of resolving with a { runId }-shaped impostor. Note: the { runId } shape remains the documented v4 return value, since v4 has no native Run serialization — v4 docs direct users to getRun(conflict.runId) inside a step.
  • Repeated await hook.getConflict() calls observe the same Run instance; both fast-paths settle through promiseQueue to keep resolution order aligned with the event log.
  • Awaiting the hook payload on a conflicted token still rejects with HookConflictError.

Docs

Updated createHook() / createWebhook() references, hooks foundations, and event-sourcing pages (v4 + v5; v5 documents the Run return, v4 documents { runId }). A follow-up to #2011 adds the full conflict-handling strategy guide to the run-idempotency docs.

Validation

  • cd packages/core && pnpm typecheck && pnpm vitest run src/ (1155 tests)
  • pnpm test:docs
  • Hook e2e suite against local nextjs-turbopack: all hookGetConflict* tests pass, including the conflict test asserting the duplicate run resolves the actual owner's runId and reads await conflict.status === 'running' via a durable step.

…ull>)
hasConflict's boolean didn't expose WHICH run owns the token, so the
duplicate run couldn't act on the conflict. getConflict resolves with
null once registration commits, or with a Run handle for the conflicting
run — letting the workflow return/log the owner's runId, inspect its
status, await its result, or cancel it and continue, all in code.
The workflow-mode create-hook module exposes the bundle's compiled Run
class (durable step-proxy methods) on a well-known symbol so the host-
side hook consumer can construct the conflicting run inside the VM.
Contexts without the class (plain unit tests) fall back to a { runId }
object, which is also the documented v4 shape (no native Run
serialization in v4).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings June 11, 2026 23:11
@pranaygp
pranaygp requested a review from a team as a code ownerJune 11, 2026 23:11
@changeset-bot

changeset-botBot commented Jun 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0aa3cb0

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

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

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production137602191595
✅ 💻 Local Development181102192030
✅ 📦 Local Production181102192030
✅ 🐘 Local Postgres179702332030
✅ 🪟 Windows14500145
✅ 📋 Other83701781015
Total7777010688845

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro119026
✅ example119026
✅ express119026
✅ fastify119026
✅ hono119026
✅ nextjs-turbopack14302
✅ nextjs-webpack14302
✅ nitro119026
✅ nuxt119026
✅ sveltekit13807
✅ vite119026
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable120025
✅ express-stable120025
✅ fastify-stable120025
✅ hono-stable120025
✅ nextjs-turbopack-canary126019
✅ nextjs-turbopack-stable-lazy-discovery-disabled14500
✅ nextjs-turbopack-stable-lazy-discovery-enabled14500
✅ nextjs-webpack-canary126019
✅ nextjs-webpack-stable-lazy-discovery-disabled14500
✅ nextjs-webpack-stable-lazy-discovery-enabled14500
✅ nitro-stable120025
✅ nuxt-stable120025
✅ sveltekit-stable13906
✅ vite-stable120025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable120025
✅ express-stable120025
✅ fastify-stable120025
✅ hono-stable120025
✅ nextjs-turbopack-canary126019
✅ nextjs-turbopack-stable-lazy-discovery-disabled14500
✅ nextjs-turbopack-stable-lazy-discovery-enabled14500
✅ nextjs-webpack-canary126019
✅ nextjs-webpack-stable-lazy-discovery-disabled14500
✅ nextjs-webpack-stable-lazy-discovery-enabled14500
✅ nitro-stable120025
✅ nuxt-stable120025
✅ sveltekit-stable13906
✅ vite-stable120025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable119026
✅ express-stable119026
✅ fastify-stable119026
✅ hono-stable119026
✅ nextjs-turbopack-canary125020
✅ nextjs-turbopack-stable-lazy-discovery-disabled14401
✅ nextjs-turbopack-stable-lazy-discovery-enabled14401
✅ nextjs-webpack-canary125020
✅ nextjs-webpack-stable-lazy-discovery-disabled14401
✅ nextjs-webpack-stable-lazy-discovery-enabled14401
✅ nitro-stable119026
✅ nuxt-stable119026
✅ sveltekit-stable13807
✅ vite-stable119026
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack14500
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable120025
✅ e2e-local-dev-tanstack-start-120025
✅ e2e-local-postgres-nest-stable119026
✅ e2e-local-postgres-tanstack-start-119026
✅ e2e-local-prod-nest-stable120025
✅ e2e-local-prod-tanstack-start-120025
✅ e2e-vercel-prod-tanstack-start119026

📋 View full workflow run

@github-actions

github-actionsBot commented Jun 11, 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.042s (-1.9%)1.006s (~)0.965s101.00x
💻 LocalNitro0.046s (+7.8% 🔺)1.006s (~)0.960s101.10x
🐘 PostgresNitro0.065s (-1.5%)1.013s (~)0.948s101.56x
💻 LocalNext.js (Turbopack)0.066s (+5.2% 🔺)1.006s (~)0.940s101.59x
🐘 PostgresNext.js (Turbopack)0.072s (-4.3%)1.012s (~)0.941s101.72x
🐘 PostgresExpress0.080s (+30.0% 🔺)1.032s (+2.0%)0.952s101.92x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.244s (-35.3% 🟢)2.122s (-12.6% 🟢)1.878s101.00x
▲ VercelNitro0.260s (-31.1% 🟢)2.099s (~)1.839s101.07x
▲ VercelExpress0.268s (-1.1%)2.117s (-18.0% 🟢)1.850s101.10x

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

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.089s (-2.4%)2.011s (~)0.922s101.00x
💻 LocalExpress1.096s (~)2.007s (~)0.911s101.01x
💻 LocalNitro1.101s (+0.9%)2.006s (~)0.905s101.01x
🐘 PostgresNitro1.108s (~)2.009s (~)0.900s101.02x
💻 LocalNext.js (Turbopack)1.139s (+1.0%)2.007s (~)0.867s101.05x
🐘 PostgresNext.js (Turbopack)1.185s (+3.7%)2.011s (~)0.826s101.09x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express1.616s (-7.8% 🟢)3.302s (-11.0% 🟢)1.686s101.00x
▲ VercelNitro1.696s (+1.2%)3.485s (+1.0%)1.790s101.05x
▲ VercelNext.js (Turbopack)1.769s (+5.4% 🔺)3.307s (-9.1% 🟢)1.539s101.09x

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

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express10.496s (-0.9%)11.031s (~)0.535s31.00x
💻 LocalExpress10.500s (~)11.021s (~)0.521s31.00x
💻 LocalNitro10.535s (~)11.023s (~)0.488s31.00x
🐘 PostgresNitro10.568s (~)11.017s (~)0.450s31.01x
💻 LocalNext.js (Turbopack)10.794s (~)11.023s (~)0.229s31.03x
🐘 PostgresNext.js (Turbopack)10.842s (-1.6%)11.017s (-5.7% 🟢)0.175s31.03x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro13.610s (+2.0%)15.211s (~)1.601s21.00x
▲ VercelExpress14.017s (+4.5%)15.119s (-3.9%)1.102s21.03x
▲ VercelNext.js (Turbopack)14.111s (~)15.512s (-4.1%)1.401s21.04x

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

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express13.731s (-0.8%)14.028s (~)0.297s51.00x
💻 LocalNitro13.787s (~)14.027s (-1.4%)0.240s51.00x
🐘 PostgresExpress13.956s (+0.9%)14.418s (+2.8%)0.462s51.02x
🐘 PostgresNitro13.986s (+1.2%)14.220s (+1.4%)0.234s51.02x
💻 LocalNext.js (Turbopack)14.348s (-1.0%)15.030s (~)0.681s41.05x
🐘 PostgresNext.js (Turbopack)14.458s (~)15.020s (~)0.562s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro20.475s (-8.7% 🟢)22.157s (-8.0% 🟢)1.681s31.00x
▲ VercelExpress20.531s (-4.4%)21.964s (-4.8%)1.434s31.00x
▲ VercelNext.js (Turbopack)21.986s (+1.8%)23.788s (+1.9%)1.801s31.07x

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

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express12.391s (-0.9%)13.024s (~)0.633s71.00x
🐘 PostgresExpress12.417s (-2.6%)13.017s (~)0.600s71.00x
💻 LocalNitro12.529s (~)13.024s (~)0.495s71.01x
🐘 PostgresNitro12.730s (+1.2%)13.162s (+1.1%)0.433s71.03x
💻 LocalNext.js (Turbopack)13.654s (~)14.026s (~)0.372s71.10x
🐘 PostgresNext.js (Turbopack)14.084s (+0.8%)14.450s (+1.0%)0.366s71.14x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro29.382s (-7.3% 🟢)31.701s (-4.8%)2.319s31.00x
▲ VercelExpress29.422s (-0.8%)31.369s (+0.7%)1.947s31.00x
▲ VercelNext.js (Turbopack)30.219s (+5.4% 🔺)32.193s (+6.0% 🔺)1.973s31.03x

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

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.204s (~)2.015s (~)0.811s151.00x
🐘 PostgresNitro1.217s (+1.1%)2.008s (~)0.791s151.01x
💻 LocalExpress1.228s (+3.2%)2.006s (~)0.778s151.02x
💻 LocalNitro1.250s (+2.2%)2.006s (~)0.756s151.04x
💻 LocalNext.js (Turbopack)1.275s (-5.4% 🟢)2.006s (~)0.731s151.06x
🐘 PostgresNext.js (Turbopack)1.294s (+2.8%)2.008s (~)0.714s151.07x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.316s (-37.4% 🟢)3.726s (-28.1% 🟢)1.410s91.00x
▲ VercelExpress2.794s (+9.5% 🔺)4.184s (-7.0% 🟢)1.391s81.21x
▲ VercelNext.js (Turbopack)3.004s (-1.3%)4.488s (-2.3%)1.484s71.30x

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

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.367s (+8.6% 🔺)2.508s (+25.0% 🔺)1.141s121.00x
🐘 PostgresExpress1.422s (+12.6% 🔺)2.172s (+8.2% 🔺)0.750s141.04x
💻 LocalExpress1.755s (-4.7%)2.006s (-6.7% 🟢)0.251s151.28x
💻 LocalNext.js (Turbopack)1.834s (-2.4%)2.008s (-6.6% 🟢)0.174s151.34x
💻 LocalNitro1.853s (+8.9% 🔺)2.151s (+7.2% 🔺)0.298s141.36x
🐘 PostgresNext.js (Turbopack)1.855s (+32.4% 🔺)2.396s (+19.4% 🔺)0.541s131.36x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.168s (-28.3% 🟢)4.565s (-25.4% 🟢)1.397s81.00x
▲ VercelNext.js (Turbopack)3.520s (-98.9% 🟢)5.051s (-98.4% 🟢)1.531s61.11x
▲ VercelNitro4.477s (-28.2% 🟢)6.029s (-22.2% 🟢)1.552s51.41x

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

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.578s (+12.1% 🔺)3.762s (+87.3% 🔺)2.184s81.00x
🐘 PostgresExpress1.758s (+25.4% 🔺)3.577s (+78.1% 🔺)1.819s91.11x
🐘 PostgresNext.js (Turbopack)3.622s (+100.8% 🔺)4.583s (+99.5% 🔺)0.962s72.30x
💻 LocalExpress4.738s (-12.0% 🟢)5.514s (-10.8% 🟢)0.775s63.00x
💻 LocalNitro5.252s (~)5.846s (-2.8%)0.595s63.33x
💻 LocalNext.js (Turbopack)5.763s (+1.0%)6.216s (~)0.453s53.65x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro4.006s (-76.4% 🟢)5.907s (-67.7% 🟢)1.902s61.00x
▲ VercelExpress4.380s (-52.0% 🟢)6.113s (-46.2% 🟢)1.733s61.09x
▲ VercelNext.js (Turbopack)6.477s (+7.7% 🔺)7.965s (-5.6% 🟢)1.488s41.62x

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

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.212s (+1.8%)2.007s (~)0.794s151.00x
🐘 PostgresExpress1.254s (+3.5%)2.027s (+1.0%)0.773s151.03x
🐘 PostgresNext.js (Turbopack)1.295s (+2.5%)2.007s (~)0.712s151.07x
💻 LocalNext.js (Turbopack)1.377s (+1.9%)2.006s (~)0.629s151.14x
💻 LocalExpress1.560s (-5.9% 🟢)2.006s (-3.3%)0.446s151.29x
💻 LocalNitro1.603s (+6.3% 🔺)2.007s (~)0.404s151.32x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.267s (-12.1% 🟢)3.589s (-15.3% 🟢)1.323s91.00x
▲ VercelExpress2.338s (-13.4% 🟢)3.777s (-19.9% 🟢)1.439s81.03x
▲ VercelNext.js (Turbopack)2.688s (-34.5% 🟢)4.300s (-23.7% 🟢)1.613s71.19x

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

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.374s (+6.0% 🔺)2.149s (+6.9% 🔺)0.775s151.00x
🐘 PostgresNitro1.378s (+8.4% 🔺)2.223s (+10.7% 🔺)0.845s141.00x
🐘 PostgresNext.js (Turbopack)1.535s (+9.3% 🔺)2.296s (+14.3% 🔺)0.761s141.12x
💻 LocalNext.js (Turbopack)2.019s (-1.3%)2.591s (-13.9% 🟢)0.572s121.47x
💻 LocalNitro2.118s (-2.4%)2.675s (+3.2%)0.557s121.54x
💻 LocalExpress2.183s (+1.1%)2.735s (+2.2%)0.552s111.59x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.708s (-21.0% 🟢)4.034s (-20.9% 🟢)1.327s81.00x
▲ VercelNext.js (Turbopack)2.904s (-20.4% 🟢)4.585s (-12.1% 🟢)1.681s71.07x
▲ VercelNitro3.259s (-32.5% 🟢)4.713s (-26.4% 🟢)1.455s71.20x

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

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.633s (+17.6% 🔺)4.015s (+99.9% 🔺)2.383s81.00x
🐘 PostgresExpress1.778s (+26.9% 🔺)3.764s (+87.2% 🔺)1.985s81.09x
🐘 PostgresNext.js (Turbopack)3.795s (+109.9% 🔺)4.588s (+106.3% 🔺)0.793s72.32x
💻 LocalExpress4.915s (-22.7% 🟢)5.515s (-21.4% 🟢)0.600s63.01x
💻 LocalNext.js (Turbopack)5.698s (-5.9% 🟢)6.217s (-8.8% 🟢)0.518s53.49x
💻 LocalNitro6.163s (+13.4% 🔺)6.417s (+6.7% 🔺)0.254s53.77x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.482s (-31.8% 🟢)5.220s (-27.9% 🟢)1.738s61.00x
▲ VercelNitro3.723s (-46.8% 🟢)5.600s (-34.9% 🟢)1.877s61.07x
▲ VercelNext.js (Turbopack)4.865s (-39.4% 🟢)6.332s (-35.3% 🟢)1.467s51.40x

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

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.594s (-3.0%)1.005s (~)0.411s601.00x
💻 LocalNitro0.599s (~)1.005s (~)0.406s601.01x
🐘 PostgresNitro0.609s (+5.1% 🔺)1.040s (+3.4%)0.432s581.03x
🐘 PostgresExpress0.671s (+11.7% 🔺)1.147s (+13.9% 🔺)0.476s531.13x
🐘 PostgresNext.js (Turbopack)0.849s (-0.6%)1.006s (-1.7%)0.157s601.43x
💻 LocalNext.js (Turbopack)0.912s (+5.7% 🔺)1.075s (+5.2% 🔺)0.163s571.54x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.786s (+16.6% 🔺)7.078s (+8.6% 🔺)1.292s91.00x
▲ VercelNext.js (Turbopack)5.799s (+11.1% 🔺)7.275s (+6.0% 🔺)1.476s91.00x
▲ VercelExpress6.123s (-39.6% 🟢)7.604s (-35.4% 🟢)1.481s81.06x

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

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.390s (-3.2%)2.029s (~)0.639s451.00x
🐘 PostgresExpress1.504s (+4.9%)2.110s (+5.0% 🔺)0.605s431.08x
💻 LocalExpress1.510s (-2.8%)2.006s (-1.1%)0.496s451.09x
💻 LocalNitro1.528s (+1.5%)2.006s (~)0.479s451.10x
🐘 PostgresNext.js (Turbopack)1.999s (-1.5%)2.308s (-13.1% 🟢)0.309s401.44x
💻 LocalNext.js (Turbopack)2.117s (+0.8%)3.008s (~)0.892s301.52x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express14.919s (+3.8%)16.513s (+2.2%)1.594s61.00x
▲ VercelNitro15.432s (+17.8% 🔺)17.056s (+17.9% 🔺)1.624s61.03x
▲ VercelNext.js (Turbopack)15.850s (+19.2% 🔺)17.815s (+18.0% 🔺)1.965s61.06x

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

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.755s (-1.5%)3.085s (-2.6%)0.330s391.00x
🐘 PostgresExpress2.768s (-1.5%)3.311s (+5.5% 🔺)0.543s371.00x
💻 LocalExpress3.336s (+1.5%)4.042s (+0.8%)0.707s301.21x
💻 LocalNitro3.340s (+1.3%)4.010s (~)0.670s301.21x
🐘 PostgresNext.js (Turbopack)3.913s (-1.8%)4.110s (-5.1% 🟢)0.197s301.42x
💻 LocalNext.js (Turbopack)4.360s (~)5.011s (~)0.650s241.58x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express27.778s (+5.9% 🔺)30.392s (+7.7% 🔺)2.614s51.00x
▲ VercelNitro27.783s (-4.6%)30.056s (-2.4%)2.274s41.00x
▲ VercelNext.js (Turbopack)29.676s (-16.9% 🟢)31.852s (-15.0% 🟢)2.176s41.07x

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

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.252s (+14.8% 🔺)1.006s (~)0.754s601.00x
🐘 PostgresExpress0.264s (+13.3% 🔺)1.009s (~)0.745s601.05x
🐘 PostgresNext.js (Turbopack)0.319s (+15.0% 🔺)1.006s (~)0.687s601.27x
💻 LocalNitro0.417s (-3.0%)1.005s (-1.7%)0.588s601.66x
💻 LocalExpress0.453s (+9.7% 🔺)1.021s (+1.6%)0.568s591.80x
💻 LocalNext.js (Turbopack)0.553s (+1.3%)1.005s (~)0.452s602.20x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.055s (-20.0% 🟢)3.438s (-16.3% 🟢)1.383s181.00x
▲ VercelNitro2.234s (-20.9% 🟢)3.836s (-11.1% 🟢)1.602s161.09x
▲ VercelNext.js (Turbopack)2.376s (-46.7% 🟢)3.893s (-37.5% 🟢)1.517s161.16x

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

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.393s (+5.1% 🔺)1.036s (+3.0%)0.643s871.00x
🐘 PostgresNitro0.421s (+17.3% 🔺)1.029s (+2.2%)0.608s881.07x
🐘 PostgresNext.js (Turbopack)0.619s (+24.1% 🔺)1.133s (+12.5% 🔺)0.513s801.57x
💻 LocalNitro2.207s (+1.2%)2.715s (~)0.508s345.61x
💻 LocalExpress2.208s (+8.4% 🔺)2.737s (+7.9% 🔺)0.529s335.61x
💻 LocalNext.js (Turbopack)2.513s (+2.6%)3.225s (+2.5%)0.712s286.39x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.890s (-46.6% 🟢)4.303s (-39.2% 🟢)1.414s211.00x
▲ VercelNitro3.175s (-65.7% 🟢)4.598s (-57.7% 🟢)1.424s211.10x
▲ VercelNext.js (Turbopack)3.728s (-51.6% 🟢)5.309s (-43.4% 🟢)1.581s181.29x

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

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.711s (+2.2%)1.190s (+18.3% 🔺)0.479s1011.00x
🐘 PostgresNitro0.808s (+18.6% 🔺)1.340s (+33.2% 🔺)0.532s901.14x
🐘 PostgresNext.js (Turbopack)3.015s (+199.9% 🔺)3.798s (+104.8% 🔺)0.782s324.24x
💻 LocalExpress9.681s (+3.9%)10.113s (+2.4%)0.431s1213.61x
💻 LocalNitro9.892s (+6.6% 🔺)10.362s (+5.8% 🔺)0.470s1213.90x
💻 LocalNext.js (Turbopack)10.774s (-1.5%)11.482s (~)0.708s1115.14x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express6.916s (-63.4% 🟢)8.385s (-59.1% 🟢)1.469s151.00x
▲ VercelNitro7.160s (-62.8% 🟢)8.614s (-61.1% 🟢)1.454s151.04x
▲ VercelNext.js (Turbopack)7.650s (-65.4% 🟢)9.225s (-62.2% 🟢)1.575s141.11x

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

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.161s (-0.6%)2.005s (~)0.011s (-10.3% 🟢)2.018s (~)0.857s101.00x
🐘 PostgresExpress1.171s (-0.7%)2.002s (~)0.001s (-25.0% 🟢)2.011s (~)0.840s101.01x
🐘 PostgresNitro1.173s (~)1.998s (~)0.001s (~)2.010s (~)0.838s101.01x
💻 LocalNitro1.188s (+1.9%)2.005s (~)0.013s (+20.2% 🔺)2.020s (~)0.832s101.02x
💻 LocalNext.js (Turbopack)1.210s (~)2.004s (~)0.013s (+1.6%)2.021s (~)0.811s101.04x
🐘 PostgresNext.js (Turbopack)1.252s (+0.5%)2.002s (~)0.001s (-7.7% 🟢)2.011s (~)0.759s101.08x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.283s (+5.3% 🔺)3.012s (-6.0% 🟢)1.285s (+41.4% 🔺)4.735s (+3.4%)2.452s101.00x
▲ VercelNext.js (Turbopack)2.429s (+8.2% 🔺)3.474s (+7.5% 🔺)1.219s (+6.8% 🔺)5.101s (+4.5%)2.672s101.06x
▲ VercelNitro2.460s (+0.5%)3.395s (+1.0%)1.287s (+35.6% 🔺)5.115s (+7.3% 🔺)2.654s101.08x

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

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.574s (~)2.011s (~)0.013s (+11.9% 🔺)2.026s (~)0.452s301.00x
🐘 PostgresNitro1.591s (-1.3%)2.004s (~)0.005s (-0.7%)2.027s (~)0.435s301.01x
💻 LocalNitro1.593s (~)2.010s (~)0.012s (-6.7% 🟢)2.024s (~)0.430s301.01x
🐘 PostgresExpress1.658s (+3.6%)2.071s (+3.2%)0.004s (-20.1% 🟢)2.099s (+3.5%)0.440s291.05x
💻 LocalNext.js (Turbopack)1.743s (~)2.011s (~)0.012s (-1.4%)2.026s (~)0.284s301.11x
🐘 PostgresNext.js (Turbopack)1.822s (+2.0%)2.043s (+1.6%)0.005s (+3.3%)2.061s (+1.6%)0.239s301.16x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.706s (-3.6%)6.557s (-6.8% 🟢)0.248s (-47.2% 🟢)7.186s (-10.0% 🟢)1.480s91.00x
▲ VercelNext.js (Turbopack)5.845s (-9.3% 🟢)6.951s (-9.8% 🟢)0.332s (-40.8% 🟢)7.707s (-13.2% 🟢)1.862s81.02x
▲ VercelExpress5.862s (-3.5%)6.751s (-7.5% 🟢)0.441s (+67.7% 🔺)7.547s (-5.6% 🟢)1.685s81.03x

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

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.809s (+14.2% 🔺)1.081s (+3.6%)0.000s (-32.1% 🟢)1.096s (+3.1%)0.287s561.00x
🐘 PostgresExpress0.948s (+34.0% 🔺)1.295s (+27.7% 🔺)0.000s (+28.3% 🔺)1.319s (+28.2% 🔺)0.371s461.17x
🐘 PostgresNext.js (Turbopack)1.035s (+20.1% 🔺)1.539s (+41.1% 🔺)0.000s (-29.5% 🟢)1.547s (+40.8% 🔺)0.512s391.28x
💻 LocalNitro1.399s (-2.9%)2.013s (~)0.000s (+60.0% 🔺)2.015s (~)0.616s301.73x
💻 LocalExpress1.415s (+4.8%)2.013s (~)0.000s (+75.0% 🔺)2.015s (~)0.600s301.75x
💻 LocalNext.js (Turbopack)1.481s (+1.0%)2.012s (~)0.000s (-13.3% 🟢)2.016s (~)0.535s301.83x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.721s (-28.4% 🟢)3.796s (-21.4% 🟢)0.000s (NaN%)4.171s (-20.1% 🟢)1.450s151.00x
▲ VercelExpress2.801s (-16.6% 🟢)4.062s (-11.2% 🟢)0.000s (-100.0% 🟢)4.442s (-11.9% 🟢)1.641s141.03x
▲ VercelNext.js (Turbopack)2.963s (-19.9% 🟢)4.093s (-17.7% 🟢)0.000s (-100.0% 🟢)4.499s (-17.8% 🟢)1.536s141.09x

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

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

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.560s (+2.3%)2.216s (~)0.000s (-50.0% 🟢)2.245s (~)0.685s271.00x
🐘 PostgresNitro1.701s (+27.5% 🔺)2.217s (+9.4% 🔺)0.000s (+Infinity% 🔺)2.231s (+8.1% 🔺)0.530s271.09x
🐘 PostgresNext.js (Turbopack)2.076s (+20.0% 🔺)2.503s (+10.7% 🔺)0.000s (+Infinity% 🔺)2.510s (+10.5% 🔺)0.434s241.33x
💻 LocalNext.js (Turbopack)2.946s (+3.3%)3.614s (+5.8% 🔺)0.001s (+111.8% 🔺)3.617s (+5.8% 🔺)0.671s171.89x
💻 LocalNitro3.128s (-1.2%)3.779s (-1.5%)0.001s (+400.0% 🔺)3.781s (-1.6%)0.653s162.01x
💻 LocalExpress3.189s (+3.0%)3.841s (+4.6%)0.001s (+6.2% 🔺)3.844s (+4.5%)0.654s162.04x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro4.214s (-42.9% 🟢)5.507s (-30.6% 🟢)0.000s (+Infinity% 🔺)5.899s (-34.7% 🟢)1.685s111.00x
▲ VercelNext.js (Turbopack)4.747s (-46.5% 🟢)5.769s (-51.6% 🟢)0.000s (-95.0% 🟢)6.151s (-50.8% 🟢)1.404s101.13x
▲ VercelExpress4.778s (-10.2% 🟢)6.123s (-6.9% 🟢)0.000s (NaN%)6.547s (-9.0% 🟢)1.769s101.13x

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

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalExpress15/21
🐘 PostgresNitro11/21
▲ VercelExpress10/21
Fastest World by Framework

Winner determined by most benchmark wins

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

Comment threaddocs/content/docs/v5/foundations/hooks.mdx Outdated

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR replaces the hook conflict detection API from hook.hasConflict: Promise<boolean> to hook.getConflict: Promise<Run | null>, so a duplicate run can learn which run currently owns a conflicting hook token (and optionally act on it via the returned Run handle). It also updates the runtime, tests, e2e workflows, and v4/v5 docs to reflect the new API.

Changes:

  • Replace hasConflict plumbing with getConflict, including resolving to a conflicting Run handle and reusing the same instance across awaits.
  • Expose the workflow-bundle Run class via a new WORKFLOW_RUN_CLASS symbol so the hook consumer can construct durable Run handles inside workflow code.
  • Update workbench workflows, unit/e2e tests, docs (v4 + v5), and changesets for the renamed API.

Reviewed changes

Copilot reviewed 23 out of 23 changed files in this pull request and generated 5 comments.

Show a summary per file
FileDescription
workbench/nextjs-turbopack/app/workflows/definitions.tsRenames e2e workflow entries from hookHasConflict* to hookGetConflict*.
workbench/example/workflows/99_e2e.tsUpdates example workflows to use hook.getConflict and return conflict run metadata.
packages/core/src/workflow/hook.tsImplements getConflict resolution, constructs conflicting Run via WORKFLOW_RUN_CLASS.
packages/core/src/workflow/hook.test.tsUpdates unit tests to assert getConflict semantics and stable instance identity.
packages/core/src/workflow/create-hook.tsRegisters workflow-bundle Run class on globalThis[WORKFLOW_RUN_CLASS].
packages/core/src/workflow.test.tsUpdates workflow runner tests for suspension + getConflict behavior.
packages/core/src/symbols.tsAdds WORKFLOW_RUN_CLASS symbol definition and rationale.
packages/core/src/runtime/suspension-handler.tsUpdates comment/semantics references from hasConflict to getConflict.
packages/core/src/runtime/suspension-handler.test.tsUpdates tests and descriptions to the new getConflict naming.
packages/core/src/runtime.tsUpdates inline-execution gating comments to reference hook.getConflict.
packages/core/src/global.tsUpdates hook invocation queue item comment to reflect getConflict.
packages/core/src/create-hook.tsPublic Hook interface now exposes getConflict and updated docs/examples.
packages/core/e2e/e2e.test.tsRenames e2e tests and asserts conflict returns owner runId + durable status.
docs/content/docs/v5/how-it-works/event-sourcing.mdxUpdates hook conflict behavior description to reference getConflict.
docs/content/docs/v5/foundations/hooks.mdxUpdates conflict-checking guidance and examples to use getConflict.
docs/content/docs/v5/api-reference/workflow/create-webhook.mdxUpdates webhook return shape docs to getConflict.
docs/content/docs/v5/api-reference/workflow/create-hook.mdxUpdates API reference + examples from hasConflict to getConflict.
docs/content/docs/v4/how-it-works/event-sourcing.mdxUpdates v4 event-sourcing docs to reference getConflict.
docs/content/docs/v4/foundations/hooks.mdxUpdates v4 conflict-checking docs to getConflict ({ runId } guidance).
docs/content/docs/v4/api-reference/workflow/create-webhook.mdxUpdates v4 webhook docs to getConflict returning { runId }/null.
docs/content/docs/v4/api-reference/workflow/create-hook.mdxUpdates v4 hook API reference to getConflict returning { runId }/null.
.changeset/hook-has-conflict.mdRemoves changeset for the superseded hasConflict API.
.changeset/hook-get-conflict.mdAdds changeset for getConflict.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

// executes inside the VM, so `Run` here is the plugin-compiled variant
// whose methods are durable step proxies. The host-side consumer uses it
// to construct the conflicting run resolved by `hook.getConflict`.
(globalThis as any)[WORKFLOW_RUN_CLASS] ??= Run;

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in 919bde5: the registration is now guarded on the workflow runtime being present (WORKFLOW_CREATE_HOOK is installed on the VM globalThis before the bundle is evaluated), so importing this module outside the VM neither mutates the host global nor exposes the host-side Run (whose methods are not step proxies). Verified via the hookGetConflict e2e suite against a real bundle.

Comment threadpackages/core/src/create-hook.ts Outdated
Comment on lines +34 to +36
* Resolves with the conflicting {@link Run} if another active hook
* already owns this hook's token, or `null` once the hook has been
* registered and is ready to receive payloads.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Already addressed in dec869d (this review appears to have run against the initial commit): createConflictingRun() no longer falls back to a { runId }-shaped object. When a real Run cannot be constructed — including hook_conflict events without conflictingRunIdgetConflict() rejects with HookConflictError instead, and the JSDoc documents exactly that.

Comment threadpackages/core/src/create-hook.ts Outdated
* ```
*/
readonly hasConflict: Promise<boolean>;
readonly getConflict: Promise<Run<unknown> | null>;

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Already addressed in dec869d: the runtime now strictly honors Promise<Run<unknown> | null>. The { runId } fallback was removed — degenerate cases (legacy events without conflictingRunId, contexts without the bundle Run class) reject with HookConflictError rather than resolving with a value that lacks Run accessors, so the public type no longer needs widening.

```

Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.hasConflict` suspends the workflow to commit the hook registration, then resolves with `false` once the hook is registered and ready to receive payloads, or `true` if another active hook already owns the same token (see [`HookConflictError`](/docs/errors/hook-conflict)).
Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.getConflict` suspends the workflow to commit the hook registration, then resolves with `null` once the hook is registered and ready to receive payloads, or with a `Run` handle for the run that owns the token if another active hook already claimed it (see [`HookConflictError`](/docs/errors/hook-conflict)). The conflicting run's accessors are durable steps, so the workflow can inspect `await conflict.status`, wait on `await conflict.returnValue`, or cancel the owner with `await conflict.cancel()` — see [Run idempotency](/docs/foundations/idempotency#run-idempotency) for these strategies.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Clarified in 919bde5: the hooks docs (v4 + v5) now state that for hook_conflict events persisted by older worlds without the owning run ID, getConflict() rejects with HookConflictError instead of resolving with an incomplete handle — so when it does resolve with a conflict, the durable accessors are always available.

Comment thread.changeset/hook-get-conflict.md Outdated
"workflow": minor
---

Add `hook.getConflict`, a promise that suspends the workflow to commit hook registration and resolves with the conflicting `Run` when another active hook owns the token (or `null` once the hook is registered), without waiting for hook payload data.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

that doesn't matter because we didn't ship a new release with the previousl documented API so it's a fast follow and change

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Updated in 919bde5: the changeset now explicitly calls out that this replaces hook.hasConflict and includes the migration (await hook.hasConflictconst conflict = await hook.getConflict(), branch on conflict !== null).

getConflict's contract is Promise<Run | null>. In the degenerate cases
where a real Run cannot be constructed — a hook_conflict event persisted
by an old world without conflictingRunId, or a context that never loaded
the workflow-mode create-hook module — reject with HookConflictError
instead of resolving with a { runId }-shaped impostor.
Test harnesses now register the Run class on the (VM) globalThis like
real bundles do.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Request Changes — the feature is right, but the WORKFLOW_RUN_CLASS global duplicates infrastructure the serialization pipeline already provides

First: the API direction is a clear improvement. A Run handle beats the boolean (hasConflict told you that, not who), code-driven conflict strategy beats a fixed policy enum, and the dec869d9e decision to reject with HookConflictError rather than resolve a { runId }-shaped impostor keeps the Run contract honest. The determinism story checks out too: both fast-paths settle through promiseQueue, conflictRunRef is shared so repeated awaits observe one instance, replay reconstructs the handle from the same event-log conflictingRunId, and the bundle-compiled Run's accessors are step proxies (I verified the compiled output — Object.defineProperty(Run.prototype, ...) with __step_Run$... proxies), so await conflict.status / conflict.cancel() from workflow code are durable. 1156 unit tests pass locally; the red express E2E lane is failing errorWorkflowNested/errorWorkflowCrossFile (not hook tests) — baseline flake.

The change I'm requesting: use the class registry instead of a bespoke global

The (globalThis as any)[WORKFLOW_RUN_CLASS] ??= Run registration in workflow-mode create-hook.ts re-implements, ad hoc, something the serialization pipeline already does systematically. I built workbench/example from this branch and inspected the compiled flow bundle:

(function(__wf_cls,__wf_id){var__wf_sym=Symbol.for("workflow-class-registry"), ...
})(Run,"class//./packages/core/dist/runtime/run//Run");

The SWC plugin already registers the workflow-compiled Run in globalThis[WORKFLOW_CLASS_REGISTRY] at bundle evaluation — because Run carries WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE. This is the exact channel a start()-returned Run already uses to cross from host to VM today: the Instance reducer emits { classId, data: { runId, resilientStart } }, and getClassRevivers(global).Instance looks the class up via getSerializationClass(classId, global) against the VM's global and calls its WORKFLOW_DESERIALIZE.

So the class this PR smuggles through a new symbol is already sitting in the VM's registry when the hook consumer runs. The only missing piece is the lookup key — the plugin derives it from the module path (class//./packages/core/dist/runtime/run//Run in the workbench; different prefix under npm-installed layouts / other bundlers), so it can't be hardcoded.

Proposal (~15 lines total):

  1. Register Run under a stable, well-known classId alongside the plugin's path-derived one. registerSerializationClass can't be called twice (its non-configurable defineProperty(cls, 'classId') throws once the plugin IIFE has run), so add a tiny alias helper to class-serialization.ts:
/** Register an additional stable id for an already-registered class * (skips the classId defineProperty). */exportfunctionaliasSerializationClass(classId: string,cls: Function){getRegistry().set(classId,cls);}
  1. Call it at module scope of runtime/run.ts (e.g. aliasSerializationClass('class//workflow//Run', Run)). Note what this buys over the symbol approach: per-context discipline for free. Evaluated inside the VM, it registers the VM's step-proxy Run on the VM's registry; evaluated on the host, it registers the host Run on the host's registry. Each global gets its own correct class — which means the if (globalThis[WORKFLOW_CREATE_HOOK]) guard and the ??= (both of which exist purely to keep the host Run from leaking into the wrong global) simply disappear, along with the module-scope conditional in create-hook.ts and the new symbol in symbols.ts.

  2. The consumer constructs through the pipeline's own hook instead of new RunClass(id):

functioncreateConflictingRun(ctx,conflictingRunId){if(typeofconflictingRunId!=='string')returnnull;constRunClass=getSerializationClass(RUN_STABLE_CLASS_ID,ctx.globalThis);if(!RunClass)returnnull;return(RunClassasany)[WORKFLOW_DESERIALIZE]({runId: conflictingRunId});}

Same degenerate-case semantics you already have (registry miss → null → awaiters reject with HookConflictError), but the construction takes the identical code path a serialized Run instance takes everywhere else. class-serialization.ts is deliberately dependency-light ("separate from private.ts to avoid pulling in Node.js-only dependencies"), so importing it from workflow/hook.ts is safe.

Side benefit worth banking: a stable classId hardens Run serialization generally. Today a Run persisted in the event log (e.g. a start()-in-workflow handle in a step result) embeds the path-derived id, which is only safe because version-skew protection pins replay to the same deployment whose bundles agree on the path. A stable alias removes that coupling — any future cross-deployment replay (or a bundler change mid-release) stops being able to break Run hydration with "Class not found".

I considered the fuller "interact with the serialization pipeline" variant — making getConflict() resolution a step so the standard step-return serde does everything — and I think your current design is better: piggybacking on hook_created/hook_conflict costs zero extra events and zero queue hops, where a step-based resolution would add one of each per call. The registry lookup keeps that property while deleting the bespoke channel.

Smaller notes

  • The v4 docs describe a { runId } return that no released 4.x SDK has (and getConflict itself isn't on stable). Same sequencing flag as #2015/#2011 — the v4 pages keep widening the documented-but-unreleased surface; fine if the backport chain is imminent, worth holding otherwise.
  • e56e6fbc25b3b88b0b's getter→method change (hook.getConflict()) is the right call — a property that allocates and enqueues new promise resolvers on every access was the same trap hasConflict had; a method at least signals effects.
  • Changeset correctly replaces the hasConflict one and is scoped to @workflow/core + workflow (minor) — and since hasConflict shipped in no stable release and at most one beta, replacing rather than deprecating is fine.

Happy to re-review quickly once the registry switch is in — everything else here is ready.

… registry
Replace the bespoke WORKFLOW_RUN_CLASS global with the registry the
serialization pipeline already uses to revive Run instances:
- The SWC plugin already auto-registers the workflow bundle's compiled
Run in globalThis[workflow-class-registry], but under a path-derived
classId the host cannot know statically. The workflow-mode create-hook
module now aliases it under a stable id (class//workflow//Run) via a
new aliasSerializationClass() helper (a plain registry entry —
registerSerializationClass cannot be reused since the plugin's IIFE
already defined the non-configurable classId property).
- createConflictingRun() looks the class up with
getSerializationClass(RUN_CLASS_ID, ctx.globalThis) and constructs
through its WORKFLOW_DESERIALIZE hook, exactly as the Instance reviver
would for a serialized Run crossing from a step into the workflow.
- Because the registry is keyed per-global, no environment guard is
needed: a stray host-side import registers the host Run on the host
registry, which is the correct class for that context. The
WORKFLOW_CREATE_HOOK guard, the ??=, and the WORKFLOW_RUN_CLASS symbol
are all deleted.
Verified: 1156 core unit tests; compiled workbench bundle contains the
stable alias alongside the plugin's path-derived registration with zero
WORKFLOW_RUN_CLASS references; all 5 hookGetConflict e2e tests pass
against a local nextjs-turbopack dev server, including conflict
resolution reading conflict.status through a durable step.

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approve — the registry switch from my previous review is now implemented (0aa3cb0)

I implemented the change I requested (after discussing with Nathan), so this approval covers Pranay's feature plus that refactor — Pranay, please give 0aa3cb0 a once-over since it rewrites the class-handoff mechanism you authored.

What changed:

  • WORKFLOW_RUN_CLASS is gone (symbol, the ??= registration, and the WORKFLOW_CREATE_HOOK guard). The workflow-mode create-hook module now aliases the bundle's Run into the existing serialization class registry under a stable id (class//workflow//Run) via a new aliasSerializationClass() helper — a plain registry entry, since registerSerializationClass's non-configurable defineProperty(classId) would throw on a class the SWC plugin already registered.
  • createConflictingRun resolves through the pipeline: getSerializationClass(RUN_CLASS_ID, ctx.globalThis) + construction via the class's WORKFLOW_DESERIALIZE hook — the identical path the Instance reviver takes for a serialized Run crossing from a step into the workflow. Degenerate-case semantics unchanged: registry miss or missing deserialize hook → null → awaiters reject with HookConflictError.
  • No environment guard needed: the registry is per-global by construction, so a stray host-side import of the module registers the host Run on the host registry — correct for that context.

Verification:

  • 1156 core unit tests pass; hook.test.ts / workflow.test.ts mocks updated to register through the registry (the VM-string test now mirrors the real bundle shape, including the WORKFLOW_DESERIALIZE hook)
  • Rebuilt workbench/example: compiled flow bundle contains the stable alias alongside the plugin's path-derived registration, with zero WORKFLOW_RUN_CLASS references
  • All 5 hookGetConflict* e2e tests pass against a local nextjs-turbopack dev server, including the conflict test that resolves the actual owner and reads await conflict.status through a durable step — the full host → VM-registry → WORKFLOW_DESERIALIZE → step-proxy chain
  • The two biome complexity findings in hook.ts are pre-existing on this branch (identical count before/after)

Remaining notes from my previous review stand but don't block: the v4 docs describe a surface not yet on stable (recurring sequencing flag), and the stable classId incidentally hardens Run event-log serialization against future cross-deployment replay.

@github-actions

Copy link
Copy Markdown
Contributor

Backport PR opened against stable: #2382. Merge conflicts were resolved by AI — please review carefully. (backport job run)

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

@pranaygp@TooTallNate