Skip to content

[world-vercel] Make HTTP/2 actually multiplex on the events path - #3190

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/world-vercel-h2-multiplexing
Jul 29, 2026
Merged

[world-vercel] Make HTTP/2 actually multiplex on the events path#3190
VaguelySerious merged 2 commits into
mainfrom
peter/world-vercel-h2-multiplexing

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

Problem

The events agent sets allowH2: true and does negotiate ALPN h2 — but it never multiplexed. Measured against api.vercel.com with a warmed pool, 16 concurrent event writes produced 8 in-flight requests over 8 TCP connections: byte-for-byte identical to the HTTP/1.1 default agent. The existing tests asserted EVENTS_AGENT_OPTIONS.allowH2 === true and that ALPN negotiates h2 — both true, neither measures concurrency.

Cause

Three independent undici gates each cap in-flight requests at one per connection. All three had to be lifted; any one of them left alone keeps the agent serialized.

  1. pipelining: 1, inherited from the shared base options. undici gates in-flight requests per connection on client[kPipelining] ?? httpContext.defaultPipelining ?? 1. client-h2.js sets defaultPipelining: Infinity, but the Client constructor always coerces pipelining to a number, so kPipelining is never nullish and H2's Infinity is unreachable — nodejs/undici#4143.
  2. Non-idempotent method.client-h2.js reports the connection busy whenever request.idempotent === false, and undici only treats GET/HEAD as idempotent by default. Every event write is a POST — nodejs/undici#5390.
  3. Streamed request body.client-h2.js also reports busy for a stream / async-iterable body. events-v4 hands over a materialized Uint8Array, but it dispatches through the global fetch — deliberately, since that is what keeps events traffic visible in the outgoing-requests view — and fetch streamifies every body on the way down.

Verified matrix (16 concurrent POSTs): pipelining alone → 8 in-flight. idempotent: true alone → 8 in-flight. Both, plus a buffered body → 16 in-flight, 0 new connections.

Change

  • pipelining is split out of the shared base options so each agent sets it explicitly — undici overloads that one option to mean both "H1 pipelining depth" and "max in-flight H2 streams per connection", and the two paths want opposite values. The H1 default keeps pipelining: 1 (it deadlocks the webhook respondWith mechanism otherwise).
  • An interceptor marks events requests idempotent and re-buffers streamed bodies under 1 MiB. Marking them idempotent affects concurrency only: in undici 7 the flag feeds nothing but the busy() gates, and retries are governed solely by RetryAgent, whose default methods excludes POST — so an event write is still never resent.
  • The interceptor wraps the RetryAgent rather than the Agent inside it. RetryHandler captures its own copy of the body up front (wrapRequestBody), so composing it inside would leave a retry re-iterating the stream the interceptor had already consumed, sending an empty body. Composed outside, the retry replays the drained Buffer. There is a test for exactly this.
  • Stream write/close get their own STREAM_AGENT_OPTIONS and deliberately do not multiplex. Appends are not idempotent, and STREAM_RETRY_OPTIONS retries PUT on transient connection errors — multiplexing would let a single RST_STREAM/GOAWAY fail, and then re-send, several already-applied appends at once. This preserves today's one-request-per-connection failure isolation. (Belt-and-braces: gate 2 already serializes PUTs, but pinning pipelining: 1 means the safety property doesn't silently depend on that upstream detail.)
  • Escape hatch WORKFLOW_H2_MULTIPLEX=0 restores one request per connection, documented in Runtime Tuning.

Verification

Against real api.vercel.com, 16 concurrent POSTs after warming the pool:

dispatcherALPNnew connections for burst
events (before)h27
events (after)h20
stream writeh27 (intended)
events, WORKFLOW_H2_MULTIPLEX=0h27

New tests run a real H2 loopback server through the production factory functions and hold requests open on a barrier to observe peak concurrency: one asserts the events dispatcher reaches 16 concurrent streams with no new connections, one asserts stream writes stay bounded by the pool, one asserts a re-buffered body survives a retry intact, one covers the kill switch. Reverting either the pipelining value or the interceptor composition fails these tests.

pnpm typecheck and all 309 world-vercel unit tests pass.

The events agent sets `allowH2: true` and does negotiate ALPN h2, but it
never multiplexed: 16 concurrent event writes produced 8 in-flight
requests over 8 TCP connections — byte-for-byte identical to the HTTP/1.1
default agent.
Three independent undici gates each capped in-flight requests at one per
connection, and all three had to be lifted:
1. `pipelining: 1`, inherited from the shared base options. undici gates
in-flight requests on `client[kPipelining] ?? defaultPipelining ?? 1`;
`client-h2.js` sets `defaultPipelining: Infinity`, but the Client
constructor always coerces `pipelining` to a number, so that value is
unreachable (nodejs/undici#4143).
2. `client-h2.js` reports the connection busy for any request with
`idempotent === false`, and only GET/HEAD are idempotent by default —
every event write is a POST (nodejs/undici#5390).
3. `client-h2.js` also reports busy for a stream/async-iterable body.
events-v4 passes a materialized `Uint8Array`, but it dispatches via
global `fetch` (deliberately, for outgoing-request observability),
which streamifies every body.
`pipelining` is split out of the shared base so each agent sets it
explicitly, and an interceptor marks events requests idempotent and
re-buffers small streamed bodies. Marking them idempotent affects
concurrency only: RetryAgent's default `methods` excludes POST, so an
event write is still never resent.
Stream write/close move to their own options and deliberately do NOT
multiplex: appends are not idempotent, and STREAM_RETRY_OPTIONS retries
PUT on transient connection errors, so multiplexing would let one reset
fail — and then re-send — several already-applied appends at once.
Verified against api.vercel.com, 16 concurrent POSTs after warming the
pool: events 0 new connections (was 7), stream writes 7,
WORKFLOW_H2_MULTIPLEX=0 back to 7.
@VaguelySerious
VaguelySerious requested review from a team and ijjk as code ownersJuly 29, 2026 19:04
@changeset-bot

changeset-botBot commented Jul 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6883125

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

This PR includes changesets to release 17 packages
NameType
@workflow/world-vercelPatch
@workflow/cliPatch
@workflow/corePatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@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 Jul 29, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewJul 29, 2026 9:42pm
example-nextjs-workflow-webpackReadyReadyPreviewJul 29, 2026 9:42pm
example-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-astro-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-express-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-fastify-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-hono-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-nestjs-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-nitro-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-nuxt-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-sveltekit-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-tanstack-start-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-vite-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workflow-docsReadyReadyPreview, v0Jul 29, 2026 9:42pm
workflow-swc-playgroundReadyReadyPreviewJul 29, 2026 9:42pm
workflow-tarballsReadyReadyPreviewJul 29, 2026 9:42pm
workflow-webReadyReadyPreviewJul 29, 2026 9:42pm

@github-actions

github-actionsBot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 6883125 · Wed, 29 Jul 2026 21:59:44 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep262 (-71%) 💚1376 🔴 (+38%) 🔻1426 🔴 (+38%) 🔻1441 🔴 (+10%)30
TTFSstream249 (-73%) 💚1353 🔴 (+36%) 🔻1370 🔴 (+35%) 🔻1541 🔴 (+48%) 🔻30
TTFShook + stream359 (-22%) 💚1597 🔴 (+24%) 🔻1626 🔴 (+22%) 🔻1748 🔴 (-16%) 💚30
STSO1020 steps (1-20)175 (±0%)286 🔴 (±0%)380 🔴 (+14%)547 🔴 (+3.6%)19
STSO1020 steps (101-120)195 (+7.7%)315 🔴 (+11%)341 🔴 (+9.6%)365 🔴 (+8.0%)19
STSO1020 steps (1001-1020)502 (+12%)619 🔴 (+12%)754 🔴 (+30%) 🔻877 🔴 (+40%) 🔻19
WO1020 steps417644 (+7.6%)417644 (+7.6%)417644 (+7.6%)417644 (+7.6%)1
SLstream latency127 (+49%) 🔻202 🔴 (+67%) 🔻390 🔴 (+185%) 🔻714 🔴 (+212%) 🔻30
SOstream overhead (text)140 (+17%) 🔻221 (+11%)276 (+34%) 🔻356 (+17%) 🔻30
SOstream overhead (structured)113 (+12%)200 (-14%)246 (-9.9%)1053 🔴 (+167%) 🔻30
📜 Previous results (1)

b9f271d

Wed, 29 Jul 2026 19:28:19 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep274 (-53%) 💚1364 🔴 (+30%) 🔻1399 🔴 (+23%) 🔻1472 🔴 (+8.6%)30
TTFSstream224 (+13%)1371 🔴 (+32%) 🔻1397 🔴 (+31%) 🔻1735 🔴 (+41%) 🔻30
TTFShook + stream330 (-73%) 💚1609 🔴 (+20%) 🔻1695 🔴 (+22%) 🔻1843 🔴 (+27%) 🔻30
STSO1020 steps (1-20)189 (+9.9%)285 🔴 (-3.4%)517 🔴 (+59%) 🔻621 🔴 (+82%) 🔻19
STSO1020 steps (101-120)223 (+14%)379 🔴 (+38%) 🔻474 🔴 (+56%) 🔻580 🔴 (+14%)19
STSO1020 steps (1001-1020)508 (+5.6%)608 🔴 (+6.9%)649 🔴 (+8.3%)710 🔴 (+14%)19
WO1020 steps425133 (+4.5%)425133 (+4.5%)425133 (+4.5%)425133 (+4.5%)1
SLstream latency126 (+59%) 🔻239 🔴 (+68%) 🔻407 🔴 (+86%) 🔻825 🔴 (+36%) 🔻30
SOstream overhead (text)172 (+77%) 🔻424 🔴 (+149%) 🔻546 🔴 (+186%) 🔻779 (+203%) 🔻30
SOstream overhead (structured)189 (+82%) 🔻382 🔴 (+89%) 🔻459 (+113%) 🔻666 (+12%)30
ℹ️ Metric definitions & methodology

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actionsBot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

📦 Local Production (2 failed)

nextjs-turbopack-stable (1 failed):

  • webhookWorkflow | wrun_41KYQX591P0GXNMNHKPWR0RG7Y

nextjs-webpack-stable (1 failed):

  • webhookWorkflow | wrun_41KYQX591P0GXNMNHKPWR0RG7Y

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
❌ 📦 Local Production161922271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7517211328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

❌ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
❌ nextjs-turbopack-stable15310
✅ nextjs-webpack-canary135019
❌ nextjs-webpack-stable15310
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

Comment threaddocs/content/docs/v5/configuration/runtime-tuning.mdx Outdated

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the HTTP/2 multiplexing implementation, retry/body handling, stream-write isolation, fallback switch, and regression coverage. No blocking correctness issues found.

Co-authored-by: Nathan Rajlich <n@n8.io>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@VaguelySerious
VaguelySerious merged commit 34975f6 into mainJul 29, 2026
15 checks passed
@VaguelySerious
VaguelySerious deleted the peter/world-vercel-h2-multiplexing branch July 29, 2026 21:38
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 34975f6 (AI decision).

This is a performance/throughput optimization (making the events agent actually multiplex over HTTP/2) plus new API surface (createEventsDispatcher, createStreamDispatcher, STREAM_AGENT_OPTIONS) and a new WORKFLOW_H2_MULTIPLEX config flag — not a fix for a crash, correctness, or data-loss defect on the maintenance line. It also builds on main-only behavior: origin/stable:packages/world-vercel/src/http-client.ts has no H2 events agent at all (allowH2: false, no EVENTS_AGENT_OPTIONS), so there is nothing on stable for this to fix. The accompanying docs page (docs/content/docs/v5/configuration/runtime-tuning.mdx) does not exist on stable either.

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

34975f6b7dd8e0dad874e852eac04c9652f5971d

@github-actions

Copy link
Copy Markdown
Contributor

Backport to stable failed — the cherry-pick had conflicts that could not be resolved automatically (backport job run).

To resolve manually, push a backport branch and open a PR against stable (the workflow never pushes directly to stable). Note: this repository requires verified signatures on every branch, so your local commits must be signed (git config commit.gpgsign true with a configured GPG/SSH signing key, or git cherry-pick -S).

git fetch origin stable
git checkout -b backport/pr-3190-to-stable origin/stable
git cherry-pick -S 34975f6b7dd8e0dad874e852eac04c9652f5971d # -S signs the commit# Fix conflicts, then:
git add -A
git cherry-pick --continue
git push -u origin backport/pr-3190-to-stable
gh pr create --base stable --head backport/pr-3190-to-stable \
--title "Backport #3190: <original PR title>" \
--body "Manual backport of #3190 (cherry-pick 34975f6b7dd8) to \`stable\`."

pranaygp added a commit that referenced this pull request Jul 30, 2026
`vercel.json`'s `env` block is documented as passing variables to the
functions, but Vercel marks the property deprecated, and a flag that
silently fails to arrive would make a null result read as "multiplexing is
not the cause" — the one outcome where being wrong is expensive.
instrumentation.ts now supplies the value if the deployment did not, and
records which source won so the probe route can report it. That also tells
us where mitigation advice should point if #3190 owns the regression.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

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

[world-vercel] Make HTTP/2 actually multiplex on the events path - #3190

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/world-vercel-h2-multiplexing
Jul 29, 2026
Merged

[world-vercel] Make HTTP/2 actually multiplex on the events path#3190
VaguelySerious merged 2 commits into
mainfrom
peter/world-vercel-h2-multiplexing

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

Problem

The events agent sets allowH2: true and does negotiate ALPN h2 — but it never multiplexed. Measured against api.vercel.com with a warmed pool, 16 concurrent event writes produced 8 in-flight requests over 8 TCP connections: byte-for-byte identical to the HTTP/1.1 default agent. The existing tests asserted EVENTS_AGENT_OPTIONS.allowH2 === true and that ALPN negotiates h2 — both true, neither measures concurrency.

Cause

Three independent undici gates each cap in-flight requests at one per connection. All three had to be lifted; any one of them left alone keeps the agent serialized.

  1. pipelining: 1, inherited from the shared base options. undici gates in-flight requests per connection on client[kPipelining] ?? httpContext.defaultPipelining ?? 1. client-h2.js sets defaultPipelining: Infinity, but the Client constructor always coerces pipelining to a number, so kPipelining is never nullish and H2's Infinity is unreachable — nodejs/undici#4143.
  2. Non-idempotent method.client-h2.js reports the connection busy whenever request.idempotent === false, and undici only treats GET/HEAD as idempotent by default. Every event write is a POST — nodejs/undici#5390.
  3. Streamed request body.client-h2.js also reports busy for a stream / async-iterable body. events-v4 hands over a materialized Uint8Array, but it dispatches through the global fetch — deliberately, since that is what keeps events traffic visible in the outgoing-requests view — and fetch streamifies every body on the way down.

Verified matrix (16 concurrent POSTs): pipelining alone → 8 in-flight. idempotent: true alone → 8 in-flight. Both, plus a buffered body → 16 in-flight, 0 new connections.

Change

  • pipelining is split out of the shared base options so each agent sets it explicitly — undici overloads that one option to mean both "H1 pipelining depth" and "max in-flight H2 streams per connection", and the two paths want opposite values. The H1 default keeps pipelining: 1 (it deadlocks the webhook respondWith mechanism otherwise).
  • An interceptor marks events requests idempotent and re-buffers streamed bodies under 1 MiB. Marking them idempotent affects concurrency only: in undici 7 the flag feeds nothing but the busy() gates, and retries are governed solely by RetryAgent, whose default methods excludes POST — so an event write is still never resent.
  • The interceptor wraps the RetryAgent rather than the Agent inside it. RetryHandler captures its own copy of the body up front (wrapRequestBody), so composing it inside would leave a retry re-iterating the stream the interceptor had already consumed, sending an empty body. Composed outside, the retry replays the drained Buffer. There is a test for exactly this.
  • Stream write/close get their own STREAM_AGENT_OPTIONS and deliberately do not multiplex. Appends are not idempotent, and STREAM_RETRY_OPTIONS retries PUT on transient connection errors — multiplexing would let a single RST_STREAM/GOAWAY fail, and then re-send, several already-applied appends at once. This preserves today's one-request-per-connection failure isolation. (Belt-and-braces: gate 2 already serializes PUTs, but pinning pipelining: 1 means the safety property doesn't silently depend on that upstream detail.)
  • Escape hatch WORKFLOW_H2_MULTIPLEX=0 restores one request per connection, documented in Runtime Tuning.

Verification

Against real api.vercel.com, 16 concurrent POSTs after warming the pool:

dispatcherALPNnew connections for burst
events (before)h27
events (after)h20
stream writeh27 (intended)
events, WORKFLOW_H2_MULTIPLEX=0h27

New tests run a real H2 loopback server through the production factory functions and hold requests open on a barrier to observe peak concurrency: one asserts the events dispatcher reaches 16 concurrent streams with no new connections, one asserts stream writes stay bounded by the pool, one asserts a re-buffered body survives a retry intact, one covers the kill switch. Reverting either the pipelining value or the interceptor composition fails these tests.

pnpm typecheck and all 309 world-vercel unit tests pass.

The events agent sets `allowH2: true` and does negotiate ALPN h2, but it
never multiplexed: 16 concurrent event writes produced 8 in-flight
requests over 8 TCP connections — byte-for-byte identical to the HTTP/1.1
default agent.
Three independent undici gates each capped in-flight requests at one per
connection, and all three had to be lifted:
1. `pipelining: 1`, inherited from the shared base options. undici gates
in-flight requests on `client[kPipelining] ?? defaultPipelining ?? 1`;
`client-h2.js` sets `defaultPipelining: Infinity`, but the Client
constructor always coerces `pipelining` to a number, so that value is
unreachable (nodejs/undici#4143).
2. `client-h2.js` reports the connection busy for any request with
`idempotent === false`, and only GET/HEAD are idempotent by default —
every event write is a POST (nodejs/undici#5390).
3. `client-h2.js` also reports busy for a stream/async-iterable body.
events-v4 passes a materialized `Uint8Array`, but it dispatches via
global `fetch` (deliberately, for outgoing-request observability),
which streamifies every body.
`pipelining` is split out of the shared base so each agent sets it
explicitly, and an interceptor marks events requests idempotent and
re-buffers small streamed bodies. Marking them idempotent affects
concurrency only: RetryAgent's default `methods` excludes POST, so an
event write is still never resent.
Stream write/close move to their own options and deliberately do NOT
multiplex: appends are not idempotent, and STREAM_RETRY_OPTIONS retries
PUT on transient connection errors, so multiplexing would let one reset
fail — and then re-send — several already-applied appends at once.
Verified against api.vercel.com, 16 concurrent POSTs after warming the
pool: events 0 new connections (was 7), stream writes 7,
WORKFLOW_H2_MULTIPLEX=0 back to 7.
@VaguelySerious
VaguelySerious requested review from a team and ijjk as code ownersJuly 29, 2026 19:04
@changeset-bot

changeset-botBot commented Jul 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6883125

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

This PR includes changesets to release 17 packages
NameType
@workflow/world-vercelPatch
@workflow/cliPatch
@workflow/corePatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@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 Jul 29, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewJul 29, 2026 9:42pm
example-nextjs-workflow-webpackReadyReadyPreviewJul 29, 2026 9:42pm
example-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-astro-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-express-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-fastify-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-hono-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-nestjs-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-nitro-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-nuxt-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-sveltekit-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-tanstack-start-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-vite-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workflow-docsReadyReadyPreview, v0Jul 29, 2026 9:42pm
workflow-swc-playgroundReadyReadyPreviewJul 29, 2026 9:42pm
workflow-tarballsReadyReadyPreviewJul 29, 2026 9:42pm
workflow-webReadyReadyPreviewJul 29, 2026 9:42pm

@github-actions

github-actionsBot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 6883125 · Wed, 29 Jul 2026 21:59:44 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep262 (-71%) 💚1376 🔴 (+38%) 🔻1426 🔴 (+38%) 🔻1441 🔴 (+10%)30
TTFSstream249 (-73%) 💚1353 🔴 (+36%) 🔻1370 🔴 (+35%) 🔻1541 🔴 (+48%) 🔻30
TTFShook + stream359 (-22%) 💚1597 🔴 (+24%) 🔻1626 🔴 (+22%) 🔻1748 🔴 (-16%) 💚30
STSO1020 steps (1-20)175 (±0%)286 🔴 (±0%)380 🔴 (+14%)547 🔴 (+3.6%)19
STSO1020 steps (101-120)195 (+7.7%)315 🔴 (+11%)341 🔴 (+9.6%)365 🔴 (+8.0%)19
STSO1020 steps (1001-1020)502 (+12%)619 🔴 (+12%)754 🔴 (+30%) 🔻877 🔴 (+40%) 🔻19
WO1020 steps417644 (+7.6%)417644 (+7.6%)417644 (+7.6%)417644 (+7.6%)1
SLstream latency127 (+49%) 🔻202 🔴 (+67%) 🔻390 🔴 (+185%) 🔻714 🔴 (+212%) 🔻30
SOstream overhead (text)140 (+17%) 🔻221 (+11%)276 (+34%) 🔻356 (+17%) 🔻30
SOstream overhead (structured)113 (+12%)200 (-14%)246 (-9.9%)1053 🔴 (+167%) 🔻30
📜 Previous results (1)

b9f271d

Wed, 29 Jul 2026 19:28:19 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep274 (-53%) 💚1364 🔴 (+30%) 🔻1399 🔴 (+23%) 🔻1472 🔴 (+8.6%)30
TTFSstream224 (+13%)1371 🔴 (+32%) 🔻1397 🔴 (+31%) 🔻1735 🔴 (+41%) 🔻30
TTFShook + stream330 (-73%) 💚1609 🔴 (+20%) 🔻1695 🔴 (+22%) 🔻1843 🔴 (+27%) 🔻30
STSO1020 steps (1-20)189 (+9.9%)285 🔴 (-3.4%)517 🔴 (+59%) 🔻621 🔴 (+82%) 🔻19
STSO1020 steps (101-120)223 (+14%)379 🔴 (+38%) 🔻474 🔴 (+56%) 🔻580 🔴 (+14%)19
STSO1020 steps (1001-1020)508 (+5.6%)608 🔴 (+6.9%)649 🔴 (+8.3%)710 🔴 (+14%)19
WO1020 steps425133 (+4.5%)425133 (+4.5%)425133 (+4.5%)425133 (+4.5%)1
SLstream latency126 (+59%) 🔻239 🔴 (+68%) 🔻407 🔴 (+86%) 🔻825 🔴 (+36%) 🔻30
SOstream overhead (text)172 (+77%) 🔻424 🔴 (+149%) 🔻546 🔴 (+186%) 🔻779 (+203%) 🔻30
SOstream overhead (structured)189 (+82%) 🔻382 🔴 (+89%) 🔻459 (+113%) 🔻666 (+12%)30
ℹ️ Metric definitions & methodology

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actionsBot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

📦 Local Production (2 failed)

nextjs-turbopack-stable (1 failed):

  • webhookWorkflow | wrun_41KYQX591P0GXNMNHKPWR0RG7Y

nextjs-webpack-stable (1 failed):

  • webhookWorkflow | wrun_41KYQX591P0GXNMNHKPWR0RG7Y

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
❌ 📦 Local Production161922271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7517211328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

❌ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
❌ nextjs-turbopack-stable15310
✅ nextjs-webpack-canary135019
❌ nextjs-webpack-stable15310
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

Comment threaddocs/content/docs/v5/configuration/runtime-tuning.mdx Outdated

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the HTTP/2 multiplexing implementation, retry/body handling, stream-write isolation, fallback switch, and regression coverage. No blocking correctness issues found.

Co-authored-by: Nathan Rajlich <n@n8.io>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@VaguelySerious
VaguelySerious merged commit 34975f6 into mainJul 29, 2026
15 checks passed
@VaguelySerious
VaguelySerious deleted the peter/world-vercel-h2-multiplexing branch July 29, 2026 21:38
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 34975f6 (AI decision).

This is a performance/throughput optimization (making the events agent actually multiplex over HTTP/2) plus new API surface (createEventsDispatcher, createStreamDispatcher, STREAM_AGENT_OPTIONS) and a new WORKFLOW_H2_MULTIPLEX config flag — not a fix for a crash, correctness, or data-loss defect on the maintenance line. It also builds on main-only behavior: origin/stable:packages/world-vercel/src/http-client.ts has no H2 events agent at all (allowH2: false, no EVENTS_AGENT_OPTIONS), so there is nothing on stable for this to fix. The accompanying docs page (docs/content/docs/v5/configuration/runtime-tuning.mdx) does not exist on stable either.

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

34975f6b7dd8e0dad874e852eac04c9652f5971d

@github-actions

Copy link
Copy Markdown
Contributor

Backport to stable failed — the cherry-pick had conflicts that could not be resolved automatically (backport job run).

To resolve manually, push a backport branch and open a PR against stable (the workflow never pushes directly to stable). Note: this repository requires verified signatures on every branch, so your local commits must be signed (git config commit.gpgsign true with a configured GPG/SSH signing key, or git cherry-pick -S).

git fetch origin stable
git checkout -b backport/pr-3190-to-stable origin/stable
git cherry-pick -S 34975f6b7dd8e0dad874e852eac04c9652f5971d # -S signs the commit# Fix conflicts, then:
git add -A
git cherry-pick --continue
git push -u origin backport/pr-3190-to-stable
gh pr create --base stable --head backport/pr-3190-to-stable \
--title "Backport #3190: <original PR title>" \
--body "Manual backport of #3190 (cherry-pick 34975f6b7dd8) to \`stable\`."

pranaygp added a commit that referenced this pull request Jul 30, 2026
`vercel.json`'s `env` block is documented as passing variables to the
functions, but Vercel marks the property deprecated, and a flag that
silently fails to arrive would make a null result read as "multiplexing is
not the cause" — the one outcome where being wrong is expensive.
instrumentation.ts now supplies the value if the deployment did not, and
records which source won so the probe route can report it. That also tells
us where mitigation advice should point if #3190 owns the regression.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

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

[world-vercel] Make HTTP/2 actually multiplex on the events path - #3190

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/world-vercel-h2-multiplexing
Jul 29, 2026
Merged

[world-vercel] Make HTTP/2 actually multiplex on the events path#3190
VaguelySerious merged 2 commits into
mainfrom
peter/world-vercel-h2-multiplexing

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

Problem

The events agent sets allowH2: true and does negotiate ALPN h2 — but it never multiplexed. Measured against api.vercel.com with a warmed pool, 16 concurrent event writes produced 8 in-flight requests over 8 TCP connections: byte-for-byte identical to the HTTP/1.1 default agent. The existing tests asserted EVENTS_AGENT_OPTIONS.allowH2 === true and that ALPN negotiates h2 — both true, neither measures concurrency.

Cause

Three independent undici gates each cap in-flight requests at one per connection. All three had to be lifted; any one of them left alone keeps the agent serialized.

  1. pipelining: 1, inherited from the shared base options. undici gates in-flight requests per connection on client[kPipelining] ?? httpContext.defaultPipelining ?? 1. client-h2.js sets defaultPipelining: Infinity, but the Client constructor always coerces pipelining to a number, so kPipelining is never nullish and H2's Infinity is unreachable — nodejs/undici#4143.
  2. Non-idempotent method.client-h2.js reports the connection busy whenever request.idempotent === false, and undici only treats GET/HEAD as idempotent by default. Every event write is a POST — nodejs/undici#5390.
  3. Streamed request body.client-h2.js also reports busy for a stream / async-iterable body. events-v4 hands over a materialized Uint8Array, but it dispatches through the global fetch — deliberately, since that is what keeps events traffic visible in the outgoing-requests view — and fetch streamifies every body on the way down.

Verified matrix (16 concurrent POSTs): pipelining alone → 8 in-flight. idempotent: true alone → 8 in-flight. Both, plus a buffered body → 16 in-flight, 0 new connections.

Change

  • pipelining is split out of the shared base options so each agent sets it explicitly — undici overloads that one option to mean both "H1 pipelining depth" and "max in-flight H2 streams per connection", and the two paths want opposite values. The H1 default keeps pipelining: 1 (it deadlocks the webhook respondWith mechanism otherwise).
  • An interceptor marks events requests idempotent and re-buffers streamed bodies under 1 MiB. Marking them idempotent affects concurrency only: in undici 7 the flag feeds nothing but the busy() gates, and retries are governed solely by RetryAgent, whose default methods excludes POST — so an event write is still never resent.
  • The interceptor wraps the RetryAgent rather than the Agent inside it. RetryHandler captures its own copy of the body up front (wrapRequestBody), so composing it inside would leave a retry re-iterating the stream the interceptor had already consumed, sending an empty body. Composed outside, the retry replays the drained Buffer. There is a test for exactly this.
  • Stream write/close get their own STREAM_AGENT_OPTIONS and deliberately do not multiplex. Appends are not idempotent, and STREAM_RETRY_OPTIONS retries PUT on transient connection errors — multiplexing would let a single RST_STREAM/GOAWAY fail, and then re-send, several already-applied appends at once. This preserves today's one-request-per-connection failure isolation. (Belt-and-braces: gate 2 already serializes PUTs, but pinning pipelining: 1 means the safety property doesn't silently depend on that upstream detail.)
  • Escape hatch WORKFLOW_H2_MULTIPLEX=0 restores one request per connection, documented in Runtime Tuning.

Verification

Against real api.vercel.com, 16 concurrent POSTs after warming the pool:

dispatcherALPNnew connections for burst
events (before)h27
events (after)h20
stream writeh27 (intended)
events, WORKFLOW_H2_MULTIPLEX=0h27

New tests run a real H2 loopback server through the production factory functions and hold requests open on a barrier to observe peak concurrency: one asserts the events dispatcher reaches 16 concurrent streams with no new connections, one asserts stream writes stay bounded by the pool, one asserts a re-buffered body survives a retry intact, one covers the kill switch. Reverting either the pipelining value or the interceptor composition fails these tests.

pnpm typecheck and all 309 world-vercel unit tests pass.

The events agent sets `allowH2: true` and does negotiate ALPN h2, but it
never multiplexed: 16 concurrent event writes produced 8 in-flight
requests over 8 TCP connections — byte-for-byte identical to the HTTP/1.1
default agent.
Three independent undici gates each capped in-flight requests at one per
connection, and all three had to be lifted:
1. `pipelining: 1`, inherited from the shared base options. undici gates
in-flight requests on `client[kPipelining] ?? defaultPipelining ?? 1`;
`client-h2.js` sets `defaultPipelining: Infinity`, but the Client
constructor always coerces `pipelining` to a number, so that value is
unreachable (nodejs/undici#4143).
2. `client-h2.js` reports the connection busy for any request with
`idempotent === false`, and only GET/HEAD are idempotent by default —
every event write is a POST (nodejs/undici#5390).
3. `client-h2.js` also reports busy for a stream/async-iterable body.
events-v4 passes a materialized `Uint8Array`, but it dispatches via
global `fetch` (deliberately, for outgoing-request observability),
which streamifies every body.
`pipelining` is split out of the shared base so each agent sets it
explicitly, and an interceptor marks events requests idempotent and
re-buffers small streamed bodies. Marking them idempotent affects
concurrency only: RetryAgent's default `methods` excludes POST, so an
event write is still never resent.
Stream write/close move to their own options and deliberately do NOT
multiplex: appends are not idempotent, and STREAM_RETRY_OPTIONS retries
PUT on transient connection errors, so multiplexing would let one reset
fail — and then re-send — several already-applied appends at once.
Verified against api.vercel.com, 16 concurrent POSTs after warming the
pool: events 0 new connections (was 7), stream writes 7,
WORKFLOW_H2_MULTIPLEX=0 back to 7.
@VaguelySerious
VaguelySerious requested review from a team and ijjk as code ownersJuly 29, 2026 19:04
@changeset-bot

changeset-botBot commented Jul 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6883125

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

This PR includes changesets to release 17 packages
NameType
@workflow/world-vercelPatch
@workflow/cliPatch
@workflow/corePatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@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 Jul 29, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewJul 29, 2026 9:42pm
example-nextjs-workflow-webpackReadyReadyPreviewJul 29, 2026 9:42pm
example-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-astro-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-express-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-fastify-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-hono-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-nestjs-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-nitro-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-nuxt-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-sveltekit-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-tanstack-start-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-vite-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workflow-docsReadyReadyPreview, v0Jul 29, 2026 9:42pm
workflow-swc-playgroundReadyReadyPreviewJul 29, 2026 9:42pm
workflow-tarballsReadyReadyPreviewJul 29, 2026 9:42pm
workflow-webReadyReadyPreviewJul 29, 2026 9:42pm

@github-actions

github-actionsBot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 6883125 · Wed, 29 Jul 2026 21:59:44 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep262 (-71%) 💚1376 🔴 (+38%) 🔻1426 🔴 (+38%) 🔻1441 🔴 (+10%)30
TTFSstream249 (-73%) 💚1353 🔴 (+36%) 🔻1370 🔴 (+35%) 🔻1541 🔴 (+48%) 🔻30
TTFShook + stream359 (-22%) 💚1597 🔴 (+24%) 🔻1626 🔴 (+22%) 🔻1748 🔴 (-16%) 💚30
STSO1020 steps (1-20)175 (±0%)286 🔴 (±0%)380 🔴 (+14%)547 🔴 (+3.6%)19
STSO1020 steps (101-120)195 (+7.7%)315 🔴 (+11%)341 🔴 (+9.6%)365 🔴 (+8.0%)19
STSO1020 steps (1001-1020)502 (+12%)619 🔴 (+12%)754 🔴 (+30%) 🔻877 🔴 (+40%) 🔻19
WO1020 steps417644 (+7.6%)417644 (+7.6%)417644 (+7.6%)417644 (+7.6%)1
SLstream latency127 (+49%) 🔻202 🔴 (+67%) 🔻390 🔴 (+185%) 🔻714 🔴 (+212%) 🔻30
SOstream overhead (text)140 (+17%) 🔻221 (+11%)276 (+34%) 🔻356 (+17%) 🔻30
SOstream overhead (structured)113 (+12%)200 (-14%)246 (-9.9%)1053 🔴 (+167%) 🔻30
📜 Previous results (1)

b9f271d

Wed, 29 Jul 2026 19:28:19 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep274 (-53%) 💚1364 🔴 (+30%) 🔻1399 🔴 (+23%) 🔻1472 🔴 (+8.6%)30
TTFSstream224 (+13%)1371 🔴 (+32%) 🔻1397 🔴 (+31%) 🔻1735 🔴 (+41%) 🔻30
TTFShook + stream330 (-73%) 💚1609 🔴 (+20%) 🔻1695 🔴 (+22%) 🔻1843 🔴 (+27%) 🔻30
STSO1020 steps (1-20)189 (+9.9%)285 🔴 (-3.4%)517 🔴 (+59%) 🔻621 🔴 (+82%) 🔻19
STSO1020 steps (101-120)223 (+14%)379 🔴 (+38%) 🔻474 🔴 (+56%) 🔻580 🔴 (+14%)19
STSO1020 steps (1001-1020)508 (+5.6%)608 🔴 (+6.9%)649 🔴 (+8.3%)710 🔴 (+14%)19
WO1020 steps425133 (+4.5%)425133 (+4.5%)425133 (+4.5%)425133 (+4.5%)1
SLstream latency126 (+59%) 🔻239 🔴 (+68%) 🔻407 🔴 (+86%) 🔻825 🔴 (+36%) 🔻30
SOstream overhead (text)172 (+77%) 🔻424 🔴 (+149%) 🔻546 🔴 (+186%) 🔻779 (+203%) 🔻30
SOstream overhead (structured)189 (+82%) 🔻382 🔴 (+89%) 🔻459 (+113%) 🔻666 (+12%)30
ℹ️ Metric definitions & methodology

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actionsBot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

📦 Local Production (2 failed)

nextjs-turbopack-stable (1 failed):

  • webhookWorkflow | wrun_41KYQX591P0GXNMNHKPWR0RG7Y

nextjs-webpack-stable (1 failed):

  • webhookWorkflow | wrun_41KYQX591P0GXNMNHKPWR0RG7Y

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
❌ 📦 Local Production161922271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7517211328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

❌ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
❌ nextjs-turbopack-stable15310
✅ nextjs-webpack-canary135019
❌ nextjs-webpack-stable15310
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

Comment threaddocs/content/docs/v5/configuration/runtime-tuning.mdx Outdated

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the HTTP/2 multiplexing implementation, retry/body handling, stream-write isolation, fallback switch, and regression coverage. No blocking correctness issues found.

Co-authored-by: Nathan Rajlich <n@n8.io>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@VaguelySerious
VaguelySerious merged commit 34975f6 into mainJul 29, 2026
15 checks passed
@VaguelySerious
VaguelySerious deleted the peter/world-vercel-h2-multiplexing branch July 29, 2026 21:38
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 34975f6 (AI decision).

This is a performance/throughput optimization (making the events agent actually multiplex over HTTP/2) plus new API surface (createEventsDispatcher, createStreamDispatcher, STREAM_AGENT_OPTIONS) and a new WORKFLOW_H2_MULTIPLEX config flag — not a fix for a crash, correctness, or data-loss defect on the maintenance line. It also builds on main-only behavior: origin/stable:packages/world-vercel/src/http-client.ts has no H2 events agent at all (allowH2: false, no EVENTS_AGENT_OPTIONS), so there is nothing on stable for this to fix. The accompanying docs page (docs/content/docs/v5/configuration/runtime-tuning.mdx) does not exist on stable either.

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

34975f6b7dd8e0dad874e852eac04c9652f5971d

@github-actions

Copy link
Copy Markdown
Contributor

Backport to stable failed — the cherry-pick had conflicts that could not be resolved automatically (backport job run).

To resolve manually, push a backport branch and open a PR against stable (the workflow never pushes directly to stable). Note: this repository requires verified signatures on every branch, so your local commits must be signed (git config commit.gpgsign true with a configured GPG/SSH signing key, or git cherry-pick -S).

git fetch origin stable
git checkout -b backport/pr-3190-to-stable origin/stable
git cherry-pick -S 34975f6b7dd8e0dad874e852eac04c9652f5971d # -S signs the commit# Fix conflicts, then:
git add -A
git cherry-pick --continue
git push -u origin backport/pr-3190-to-stable
gh pr create --base stable --head backport/pr-3190-to-stable \
--title "Backport #3190: <original PR title>" \
--body "Manual backport of #3190 (cherry-pick 34975f6b7dd8) to \`stable\`."

pranaygp added a commit that referenced this pull request Jul 30, 2026
`vercel.json`'s `env` block is documented as passing variables to the
functions, but Vercel marks the property deprecated, and a flag that
silently fails to arrive would make a null result read as "multiplexing is
not the cause" — the one outcome where being wrong is expensive.
instrumentation.ts now supplies the value if the deployment did not, and
records which source won so the probe route can report it. That also tells
us where mitigation advice should point if #3190 owns the regression.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

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

[world-vercel] Make HTTP/2 actually multiplex on the events path - #3190

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/world-vercel-h2-multiplexing
Jul 29, 2026
Merged

[world-vercel] Make HTTP/2 actually multiplex on the events path#3190
VaguelySerious merged 2 commits into
mainfrom
peter/world-vercel-h2-multiplexing

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

Problem

The events agent sets allowH2: true and does negotiate ALPN h2 — but it never multiplexed. Measured against api.vercel.com with a warmed pool, 16 concurrent event writes produced 8 in-flight requests over 8 TCP connections: byte-for-byte identical to the HTTP/1.1 default agent. The existing tests asserted EVENTS_AGENT_OPTIONS.allowH2 === true and that ALPN negotiates h2 — both true, neither measures concurrency.

Cause

Three independent undici gates each cap in-flight requests at one per connection. All three had to be lifted; any one of them left alone keeps the agent serialized.

  1. pipelining: 1, inherited from the shared base options. undici gates in-flight requests per connection on client[kPipelining] ?? httpContext.defaultPipelining ?? 1. client-h2.js sets defaultPipelining: Infinity, but the Client constructor always coerces pipelining to a number, so kPipelining is never nullish and H2's Infinity is unreachable — nodejs/undici#4143.
  2. Non-idempotent method.client-h2.js reports the connection busy whenever request.idempotent === false, and undici only treats GET/HEAD as idempotent by default. Every event write is a POST — nodejs/undici#5390.
  3. Streamed request body.client-h2.js also reports busy for a stream / async-iterable body. events-v4 hands over a materialized Uint8Array, but it dispatches through the global fetch — deliberately, since that is what keeps events traffic visible in the outgoing-requests view — and fetch streamifies every body on the way down.

Verified matrix (16 concurrent POSTs): pipelining alone → 8 in-flight. idempotent: true alone → 8 in-flight. Both, plus a buffered body → 16 in-flight, 0 new connections.

Change

  • pipelining is split out of the shared base options so each agent sets it explicitly — undici overloads that one option to mean both "H1 pipelining depth" and "max in-flight H2 streams per connection", and the two paths want opposite values. The H1 default keeps pipelining: 1 (it deadlocks the webhook respondWith mechanism otherwise).
  • An interceptor marks events requests idempotent and re-buffers streamed bodies under 1 MiB. Marking them idempotent affects concurrency only: in undici 7 the flag feeds nothing but the busy() gates, and retries are governed solely by RetryAgent, whose default methods excludes POST — so an event write is still never resent.
  • The interceptor wraps the RetryAgent rather than the Agent inside it. RetryHandler captures its own copy of the body up front (wrapRequestBody), so composing it inside would leave a retry re-iterating the stream the interceptor had already consumed, sending an empty body. Composed outside, the retry replays the drained Buffer. There is a test for exactly this.
  • Stream write/close get their own STREAM_AGENT_OPTIONS and deliberately do not multiplex. Appends are not idempotent, and STREAM_RETRY_OPTIONS retries PUT on transient connection errors — multiplexing would let a single RST_STREAM/GOAWAY fail, and then re-send, several already-applied appends at once. This preserves today's one-request-per-connection failure isolation. (Belt-and-braces: gate 2 already serializes PUTs, but pinning pipelining: 1 means the safety property doesn't silently depend on that upstream detail.)
  • Escape hatch WORKFLOW_H2_MULTIPLEX=0 restores one request per connection, documented in Runtime Tuning.

Verification

Against real api.vercel.com, 16 concurrent POSTs after warming the pool:

dispatcherALPNnew connections for burst
events (before)h27
events (after)h20
stream writeh27 (intended)
events, WORKFLOW_H2_MULTIPLEX=0h27

New tests run a real H2 loopback server through the production factory functions and hold requests open on a barrier to observe peak concurrency: one asserts the events dispatcher reaches 16 concurrent streams with no new connections, one asserts stream writes stay bounded by the pool, one asserts a re-buffered body survives a retry intact, one covers the kill switch. Reverting either the pipelining value or the interceptor composition fails these tests.

pnpm typecheck and all 309 world-vercel unit tests pass.

The events agent sets `allowH2: true` and does negotiate ALPN h2, but it
never multiplexed: 16 concurrent event writes produced 8 in-flight
requests over 8 TCP connections — byte-for-byte identical to the HTTP/1.1
default agent.
Three independent undici gates each capped in-flight requests at one per
connection, and all three had to be lifted:
1. `pipelining: 1`, inherited from the shared base options. undici gates
in-flight requests on `client[kPipelining] ?? defaultPipelining ?? 1`;
`client-h2.js` sets `defaultPipelining: Infinity`, but the Client
constructor always coerces `pipelining` to a number, so that value is
unreachable (nodejs/undici#4143).
2. `client-h2.js` reports the connection busy for any request with
`idempotent === false`, and only GET/HEAD are idempotent by default —
every event write is a POST (nodejs/undici#5390).
3. `client-h2.js` also reports busy for a stream/async-iterable body.
events-v4 passes a materialized `Uint8Array`, but it dispatches via
global `fetch` (deliberately, for outgoing-request observability),
which streamifies every body.
`pipelining` is split out of the shared base so each agent sets it
explicitly, and an interceptor marks events requests idempotent and
re-buffers small streamed bodies. Marking them idempotent affects
concurrency only: RetryAgent's default `methods` excludes POST, so an
event write is still never resent.
Stream write/close move to their own options and deliberately do NOT
multiplex: appends are not idempotent, and STREAM_RETRY_OPTIONS retries
PUT on transient connection errors, so multiplexing would let one reset
fail — and then re-send — several already-applied appends at once.
Verified against api.vercel.com, 16 concurrent POSTs after warming the
pool: events 0 new connections (was 7), stream writes 7,
WORKFLOW_H2_MULTIPLEX=0 back to 7.
@VaguelySerious
VaguelySerious requested review from a team and ijjk as code ownersJuly 29, 2026 19:04
@changeset-bot

changeset-botBot commented Jul 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6883125

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

This PR includes changesets to release 17 packages
NameType
@workflow/world-vercelPatch
@workflow/cliPatch
@workflow/corePatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@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 Jul 29, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewJul 29, 2026 9:42pm
example-nextjs-workflow-webpackReadyReadyPreviewJul 29, 2026 9:42pm
example-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-astro-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-express-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-fastify-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-hono-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-nestjs-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-nitro-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-nuxt-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-sveltekit-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-tanstack-start-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-vite-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workflow-docsReadyReadyPreview, v0Jul 29, 2026 9:42pm
workflow-swc-playgroundReadyReadyPreviewJul 29, 2026 9:42pm
workflow-tarballsReadyReadyPreviewJul 29, 2026 9:42pm
workflow-webReadyReadyPreviewJul 29, 2026 9:42pm

@github-actions

github-actionsBot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 6883125 · Wed, 29 Jul 2026 21:59:44 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep262 (-71%) 💚1376 🔴 (+38%) 🔻1426 🔴 (+38%) 🔻1441 🔴 (+10%)30
TTFSstream249 (-73%) 💚1353 🔴 (+36%) 🔻1370 🔴 (+35%) 🔻1541 🔴 (+48%) 🔻30
TTFShook + stream359 (-22%) 💚1597 🔴 (+24%) 🔻1626 🔴 (+22%) 🔻1748 🔴 (-16%) 💚30
STSO1020 steps (1-20)175 (±0%)286 🔴 (±0%)380 🔴 (+14%)547 🔴 (+3.6%)19
STSO1020 steps (101-120)195 (+7.7%)315 🔴 (+11%)341 🔴 (+9.6%)365 🔴 (+8.0%)19
STSO1020 steps (1001-1020)502 (+12%)619 🔴 (+12%)754 🔴 (+30%) 🔻877 🔴 (+40%) 🔻19
WO1020 steps417644 (+7.6%)417644 (+7.6%)417644 (+7.6%)417644 (+7.6%)1
SLstream latency127 (+49%) 🔻202 🔴 (+67%) 🔻390 🔴 (+185%) 🔻714 🔴 (+212%) 🔻30
SOstream overhead (text)140 (+17%) 🔻221 (+11%)276 (+34%) 🔻356 (+17%) 🔻30
SOstream overhead (structured)113 (+12%)200 (-14%)246 (-9.9%)1053 🔴 (+167%) 🔻30
📜 Previous results (1)

b9f271d

Wed, 29 Jul 2026 19:28:19 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep274 (-53%) 💚1364 🔴 (+30%) 🔻1399 🔴 (+23%) 🔻1472 🔴 (+8.6%)30
TTFSstream224 (+13%)1371 🔴 (+32%) 🔻1397 🔴 (+31%) 🔻1735 🔴 (+41%) 🔻30
TTFShook + stream330 (-73%) 💚1609 🔴 (+20%) 🔻1695 🔴 (+22%) 🔻1843 🔴 (+27%) 🔻30
STSO1020 steps (1-20)189 (+9.9%)285 🔴 (-3.4%)517 🔴 (+59%) 🔻621 🔴 (+82%) 🔻19
STSO1020 steps (101-120)223 (+14%)379 🔴 (+38%) 🔻474 🔴 (+56%) 🔻580 🔴 (+14%)19
STSO1020 steps (1001-1020)508 (+5.6%)608 🔴 (+6.9%)649 🔴 (+8.3%)710 🔴 (+14%)19
WO1020 steps425133 (+4.5%)425133 (+4.5%)425133 (+4.5%)425133 (+4.5%)1
SLstream latency126 (+59%) 🔻239 🔴 (+68%) 🔻407 🔴 (+86%) 🔻825 🔴 (+36%) 🔻30
SOstream overhead (text)172 (+77%) 🔻424 🔴 (+149%) 🔻546 🔴 (+186%) 🔻779 (+203%) 🔻30
SOstream overhead (structured)189 (+82%) 🔻382 🔴 (+89%) 🔻459 (+113%) 🔻666 (+12%)30
ℹ️ Metric definitions & methodology

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actionsBot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

📦 Local Production (2 failed)

nextjs-turbopack-stable (1 failed):

  • webhookWorkflow | wrun_41KYQX591P0GXNMNHKPWR0RG7Y

nextjs-webpack-stable (1 failed):

  • webhookWorkflow | wrun_41KYQX591P0GXNMNHKPWR0RG7Y

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
❌ 📦 Local Production161922271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7517211328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

❌ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
❌ nextjs-turbopack-stable15310
✅ nextjs-webpack-canary135019
❌ nextjs-webpack-stable15310
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

Comment threaddocs/content/docs/v5/configuration/runtime-tuning.mdx Outdated

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the HTTP/2 multiplexing implementation, retry/body handling, stream-write isolation, fallback switch, and regression coverage. No blocking correctness issues found.

Co-authored-by: Nathan Rajlich <n@n8.io>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@VaguelySerious
VaguelySerious merged commit 34975f6 into mainJul 29, 2026
15 checks passed
@VaguelySerious
VaguelySerious deleted the peter/world-vercel-h2-multiplexing branch July 29, 2026 21:38
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 34975f6 (AI decision).

This is a performance/throughput optimization (making the events agent actually multiplex over HTTP/2) plus new API surface (createEventsDispatcher, createStreamDispatcher, STREAM_AGENT_OPTIONS) and a new WORKFLOW_H2_MULTIPLEX config flag — not a fix for a crash, correctness, or data-loss defect on the maintenance line. It also builds on main-only behavior: origin/stable:packages/world-vercel/src/http-client.ts has no H2 events agent at all (allowH2: false, no EVENTS_AGENT_OPTIONS), so there is nothing on stable for this to fix. The accompanying docs page (docs/content/docs/v5/configuration/runtime-tuning.mdx) does not exist on stable either.

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

34975f6b7dd8e0dad874e852eac04c9652f5971d

@github-actions

Copy link
Copy Markdown
Contributor

Backport to stable failed — the cherry-pick had conflicts that could not be resolved automatically (backport job run).

To resolve manually, push a backport branch and open a PR against stable (the workflow never pushes directly to stable). Note: this repository requires verified signatures on every branch, so your local commits must be signed (git config commit.gpgsign true with a configured GPG/SSH signing key, or git cherry-pick -S).

git fetch origin stable
git checkout -b backport/pr-3190-to-stable origin/stable
git cherry-pick -S 34975f6b7dd8e0dad874e852eac04c9652f5971d # -S signs the commit# Fix conflicts, then:
git add -A
git cherry-pick --continue
git push -u origin backport/pr-3190-to-stable
gh pr create --base stable --head backport/pr-3190-to-stable \
--title "Backport #3190: <original PR title>" \
--body "Manual backport of #3190 (cherry-pick 34975f6b7dd8) to \`stable\`."

pranaygp added a commit that referenced this pull request Jul 30, 2026
`vercel.json`'s `env` block is documented as passing variables to the
functions, but Vercel marks the property deprecated, and a flag that
silently fails to arrive would make a null result read as "multiplexing is
not the cause" — the one outcome where being wrong is expensive.
instrumentation.ts now supplies the value if the deployment did not, and
records which source won so the probe route can report it. That also tells
us where mitigation advice should point if #3190 owns the regression.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

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

[world-vercel] Make HTTP/2 actually multiplex on the events path - #3190

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/world-vercel-h2-multiplexing
Jul 29, 2026
Merged

[world-vercel] Make HTTP/2 actually multiplex on the events path#3190
VaguelySerious merged 2 commits into
mainfrom
peter/world-vercel-h2-multiplexing

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

Problem

The events agent sets allowH2: true and does negotiate ALPN h2 — but it never multiplexed. Measured against api.vercel.com with a warmed pool, 16 concurrent event writes produced 8 in-flight requests over 8 TCP connections: byte-for-byte identical to the HTTP/1.1 default agent. The existing tests asserted EVENTS_AGENT_OPTIONS.allowH2 === true and that ALPN negotiates h2 — both true, neither measures concurrency.

Cause

Three independent undici gates each cap in-flight requests at one per connection. All three had to be lifted; any one of them left alone keeps the agent serialized.

  1. pipelining: 1, inherited from the shared base options. undici gates in-flight requests per connection on client[kPipelining] ?? httpContext.defaultPipelining ?? 1. client-h2.js sets defaultPipelining: Infinity, but the Client constructor always coerces pipelining to a number, so kPipelining is never nullish and H2's Infinity is unreachable — nodejs/undici#4143.
  2. Non-idempotent method.client-h2.js reports the connection busy whenever request.idempotent === false, and undici only treats GET/HEAD as idempotent by default. Every event write is a POST — nodejs/undici#5390.
  3. Streamed request body.client-h2.js also reports busy for a stream / async-iterable body. events-v4 hands over a materialized Uint8Array, but it dispatches through the global fetch — deliberately, since that is what keeps events traffic visible in the outgoing-requests view — and fetch streamifies every body on the way down.

Verified matrix (16 concurrent POSTs): pipelining alone → 8 in-flight. idempotent: true alone → 8 in-flight. Both, plus a buffered body → 16 in-flight, 0 new connections.

Change

  • pipelining is split out of the shared base options so each agent sets it explicitly — undici overloads that one option to mean both "H1 pipelining depth" and "max in-flight H2 streams per connection", and the two paths want opposite values. The H1 default keeps pipelining: 1 (it deadlocks the webhook respondWith mechanism otherwise).
  • An interceptor marks events requests idempotent and re-buffers streamed bodies under 1 MiB. Marking them idempotent affects concurrency only: in undici 7 the flag feeds nothing but the busy() gates, and retries are governed solely by RetryAgent, whose default methods excludes POST — so an event write is still never resent.
  • The interceptor wraps the RetryAgent rather than the Agent inside it. RetryHandler captures its own copy of the body up front (wrapRequestBody), so composing it inside would leave a retry re-iterating the stream the interceptor had already consumed, sending an empty body. Composed outside, the retry replays the drained Buffer. There is a test for exactly this.
  • Stream write/close get their own STREAM_AGENT_OPTIONS and deliberately do not multiplex. Appends are not idempotent, and STREAM_RETRY_OPTIONS retries PUT on transient connection errors — multiplexing would let a single RST_STREAM/GOAWAY fail, and then re-send, several already-applied appends at once. This preserves today's one-request-per-connection failure isolation. (Belt-and-braces: gate 2 already serializes PUTs, but pinning pipelining: 1 means the safety property doesn't silently depend on that upstream detail.)
  • Escape hatch WORKFLOW_H2_MULTIPLEX=0 restores one request per connection, documented in Runtime Tuning.

Verification

Against real api.vercel.com, 16 concurrent POSTs after warming the pool:

dispatcherALPNnew connections for burst
events (before)h27
events (after)h20
stream writeh27 (intended)
events, WORKFLOW_H2_MULTIPLEX=0h27

New tests run a real H2 loopback server through the production factory functions and hold requests open on a barrier to observe peak concurrency: one asserts the events dispatcher reaches 16 concurrent streams with no new connections, one asserts stream writes stay bounded by the pool, one asserts a re-buffered body survives a retry intact, one covers the kill switch. Reverting either the pipelining value or the interceptor composition fails these tests.

pnpm typecheck and all 309 world-vercel unit tests pass.

The events agent sets `allowH2: true` and does negotiate ALPN h2, but it
never multiplexed: 16 concurrent event writes produced 8 in-flight
requests over 8 TCP connections — byte-for-byte identical to the HTTP/1.1
default agent.
Three independent undici gates each capped in-flight requests at one per
connection, and all three had to be lifted:
1. `pipelining: 1`, inherited from the shared base options. undici gates
in-flight requests on `client[kPipelining] ?? defaultPipelining ?? 1`;
`client-h2.js` sets `defaultPipelining: Infinity`, but the Client
constructor always coerces `pipelining` to a number, so that value is
unreachable (nodejs/undici#4143).
2. `client-h2.js` reports the connection busy for any request with
`idempotent === false`, and only GET/HEAD are idempotent by default —
every event write is a POST (nodejs/undici#5390).
3. `client-h2.js` also reports busy for a stream/async-iterable body.
events-v4 passes a materialized `Uint8Array`, but it dispatches via
global `fetch` (deliberately, for outgoing-request observability),
which streamifies every body.
`pipelining` is split out of the shared base so each agent sets it
explicitly, and an interceptor marks events requests idempotent and
re-buffers small streamed bodies. Marking them idempotent affects
concurrency only: RetryAgent's default `methods` excludes POST, so an
event write is still never resent.
Stream write/close move to their own options and deliberately do NOT
multiplex: appends are not idempotent, and STREAM_RETRY_OPTIONS retries
PUT on transient connection errors, so multiplexing would let one reset
fail — and then re-send — several already-applied appends at once.
Verified against api.vercel.com, 16 concurrent POSTs after warming the
pool: events 0 new connections (was 7), stream writes 7,
WORKFLOW_H2_MULTIPLEX=0 back to 7.
@VaguelySerious
VaguelySerious requested review from a team and ijjk as code ownersJuly 29, 2026 19:04
@changeset-bot

changeset-botBot commented Jul 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6883125

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

This PR includes changesets to release 17 packages
NameType
@workflow/world-vercelPatch
@workflow/cliPatch
@workflow/corePatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@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 Jul 29, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewJul 29, 2026 9:42pm
example-nextjs-workflow-webpackReadyReadyPreviewJul 29, 2026 9:42pm
example-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-astro-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-express-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-fastify-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-hono-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-nestjs-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-nitro-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-nuxt-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-sveltekit-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-tanstack-start-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-vite-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workflow-docsReadyReadyPreview, v0Jul 29, 2026 9:42pm
workflow-swc-playgroundReadyReadyPreviewJul 29, 2026 9:42pm
workflow-tarballsReadyReadyPreviewJul 29, 2026 9:42pm
workflow-webReadyReadyPreviewJul 29, 2026 9:42pm

@github-actions

github-actionsBot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 6883125 · Wed, 29 Jul 2026 21:59:44 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep262 (-71%) 💚1376 🔴 (+38%) 🔻1426 🔴 (+38%) 🔻1441 🔴 (+10%)30
TTFSstream249 (-73%) 💚1353 🔴 (+36%) 🔻1370 🔴 (+35%) 🔻1541 🔴 (+48%) 🔻30
TTFShook + stream359 (-22%) 💚1597 🔴 (+24%) 🔻1626 🔴 (+22%) 🔻1748 🔴 (-16%) 💚30
STSO1020 steps (1-20)175 (±0%)286 🔴 (±0%)380 🔴 (+14%)547 🔴 (+3.6%)19
STSO1020 steps (101-120)195 (+7.7%)315 🔴 (+11%)341 🔴 (+9.6%)365 🔴 (+8.0%)19
STSO1020 steps (1001-1020)502 (+12%)619 🔴 (+12%)754 🔴 (+30%) 🔻877 🔴 (+40%) 🔻19
WO1020 steps417644 (+7.6%)417644 (+7.6%)417644 (+7.6%)417644 (+7.6%)1
SLstream latency127 (+49%) 🔻202 🔴 (+67%) 🔻390 🔴 (+185%) 🔻714 🔴 (+212%) 🔻30
SOstream overhead (text)140 (+17%) 🔻221 (+11%)276 (+34%) 🔻356 (+17%) 🔻30
SOstream overhead (structured)113 (+12%)200 (-14%)246 (-9.9%)1053 🔴 (+167%) 🔻30
📜 Previous results (1)

b9f271d

Wed, 29 Jul 2026 19:28:19 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep274 (-53%) 💚1364 🔴 (+30%) 🔻1399 🔴 (+23%) 🔻1472 🔴 (+8.6%)30
TTFSstream224 (+13%)1371 🔴 (+32%) 🔻1397 🔴 (+31%) 🔻1735 🔴 (+41%) 🔻30
TTFShook + stream330 (-73%) 💚1609 🔴 (+20%) 🔻1695 🔴 (+22%) 🔻1843 🔴 (+27%) 🔻30
STSO1020 steps (1-20)189 (+9.9%)285 🔴 (-3.4%)517 🔴 (+59%) 🔻621 🔴 (+82%) 🔻19
STSO1020 steps (101-120)223 (+14%)379 🔴 (+38%) 🔻474 🔴 (+56%) 🔻580 🔴 (+14%)19
STSO1020 steps (1001-1020)508 (+5.6%)608 🔴 (+6.9%)649 🔴 (+8.3%)710 🔴 (+14%)19
WO1020 steps425133 (+4.5%)425133 (+4.5%)425133 (+4.5%)425133 (+4.5%)1
SLstream latency126 (+59%) 🔻239 🔴 (+68%) 🔻407 🔴 (+86%) 🔻825 🔴 (+36%) 🔻30
SOstream overhead (text)172 (+77%) 🔻424 🔴 (+149%) 🔻546 🔴 (+186%) 🔻779 (+203%) 🔻30
SOstream overhead (structured)189 (+82%) 🔻382 🔴 (+89%) 🔻459 (+113%) 🔻666 (+12%)30
ℹ️ Metric definitions & methodology

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actionsBot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

📦 Local Production (2 failed)

nextjs-turbopack-stable (1 failed):

  • webhookWorkflow | wrun_41KYQX591P0GXNMNHKPWR0RG7Y

nextjs-webpack-stable (1 failed):

  • webhookWorkflow | wrun_41KYQX591P0GXNMNHKPWR0RG7Y

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
❌ 📦 Local Production161922271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7517211328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

❌ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
❌ nextjs-turbopack-stable15310
✅ nextjs-webpack-canary135019
❌ nextjs-webpack-stable15310
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

Comment threaddocs/content/docs/v5/configuration/runtime-tuning.mdx Outdated

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the HTTP/2 multiplexing implementation, retry/body handling, stream-write isolation, fallback switch, and regression coverage. No blocking correctness issues found.

Co-authored-by: Nathan Rajlich <n@n8.io>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@VaguelySerious
VaguelySerious merged commit 34975f6 into mainJul 29, 2026
15 checks passed
@VaguelySerious
VaguelySerious deleted the peter/world-vercel-h2-multiplexing branch July 29, 2026 21:38
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 34975f6 (AI decision).

This is a performance/throughput optimization (making the events agent actually multiplex over HTTP/2) plus new API surface (createEventsDispatcher, createStreamDispatcher, STREAM_AGENT_OPTIONS) and a new WORKFLOW_H2_MULTIPLEX config flag — not a fix for a crash, correctness, or data-loss defect on the maintenance line. It also builds on main-only behavior: origin/stable:packages/world-vercel/src/http-client.ts has no H2 events agent at all (allowH2: false, no EVENTS_AGENT_OPTIONS), so there is nothing on stable for this to fix. The accompanying docs page (docs/content/docs/v5/configuration/runtime-tuning.mdx) does not exist on stable either.

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

34975f6b7dd8e0dad874e852eac04c9652f5971d

@github-actions

Copy link
Copy Markdown
Contributor

Backport to stable failed — the cherry-pick had conflicts that could not be resolved automatically (backport job run).

To resolve manually, push a backport branch and open a PR against stable (the workflow never pushes directly to stable). Note: this repository requires verified signatures on every branch, so your local commits must be signed (git config commit.gpgsign true with a configured GPG/SSH signing key, or git cherry-pick -S).

git fetch origin stable
git checkout -b backport/pr-3190-to-stable origin/stable
git cherry-pick -S 34975f6b7dd8e0dad874e852eac04c9652f5971d # -S signs the commit# Fix conflicts, then:
git add -A
git cherry-pick --continue
git push -u origin backport/pr-3190-to-stable
gh pr create --base stable --head backport/pr-3190-to-stable \
--title "Backport #3190: <original PR title>" \
--body "Manual backport of #3190 (cherry-pick 34975f6b7dd8) to \`stable\`."

pranaygp added a commit that referenced this pull request Jul 30, 2026
`vercel.json`'s `env` block is documented as passing variables to the
functions, but Vercel marks the property deprecated, and a flag that
silently fails to arrive would make a null result read as "multiplexing is
not the cause" — the one outcome where being wrong is expensive.
instrumentation.ts now supplies the value if the deployment did not, and
records which source won so the probe route can report it. That also tells
us where mitigation advice should point if #3190 owns the regression.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

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

[world-vercel] Make HTTP/2 actually multiplex on the events path - #3190

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/world-vercel-h2-multiplexing
Jul 29, 2026
Merged

[world-vercel] Make HTTP/2 actually multiplex on the events path#3190
VaguelySerious merged 2 commits into
mainfrom
peter/world-vercel-h2-multiplexing

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

Problem

The events agent sets allowH2: true and does negotiate ALPN h2 — but it never multiplexed. Measured against api.vercel.com with a warmed pool, 16 concurrent event writes produced 8 in-flight requests over 8 TCP connections: byte-for-byte identical to the HTTP/1.1 default agent. The existing tests asserted EVENTS_AGENT_OPTIONS.allowH2 === true and that ALPN negotiates h2 — both true, neither measures concurrency.

Cause

Three independent undici gates each cap in-flight requests at one per connection. All three had to be lifted; any one of them left alone keeps the agent serialized.

  1. pipelining: 1, inherited from the shared base options. undici gates in-flight requests per connection on client[kPipelining] ?? httpContext.defaultPipelining ?? 1. client-h2.js sets defaultPipelining: Infinity, but the Client constructor always coerces pipelining to a number, so kPipelining is never nullish and H2's Infinity is unreachable — nodejs/undici#4143.
  2. Non-idempotent method.client-h2.js reports the connection busy whenever request.idempotent === false, and undici only treats GET/HEAD as idempotent by default. Every event write is a POST — nodejs/undici#5390.
  3. Streamed request body.client-h2.js also reports busy for a stream / async-iterable body. events-v4 hands over a materialized Uint8Array, but it dispatches through the global fetch — deliberately, since that is what keeps events traffic visible in the outgoing-requests view — and fetch streamifies every body on the way down.

Verified matrix (16 concurrent POSTs): pipelining alone → 8 in-flight. idempotent: true alone → 8 in-flight. Both, plus a buffered body → 16 in-flight, 0 new connections.

Change

  • pipelining is split out of the shared base options so each agent sets it explicitly — undici overloads that one option to mean both "H1 pipelining depth" and "max in-flight H2 streams per connection", and the two paths want opposite values. The H1 default keeps pipelining: 1 (it deadlocks the webhook respondWith mechanism otherwise).
  • An interceptor marks events requests idempotent and re-buffers streamed bodies under 1 MiB. Marking them idempotent affects concurrency only: in undici 7 the flag feeds nothing but the busy() gates, and retries are governed solely by RetryAgent, whose default methods excludes POST — so an event write is still never resent.
  • The interceptor wraps the RetryAgent rather than the Agent inside it. RetryHandler captures its own copy of the body up front (wrapRequestBody), so composing it inside would leave a retry re-iterating the stream the interceptor had already consumed, sending an empty body. Composed outside, the retry replays the drained Buffer. There is a test for exactly this.
  • Stream write/close get their own STREAM_AGENT_OPTIONS and deliberately do not multiplex. Appends are not idempotent, and STREAM_RETRY_OPTIONS retries PUT on transient connection errors — multiplexing would let a single RST_STREAM/GOAWAY fail, and then re-send, several already-applied appends at once. This preserves today's one-request-per-connection failure isolation. (Belt-and-braces: gate 2 already serializes PUTs, but pinning pipelining: 1 means the safety property doesn't silently depend on that upstream detail.)
  • Escape hatch WORKFLOW_H2_MULTIPLEX=0 restores one request per connection, documented in Runtime Tuning.

Verification

Against real api.vercel.com, 16 concurrent POSTs after warming the pool:

dispatcherALPNnew connections for burst
events (before)h27
events (after)h20
stream writeh27 (intended)
events, WORKFLOW_H2_MULTIPLEX=0h27

New tests run a real H2 loopback server through the production factory functions and hold requests open on a barrier to observe peak concurrency: one asserts the events dispatcher reaches 16 concurrent streams with no new connections, one asserts stream writes stay bounded by the pool, one asserts a re-buffered body survives a retry intact, one covers the kill switch. Reverting either the pipelining value or the interceptor composition fails these tests.

pnpm typecheck and all 309 world-vercel unit tests pass.

The events agent sets `allowH2: true` and does negotiate ALPN h2, but it
never multiplexed: 16 concurrent event writes produced 8 in-flight
requests over 8 TCP connections — byte-for-byte identical to the HTTP/1.1
default agent.
Three independent undici gates each capped in-flight requests at one per
connection, and all three had to be lifted:
1. `pipelining: 1`, inherited from the shared base options. undici gates
in-flight requests on `client[kPipelining] ?? defaultPipelining ?? 1`;
`client-h2.js` sets `defaultPipelining: Infinity`, but the Client
constructor always coerces `pipelining` to a number, so that value is
unreachable (nodejs/undici#4143).
2. `client-h2.js` reports the connection busy for any request with
`idempotent === false`, and only GET/HEAD are idempotent by default —
every event write is a POST (nodejs/undici#5390).
3. `client-h2.js` also reports busy for a stream/async-iterable body.
events-v4 passes a materialized `Uint8Array`, but it dispatches via
global `fetch` (deliberately, for outgoing-request observability),
which streamifies every body.
`pipelining` is split out of the shared base so each agent sets it
explicitly, and an interceptor marks events requests idempotent and
re-buffers small streamed bodies. Marking them idempotent affects
concurrency only: RetryAgent's default `methods` excludes POST, so an
event write is still never resent.
Stream write/close move to their own options and deliberately do NOT
multiplex: appends are not idempotent, and STREAM_RETRY_OPTIONS retries
PUT on transient connection errors, so multiplexing would let one reset
fail — and then re-send — several already-applied appends at once.
Verified against api.vercel.com, 16 concurrent POSTs after warming the
pool: events 0 new connections (was 7), stream writes 7,
WORKFLOW_H2_MULTIPLEX=0 back to 7.
@VaguelySerious
VaguelySerious requested review from a team and ijjk as code ownersJuly 29, 2026 19:04
@changeset-bot

changeset-botBot commented Jul 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6883125

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

This PR includes changesets to release 17 packages
NameType
@workflow/world-vercelPatch
@workflow/cliPatch
@workflow/corePatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@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 Jul 29, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewJul 29, 2026 9:42pm
example-nextjs-workflow-webpackReadyReadyPreviewJul 29, 2026 9:42pm
example-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-astro-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-express-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-fastify-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-hono-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-nestjs-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-nitro-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-nuxt-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-sveltekit-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-tanstack-start-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-vite-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workflow-docsReadyReadyPreview, v0Jul 29, 2026 9:42pm
workflow-swc-playgroundReadyReadyPreviewJul 29, 2026 9:42pm
workflow-tarballsReadyReadyPreviewJul 29, 2026 9:42pm
workflow-webReadyReadyPreviewJul 29, 2026 9:42pm

@github-actions

github-actionsBot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 6883125 · Wed, 29 Jul 2026 21:59:44 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep262 (-71%) 💚1376 🔴 (+38%) 🔻1426 🔴 (+38%) 🔻1441 🔴 (+10%)30
TTFSstream249 (-73%) 💚1353 🔴 (+36%) 🔻1370 🔴 (+35%) 🔻1541 🔴 (+48%) 🔻30
TTFShook + stream359 (-22%) 💚1597 🔴 (+24%) 🔻1626 🔴 (+22%) 🔻1748 🔴 (-16%) 💚30
STSO1020 steps (1-20)175 (±0%)286 🔴 (±0%)380 🔴 (+14%)547 🔴 (+3.6%)19
STSO1020 steps (101-120)195 (+7.7%)315 🔴 (+11%)341 🔴 (+9.6%)365 🔴 (+8.0%)19
STSO1020 steps (1001-1020)502 (+12%)619 🔴 (+12%)754 🔴 (+30%) 🔻877 🔴 (+40%) 🔻19
WO1020 steps417644 (+7.6%)417644 (+7.6%)417644 (+7.6%)417644 (+7.6%)1
SLstream latency127 (+49%) 🔻202 🔴 (+67%) 🔻390 🔴 (+185%) 🔻714 🔴 (+212%) 🔻30
SOstream overhead (text)140 (+17%) 🔻221 (+11%)276 (+34%) 🔻356 (+17%) 🔻30
SOstream overhead (structured)113 (+12%)200 (-14%)246 (-9.9%)1053 🔴 (+167%) 🔻30
📜 Previous results (1)

b9f271d

Wed, 29 Jul 2026 19:28:19 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep274 (-53%) 💚1364 🔴 (+30%) 🔻1399 🔴 (+23%) 🔻1472 🔴 (+8.6%)30
TTFSstream224 (+13%)1371 🔴 (+32%) 🔻1397 🔴 (+31%) 🔻1735 🔴 (+41%) 🔻30
TTFShook + stream330 (-73%) 💚1609 🔴 (+20%) 🔻1695 🔴 (+22%) 🔻1843 🔴 (+27%) 🔻30
STSO1020 steps (1-20)189 (+9.9%)285 🔴 (-3.4%)517 🔴 (+59%) 🔻621 🔴 (+82%) 🔻19
STSO1020 steps (101-120)223 (+14%)379 🔴 (+38%) 🔻474 🔴 (+56%) 🔻580 🔴 (+14%)19
STSO1020 steps (1001-1020)508 (+5.6%)608 🔴 (+6.9%)649 🔴 (+8.3%)710 🔴 (+14%)19
WO1020 steps425133 (+4.5%)425133 (+4.5%)425133 (+4.5%)425133 (+4.5%)1
SLstream latency126 (+59%) 🔻239 🔴 (+68%) 🔻407 🔴 (+86%) 🔻825 🔴 (+36%) 🔻30
SOstream overhead (text)172 (+77%) 🔻424 🔴 (+149%) 🔻546 🔴 (+186%) 🔻779 (+203%) 🔻30
SOstream overhead (structured)189 (+82%) 🔻382 🔴 (+89%) 🔻459 (+113%) 🔻666 (+12%)30
ℹ️ Metric definitions & methodology

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actionsBot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

📦 Local Production (2 failed)

nextjs-turbopack-stable (1 failed):

  • webhookWorkflow | wrun_41KYQX591P0GXNMNHKPWR0RG7Y

nextjs-webpack-stable (1 failed):

  • webhookWorkflow | wrun_41KYQX591P0GXNMNHKPWR0RG7Y

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
❌ 📦 Local Production161922271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7517211328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

❌ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
❌ nextjs-turbopack-stable15310
✅ nextjs-webpack-canary135019
❌ nextjs-webpack-stable15310
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

Comment threaddocs/content/docs/v5/configuration/runtime-tuning.mdx Outdated

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the HTTP/2 multiplexing implementation, retry/body handling, stream-write isolation, fallback switch, and regression coverage. No blocking correctness issues found.

Co-authored-by: Nathan Rajlich <n@n8.io>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@VaguelySerious
VaguelySerious merged commit 34975f6 into mainJul 29, 2026
15 checks passed
@VaguelySerious
VaguelySerious deleted the peter/world-vercel-h2-multiplexing branch July 29, 2026 21:38
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 34975f6 (AI decision).

This is a performance/throughput optimization (making the events agent actually multiplex over HTTP/2) plus new API surface (createEventsDispatcher, createStreamDispatcher, STREAM_AGENT_OPTIONS) and a new WORKFLOW_H2_MULTIPLEX config flag — not a fix for a crash, correctness, or data-loss defect on the maintenance line. It also builds on main-only behavior: origin/stable:packages/world-vercel/src/http-client.ts has no H2 events agent at all (allowH2: false, no EVENTS_AGENT_OPTIONS), so there is nothing on stable for this to fix. The accompanying docs page (docs/content/docs/v5/configuration/runtime-tuning.mdx) does not exist on stable either.

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

34975f6b7dd8e0dad874e852eac04c9652f5971d

@github-actions

Copy link
Copy Markdown
Contributor

Backport to stable failed — the cherry-pick had conflicts that could not be resolved automatically (backport job run).

To resolve manually, push a backport branch and open a PR against stable (the workflow never pushes directly to stable). Note: this repository requires verified signatures on every branch, so your local commits must be signed (git config commit.gpgsign true with a configured GPG/SSH signing key, or git cherry-pick -S).

git fetch origin stable
git checkout -b backport/pr-3190-to-stable origin/stable
git cherry-pick -S 34975f6b7dd8e0dad874e852eac04c9652f5971d # -S signs the commit# Fix conflicts, then:
git add -A
git cherry-pick --continue
git push -u origin backport/pr-3190-to-stable
gh pr create --base stable --head backport/pr-3190-to-stable \
--title "Backport #3190: <original PR title>" \
--body "Manual backport of #3190 (cherry-pick 34975f6b7dd8) to \`stable\`."

pranaygp added a commit that referenced this pull request Jul 30, 2026
`vercel.json`'s `env` block is documented as passing variables to the
functions, but Vercel marks the property deprecated, and a flag that
silently fails to arrive would make a null result read as "multiplexing is
not the cause" — the one outcome where being wrong is expensive.
instrumentation.ts now supplies the value if the deployment did not, and
records which source won so the probe route can report it. That also tells
us where mitigation advice should point if #3190 owns the regression.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

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

[world-vercel] Make HTTP/2 actually multiplex on the events path - #3190

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/world-vercel-h2-multiplexing
Jul 29, 2026
Merged

[world-vercel] Make HTTP/2 actually multiplex on the events path#3190
VaguelySerious merged 2 commits into
mainfrom
peter/world-vercel-h2-multiplexing

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

Problem

The events agent sets allowH2: true and does negotiate ALPN h2 — but it never multiplexed. Measured against api.vercel.com with a warmed pool, 16 concurrent event writes produced 8 in-flight requests over 8 TCP connections: byte-for-byte identical to the HTTP/1.1 default agent. The existing tests asserted EVENTS_AGENT_OPTIONS.allowH2 === true and that ALPN negotiates h2 — both true, neither measures concurrency.

Cause

Three independent undici gates each cap in-flight requests at one per connection. All three had to be lifted; any one of them left alone keeps the agent serialized.

  1. pipelining: 1, inherited from the shared base options. undici gates in-flight requests per connection on client[kPipelining] ?? httpContext.defaultPipelining ?? 1. client-h2.js sets defaultPipelining: Infinity, but the Client constructor always coerces pipelining to a number, so kPipelining is never nullish and H2's Infinity is unreachable — nodejs/undici#4143.
  2. Non-idempotent method.client-h2.js reports the connection busy whenever request.idempotent === false, and undici only treats GET/HEAD as idempotent by default. Every event write is a POST — nodejs/undici#5390.
  3. Streamed request body.client-h2.js also reports busy for a stream / async-iterable body. events-v4 hands over a materialized Uint8Array, but it dispatches through the global fetch — deliberately, since that is what keeps events traffic visible in the outgoing-requests view — and fetch streamifies every body on the way down.

Verified matrix (16 concurrent POSTs): pipelining alone → 8 in-flight. idempotent: true alone → 8 in-flight. Both, plus a buffered body → 16 in-flight, 0 new connections.

Change

  • pipelining is split out of the shared base options so each agent sets it explicitly — undici overloads that one option to mean both "H1 pipelining depth" and "max in-flight H2 streams per connection", and the two paths want opposite values. The H1 default keeps pipelining: 1 (it deadlocks the webhook respondWith mechanism otherwise).
  • An interceptor marks events requests idempotent and re-buffers streamed bodies under 1 MiB. Marking them idempotent affects concurrency only: in undici 7 the flag feeds nothing but the busy() gates, and retries are governed solely by RetryAgent, whose default methods excludes POST — so an event write is still never resent.
  • The interceptor wraps the RetryAgent rather than the Agent inside it. RetryHandler captures its own copy of the body up front (wrapRequestBody), so composing it inside would leave a retry re-iterating the stream the interceptor had already consumed, sending an empty body. Composed outside, the retry replays the drained Buffer. There is a test for exactly this.
  • Stream write/close get their own STREAM_AGENT_OPTIONS and deliberately do not multiplex. Appends are not idempotent, and STREAM_RETRY_OPTIONS retries PUT on transient connection errors — multiplexing would let a single RST_STREAM/GOAWAY fail, and then re-send, several already-applied appends at once. This preserves today's one-request-per-connection failure isolation. (Belt-and-braces: gate 2 already serializes PUTs, but pinning pipelining: 1 means the safety property doesn't silently depend on that upstream detail.)
  • Escape hatch WORKFLOW_H2_MULTIPLEX=0 restores one request per connection, documented in Runtime Tuning.

Verification

Against real api.vercel.com, 16 concurrent POSTs after warming the pool:

dispatcherALPNnew connections for burst
events (before)h27
events (after)h20
stream writeh27 (intended)
events, WORKFLOW_H2_MULTIPLEX=0h27

New tests run a real H2 loopback server through the production factory functions and hold requests open on a barrier to observe peak concurrency: one asserts the events dispatcher reaches 16 concurrent streams with no new connections, one asserts stream writes stay bounded by the pool, one asserts a re-buffered body survives a retry intact, one covers the kill switch. Reverting either the pipelining value or the interceptor composition fails these tests.

pnpm typecheck and all 309 world-vercel unit tests pass.

The events agent sets `allowH2: true` and does negotiate ALPN h2, but it
never multiplexed: 16 concurrent event writes produced 8 in-flight
requests over 8 TCP connections — byte-for-byte identical to the HTTP/1.1
default agent.
Three independent undici gates each capped in-flight requests at one per
connection, and all three had to be lifted:
1. `pipelining: 1`, inherited from the shared base options. undici gates
in-flight requests on `client[kPipelining] ?? defaultPipelining ?? 1`;
`client-h2.js` sets `defaultPipelining: Infinity`, but the Client
constructor always coerces `pipelining` to a number, so that value is
unreachable (nodejs/undici#4143).
2. `client-h2.js` reports the connection busy for any request with
`idempotent === false`, and only GET/HEAD are idempotent by default —
every event write is a POST (nodejs/undici#5390).
3. `client-h2.js` also reports busy for a stream/async-iterable body.
events-v4 passes a materialized `Uint8Array`, but it dispatches via
global `fetch` (deliberately, for outgoing-request observability),
which streamifies every body.
`pipelining` is split out of the shared base so each agent sets it
explicitly, and an interceptor marks events requests idempotent and
re-buffers small streamed bodies. Marking them idempotent affects
concurrency only: RetryAgent's default `methods` excludes POST, so an
event write is still never resent.
Stream write/close move to their own options and deliberately do NOT
multiplex: appends are not idempotent, and STREAM_RETRY_OPTIONS retries
PUT on transient connection errors, so multiplexing would let one reset
fail — and then re-send — several already-applied appends at once.
Verified against api.vercel.com, 16 concurrent POSTs after warming the
pool: events 0 new connections (was 7), stream writes 7,
WORKFLOW_H2_MULTIPLEX=0 back to 7.
@VaguelySerious
VaguelySerious requested review from a team and ijjk as code ownersJuly 29, 2026 19:04
@changeset-bot

changeset-botBot commented Jul 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6883125

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

This PR includes changesets to release 17 packages
NameType
@workflow/world-vercelPatch
@workflow/cliPatch
@workflow/corePatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@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 Jul 29, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewJul 29, 2026 9:42pm
example-nextjs-workflow-webpackReadyReadyPreviewJul 29, 2026 9:42pm
example-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-astro-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-express-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-fastify-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-hono-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-nestjs-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-nitro-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-nuxt-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-sveltekit-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-tanstack-start-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-vite-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workflow-docsReadyReadyPreview, v0Jul 29, 2026 9:42pm
workflow-swc-playgroundReadyReadyPreviewJul 29, 2026 9:42pm
workflow-tarballsReadyReadyPreviewJul 29, 2026 9:42pm
workflow-webReadyReadyPreviewJul 29, 2026 9:42pm

@github-actions

github-actionsBot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 6883125 · Wed, 29 Jul 2026 21:59:44 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep262 (-71%) 💚1376 🔴 (+38%) 🔻1426 🔴 (+38%) 🔻1441 🔴 (+10%)30
TTFSstream249 (-73%) 💚1353 🔴 (+36%) 🔻1370 🔴 (+35%) 🔻1541 🔴 (+48%) 🔻30
TTFShook + stream359 (-22%) 💚1597 🔴 (+24%) 🔻1626 🔴 (+22%) 🔻1748 🔴 (-16%) 💚30
STSO1020 steps (1-20)175 (±0%)286 🔴 (±0%)380 🔴 (+14%)547 🔴 (+3.6%)19
STSO1020 steps (101-120)195 (+7.7%)315 🔴 (+11%)341 🔴 (+9.6%)365 🔴 (+8.0%)19
STSO1020 steps (1001-1020)502 (+12%)619 🔴 (+12%)754 🔴 (+30%) 🔻877 🔴 (+40%) 🔻19
WO1020 steps417644 (+7.6%)417644 (+7.6%)417644 (+7.6%)417644 (+7.6%)1
SLstream latency127 (+49%) 🔻202 🔴 (+67%) 🔻390 🔴 (+185%) 🔻714 🔴 (+212%) 🔻30
SOstream overhead (text)140 (+17%) 🔻221 (+11%)276 (+34%) 🔻356 (+17%) 🔻30
SOstream overhead (structured)113 (+12%)200 (-14%)246 (-9.9%)1053 🔴 (+167%) 🔻30
📜 Previous results (1)

b9f271d

Wed, 29 Jul 2026 19:28:19 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep274 (-53%) 💚1364 🔴 (+30%) 🔻1399 🔴 (+23%) 🔻1472 🔴 (+8.6%)30
TTFSstream224 (+13%)1371 🔴 (+32%) 🔻1397 🔴 (+31%) 🔻1735 🔴 (+41%) 🔻30
TTFShook + stream330 (-73%) 💚1609 🔴 (+20%) 🔻1695 🔴 (+22%) 🔻1843 🔴 (+27%) 🔻30
STSO1020 steps (1-20)189 (+9.9%)285 🔴 (-3.4%)517 🔴 (+59%) 🔻621 🔴 (+82%) 🔻19
STSO1020 steps (101-120)223 (+14%)379 🔴 (+38%) 🔻474 🔴 (+56%) 🔻580 🔴 (+14%)19
STSO1020 steps (1001-1020)508 (+5.6%)608 🔴 (+6.9%)649 🔴 (+8.3%)710 🔴 (+14%)19
WO1020 steps425133 (+4.5%)425133 (+4.5%)425133 (+4.5%)425133 (+4.5%)1
SLstream latency126 (+59%) 🔻239 🔴 (+68%) 🔻407 🔴 (+86%) 🔻825 🔴 (+36%) 🔻30
SOstream overhead (text)172 (+77%) 🔻424 🔴 (+149%) 🔻546 🔴 (+186%) 🔻779 (+203%) 🔻30
SOstream overhead (structured)189 (+82%) 🔻382 🔴 (+89%) 🔻459 (+113%) 🔻666 (+12%)30
ℹ️ Metric definitions & methodology

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actionsBot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

📦 Local Production (2 failed)

nextjs-turbopack-stable (1 failed):

  • webhookWorkflow | wrun_41KYQX591P0GXNMNHKPWR0RG7Y

nextjs-webpack-stable (1 failed):

  • webhookWorkflow | wrun_41KYQX591P0GXNMNHKPWR0RG7Y

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
❌ 📦 Local Production161922271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7517211328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

❌ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
❌ nextjs-turbopack-stable15310
✅ nextjs-webpack-canary135019
❌ nextjs-webpack-stable15310
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

Comment threaddocs/content/docs/v5/configuration/runtime-tuning.mdx Outdated

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the HTTP/2 multiplexing implementation, retry/body handling, stream-write isolation, fallback switch, and regression coverage. No blocking correctness issues found.

Co-authored-by: Nathan Rajlich <n@n8.io>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@VaguelySerious
VaguelySerious merged commit 34975f6 into mainJul 29, 2026
15 checks passed
@VaguelySerious
VaguelySerious deleted the peter/world-vercel-h2-multiplexing branch July 29, 2026 21:38
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 34975f6 (AI decision).

This is a performance/throughput optimization (making the events agent actually multiplex over HTTP/2) plus new API surface (createEventsDispatcher, createStreamDispatcher, STREAM_AGENT_OPTIONS) and a new WORKFLOW_H2_MULTIPLEX config flag — not a fix for a crash, correctness, or data-loss defect on the maintenance line. It also builds on main-only behavior: origin/stable:packages/world-vercel/src/http-client.ts has no H2 events agent at all (allowH2: false, no EVENTS_AGENT_OPTIONS), so there is nothing on stable for this to fix. The accompanying docs page (docs/content/docs/v5/configuration/runtime-tuning.mdx) does not exist on stable either.

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

34975f6b7dd8e0dad874e852eac04c9652f5971d

@github-actions

Copy link
Copy Markdown
Contributor

Backport to stable failed — the cherry-pick had conflicts that could not be resolved automatically (backport job run).

To resolve manually, push a backport branch and open a PR against stable (the workflow never pushes directly to stable). Note: this repository requires verified signatures on every branch, so your local commits must be signed (git config commit.gpgsign true with a configured GPG/SSH signing key, or git cherry-pick -S).

git fetch origin stable
git checkout -b backport/pr-3190-to-stable origin/stable
git cherry-pick -S 34975f6b7dd8e0dad874e852eac04c9652f5971d # -S signs the commit# Fix conflicts, then:
git add -A
git cherry-pick --continue
git push -u origin backport/pr-3190-to-stable
gh pr create --base stable --head backport/pr-3190-to-stable \
--title "Backport #3190: <original PR title>" \
--body "Manual backport of #3190 (cherry-pick 34975f6b7dd8) to \`stable\`."

pranaygp added a commit that referenced this pull request Jul 30, 2026
`vercel.json`'s `env` block is documented as passing variables to the
functions, but Vercel marks the property deprecated, and a flag that
silently fails to arrive would make a null result read as "multiplexing is
not the cause" — the one outcome where being wrong is expensive.
instrumentation.ts now supplies the value if the deployment did not, and
records which source won so the probe route can report it. That also tells
us where mitigation advice should point if #3190 owns the regression.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

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

[world-vercel] Make HTTP/2 actually multiplex on the events path - #3190

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/world-vercel-h2-multiplexing
Jul 29, 2026
Merged

[world-vercel] Make HTTP/2 actually multiplex on the events path#3190
VaguelySerious merged 2 commits into
mainfrom
peter/world-vercel-h2-multiplexing

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

Problem

The events agent sets allowH2: true and does negotiate ALPN h2 — but it never multiplexed. Measured against api.vercel.com with a warmed pool, 16 concurrent event writes produced 8 in-flight requests over 8 TCP connections: byte-for-byte identical to the HTTP/1.1 default agent. The existing tests asserted EVENTS_AGENT_OPTIONS.allowH2 === true and that ALPN negotiates h2 — both true, neither measures concurrency.

Cause

Three independent undici gates each cap in-flight requests at one per connection. All three had to be lifted; any one of them left alone keeps the agent serialized.

  1. pipelining: 1, inherited from the shared base options. undici gates in-flight requests per connection on client[kPipelining] ?? httpContext.defaultPipelining ?? 1. client-h2.js sets defaultPipelining: Infinity, but the Client constructor always coerces pipelining to a number, so kPipelining is never nullish and H2's Infinity is unreachable — nodejs/undici#4143.
  2. Non-idempotent method.client-h2.js reports the connection busy whenever request.idempotent === false, and undici only treats GET/HEAD as idempotent by default. Every event write is a POST — nodejs/undici#5390.
  3. Streamed request body.client-h2.js also reports busy for a stream / async-iterable body. events-v4 hands over a materialized Uint8Array, but it dispatches through the global fetch — deliberately, since that is what keeps events traffic visible in the outgoing-requests view — and fetch streamifies every body on the way down.

Verified matrix (16 concurrent POSTs): pipelining alone → 8 in-flight. idempotent: true alone → 8 in-flight. Both, plus a buffered body → 16 in-flight, 0 new connections.

Change

  • pipelining is split out of the shared base options so each agent sets it explicitly — undici overloads that one option to mean both "H1 pipelining depth" and "max in-flight H2 streams per connection", and the two paths want opposite values. The H1 default keeps pipelining: 1 (it deadlocks the webhook respondWith mechanism otherwise).
  • An interceptor marks events requests idempotent and re-buffers streamed bodies under 1 MiB. Marking them idempotent affects concurrency only: in undici 7 the flag feeds nothing but the busy() gates, and retries are governed solely by RetryAgent, whose default methods excludes POST — so an event write is still never resent.
  • The interceptor wraps the RetryAgent rather than the Agent inside it. RetryHandler captures its own copy of the body up front (wrapRequestBody), so composing it inside would leave a retry re-iterating the stream the interceptor had already consumed, sending an empty body. Composed outside, the retry replays the drained Buffer. There is a test for exactly this.
  • Stream write/close get their own STREAM_AGENT_OPTIONS and deliberately do not multiplex. Appends are not idempotent, and STREAM_RETRY_OPTIONS retries PUT on transient connection errors — multiplexing would let a single RST_STREAM/GOAWAY fail, and then re-send, several already-applied appends at once. This preserves today's one-request-per-connection failure isolation. (Belt-and-braces: gate 2 already serializes PUTs, but pinning pipelining: 1 means the safety property doesn't silently depend on that upstream detail.)
  • Escape hatch WORKFLOW_H2_MULTIPLEX=0 restores one request per connection, documented in Runtime Tuning.

Verification

Against real api.vercel.com, 16 concurrent POSTs after warming the pool:

dispatcherALPNnew connections for burst
events (before)h27
events (after)h20
stream writeh27 (intended)
events, WORKFLOW_H2_MULTIPLEX=0h27

New tests run a real H2 loopback server through the production factory functions and hold requests open on a barrier to observe peak concurrency: one asserts the events dispatcher reaches 16 concurrent streams with no new connections, one asserts stream writes stay bounded by the pool, one asserts a re-buffered body survives a retry intact, one covers the kill switch. Reverting either the pipelining value or the interceptor composition fails these tests.

pnpm typecheck and all 309 world-vercel unit tests pass.

The events agent sets `allowH2: true` and does negotiate ALPN h2, but it
never multiplexed: 16 concurrent event writes produced 8 in-flight
requests over 8 TCP connections — byte-for-byte identical to the HTTP/1.1
default agent.
Three independent undici gates each capped in-flight requests at one per
connection, and all three had to be lifted:
1. `pipelining: 1`, inherited from the shared base options. undici gates
in-flight requests on `client[kPipelining] ?? defaultPipelining ?? 1`;
`client-h2.js` sets `defaultPipelining: Infinity`, but the Client
constructor always coerces `pipelining` to a number, so that value is
unreachable (nodejs/undici#4143).
2. `client-h2.js` reports the connection busy for any request with
`idempotent === false`, and only GET/HEAD are idempotent by default —
every event write is a POST (nodejs/undici#5390).
3. `client-h2.js` also reports busy for a stream/async-iterable body.
events-v4 passes a materialized `Uint8Array`, but it dispatches via
global `fetch` (deliberately, for outgoing-request observability),
which streamifies every body.
`pipelining` is split out of the shared base so each agent sets it
explicitly, and an interceptor marks events requests idempotent and
re-buffers small streamed bodies. Marking them idempotent affects
concurrency only: RetryAgent's default `methods` excludes POST, so an
event write is still never resent.
Stream write/close move to their own options and deliberately do NOT
multiplex: appends are not idempotent, and STREAM_RETRY_OPTIONS retries
PUT on transient connection errors, so multiplexing would let one reset
fail — and then re-send — several already-applied appends at once.
Verified against api.vercel.com, 16 concurrent POSTs after warming the
pool: events 0 new connections (was 7), stream writes 7,
WORKFLOW_H2_MULTIPLEX=0 back to 7.
@VaguelySerious
VaguelySerious requested review from a team and ijjk as code ownersJuly 29, 2026 19:04
@changeset-bot

changeset-botBot commented Jul 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6883125

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

This PR includes changesets to release 17 packages
NameType
@workflow/world-vercelPatch
@workflow/cliPatch
@workflow/corePatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@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 Jul 29, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewJul 29, 2026 9:42pm
example-nextjs-workflow-webpackReadyReadyPreviewJul 29, 2026 9:42pm
example-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-astro-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-express-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-fastify-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-hono-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-nestjs-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-nitro-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-nuxt-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-sveltekit-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-tanstack-start-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workbench-vite-workflowReadyReadyPreviewJul 29, 2026 9:42pm
workflow-docsReadyReadyPreview, v0Jul 29, 2026 9:42pm
workflow-swc-playgroundReadyReadyPreviewJul 29, 2026 9:42pm
workflow-tarballsReadyReadyPreviewJul 29, 2026 9:42pm
workflow-webReadyReadyPreviewJul 29, 2026 9:42pm

@github-actions

github-actionsBot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 6883125 · Wed, 29 Jul 2026 21:59:44 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep262 (-71%) 💚1376 🔴 (+38%) 🔻1426 🔴 (+38%) 🔻1441 🔴 (+10%)30
TTFSstream249 (-73%) 💚1353 🔴 (+36%) 🔻1370 🔴 (+35%) 🔻1541 🔴 (+48%) 🔻30
TTFShook + stream359 (-22%) 💚1597 🔴 (+24%) 🔻1626 🔴 (+22%) 🔻1748 🔴 (-16%) 💚30
STSO1020 steps (1-20)175 (±0%)286 🔴 (±0%)380 🔴 (+14%)547 🔴 (+3.6%)19
STSO1020 steps (101-120)195 (+7.7%)315 🔴 (+11%)341 🔴 (+9.6%)365 🔴 (+8.0%)19
STSO1020 steps (1001-1020)502 (+12%)619 🔴 (+12%)754 🔴 (+30%) 🔻877 🔴 (+40%) 🔻19
WO1020 steps417644 (+7.6%)417644 (+7.6%)417644 (+7.6%)417644 (+7.6%)1
SLstream latency127 (+49%) 🔻202 🔴 (+67%) 🔻390 🔴 (+185%) 🔻714 🔴 (+212%) 🔻30
SOstream overhead (text)140 (+17%) 🔻221 (+11%)276 (+34%) 🔻356 (+17%) 🔻30
SOstream overhead (structured)113 (+12%)200 (-14%)246 (-9.9%)1053 🔴 (+167%) 🔻30
📜 Previous results (1)

b9f271d

Wed, 29 Jul 2026 19:28:19 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep274 (-53%) 💚1364 🔴 (+30%) 🔻1399 🔴 (+23%) 🔻1472 🔴 (+8.6%)30
TTFSstream224 (+13%)1371 🔴 (+32%) 🔻1397 🔴 (+31%) 🔻1735 🔴 (+41%) 🔻30
TTFShook + stream330 (-73%) 💚1609 🔴 (+20%) 🔻1695 🔴 (+22%) 🔻1843 🔴 (+27%) 🔻30
STSO1020 steps (1-20)189 (+9.9%)285 🔴 (-3.4%)517 🔴 (+59%) 🔻621 🔴 (+82%) 🔻19
STSO1020 steps (101-120)223 (+14%)379 🔴 (+38%) 🔻474 🔴 (+56%) 🔻580 🔴 (+14%)19
STSO1020 steps (1001-1020)508 (+5.6%)608 🔴 (+6.9%)649 🔴 (+8.3%)710 🔴 (+14%)19
WO1020 steps425133 (+4.5%)425133 (+4.5%)425133 (+4.5%)425133 (+4.5%)1
SLstream latency126 (+59%) 🔻239 🔴 (+68%) 🔻407 🔴 (+86%) 🔻825 🔴 (+36%) 🔻30
SOstream overhead (text)172 (+77%) 🔻424 🔴 (+149%) 🔻546 🔴 (+186%) 🔻779 (+203%) 🔻30
SOstream overhead (structured)189 (+82%) 🔻382 🔴 (+89%) 🔻459 (+113%) 🔻666 (+12%)30
ℹ️ Metric definitions & methodology

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actionsBot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

📦 Local Production (2 failed)

nextjs-turbopack-stable (1 failed):

  • webhookWorkflow | wrun_41KYQX591P0GXNMNHKPWR0RG7Y

nextjs-webpack-stable (1 failed):

  • webhookWorkflow | wrun_41KYQX591P0GXNMNHKPWR0RG7Y

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
❌ 📦 Local Production161922271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7517211328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

❌ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
❌ nextjs-turbopack-stable15310
✅ nextjs-webpack-canary135019
❌ nextjs-webpack-stable15310
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

Comment threaddocs/content/docs/v5/configuration/runtime-tuning.mdx Outdated

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the HTTP/2 multiplexing implementation, retry/body handling, stream-write isolation, fallback switch, and regression coverage. No blocking correctness issues found.

Co-authored-by: Nathan Rajlich <n@n8.io>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@VaguelySerious
VaguelySerious merged commit 34975f6 into mainJul 29, 2026
15 checks passed
@VaguelySerious
VaguelySerious deleted the peter/world-vercel-h2-multiplexing branch July 29, 2026 21:38
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 34975f6 (AI decision).

This is a performance/throughput optimization (making the events agent actually multiplex over HTTP/2) plus new API surface (createEventsDispatcher, createStreamDispatcher, STREAM_AGENT_OPTIONS) and a new WORKFLOW_H2_MULTIPLEX config flag — not a fix for a crash, correctness, or data-loss defect on the maintenance line. It also builds on main-only behavior: origin/stable:packages/world-vercel/src/http-client.ts has no H2 events agent at all (allowH2: false, no EVENTS_AGENT_OPTIONS), so there is nothing on stable for this to fix. The accompanying docs page (docs/content/docs/v5/configuration/runtime-tuning.mdx) does not exist on stable either.

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

34975f6b7dd8e0dad874e852eac04c9652f5971d

@github-actions

Copy link
Copy Markdown
Contributor

Backport to stable failed — the cherry-pick had conflicts that could not be resolved automatically (backport job run).

To resolve manually, push a backport branch and open a PR against stable (the workflow never pushes directly to stable). Note: this repository requires verified signatures on every branch, so your local commits must be signed (git config commit.gpgsign true with a configured GPG/SSH signing key, or git cherry-pick -S).

git fetch origin stable
git checkout -b backport/pr-3190-to-stable origin/stable
git cherry-pick -S 34975f6b7dd8e0dad874e852eac04c9652f5971d # -S signs the commit# Fix conflicts, then:
git add -A
git cherry-pick --continue
git push -u origin backport/pr-3190-to-stable
gh pr create --base stable --head backport/pr-3190-to-stable \
--title "Backport #3190: <original PR title>" \
--body "Manual backport of #3190 (cherry-pick 34975f6b7dd8) to \`stable\`."

pranaygp added a commit that referenced this pull request Jul 30, 2026
`vercel.json`'s `env` block is documented as passing variables to the
functions, but Vercel marks the property deprecated, and a flag that
silently fails to arrive would make a null result read as "multiplexing is
not the cause" — the one outcome where being wrong is expensive.
instrumentation.ts now supplies the value if the deployment did not, and
records which source won so the probe route can report it. That also tells
us where mitigation advice should point if #3190 owns the regression.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@VaguelySerious@TooTallNate@karthikscale3