Skip to content

[world-vercel] Raise H2 receive windows on the events agent - #3212

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/h2-agent-tuning
Jul 30, 2026
Merged

VaguelySerious merged 2 commits into
mainfrom
peter/h2-agent-tuning

Conversation

@VaguelySerious

@VaguelySerious VaguelySerious commented Jul 30, 2026 •

Copy link
Copy Markdown
Member

Follow-up to #3190. That PR made the events agent actually multiplex; this one tunes the flow-control windows it runs with.

Problem

undici defaults to a 256 KiB stream window and a 512 KiB connection window (lib/dispatcher/client.js:276-277). Both are far below the bandwidth-delay product of the function→edge path — a Function in iad1 measures ~9 ms RTT to the edge (min 6.2, p50 9.4–9.8, against edge-terminated routes). Once a response exceeds the window the origin stops and waits for a WINDOW_UPDATE, costing a round trip per window's worth of body. Event-log reads on replay are exactly that shape: pages are read sequentially, page size is server-driven with no byte cap, and step payloads are serialized into events, so a page can be several MiB.

There's also an interaction with #3190 worth calling out: concentrating streams onto one connection makes them share that connection's single receive window. Before this change, pipelining: 1 measured ~70% faster than pipelining: 100 on downloads, purely because spreading over 8 connections gave 8 separate windows. Multiplexing was paying for itself on writes and giving some back on reads.

Change

EVENTS_AGENT_OPTIONS gains initialWindowSize: 4 MiB and connectionWindowSize: 16 MiB. Nothing else changes — the default (H1) and stream-write agents are untouched.

Both windows have to move together. Whichever is left at its default becomes the binding constraint on its own (rtt=10ms):

Config conc=1 (4 MiB) conc=4 (1 MiB) conc=32 (256 KiB)
initialWindowSize alone −44.6% +7.4% +1.7%
connectionWindowSize alone −1.2% −45.0% −77.6%
both −69.9% −79.2% −83.0%

A single read is capped by the stream window, so raising only the connection window does nothing. Concurrent reads share the connection window, so raising only the stream window relocates the stall and measures slightly worse than leaving both alone.

Results

Shipped config vs undici defaults, duplicate pairs agreeing within 0.5%:

Shape undici defaults 4 MiB / 16 MiB
conc=1, 4 MiB 205.6 / 200.4 ms 21.5 / 21.4 ms −89.4%
conc=4, 1 MiB 103.0 / 101.2 ms 22.5 / 22.9 ms −77.9%
conc=32, 256 KiB 204.6 / 208.3 ms 33.9 / 32.8 ms −83.6%

Sizing is the knee, not a round number: 4 MiB of stream window captures the single-read win in full (216 → 17 ms; 2 MiB only reaches 29 ms), and 16 MiB of connection window is needed for the concurrent case (8 MiB is no better than 1/8 at conc=32, 16 MiB is 25% faster, 32 MiB adds nothing).

maxConcurrentStreams is deliberately not set. It's inert: undici only uses it to seed peerMaxConcurrentStreams at connect, after which the server's SETTINGS overwrite it (the edge advertises 160). Measured at ±1.6% inside a 6.1% noise floor across every shape. pipelining remains the only knob that gates in-flight streams.

Costs

  • Write path: none. Receive windows don't govern uploads. With four interleaved controls, every setting lands within a 1.3% noise floor.
  • Memory: a bounded ceiling. Under an adversarial slow consumer (8 concurrent 8 MiB bodies, one chunk read per ms) peak RSS ran ~60–100 MiB above smaller settings. It's a ceiling on in-flight unacked bytes rather than an allocation, only fills when the origin outruns the consumer, and the events readers decode whole response bodies anyway — so those bytes reach app memory either way.

Methodology

Numbers come from a local harness, kept out of this PR: a loopback H2 origin mirroring the edge's advertised SETTINGS (MAX_CONCURRENT_STREAMS 160, INITIAL_WINDOW_SIZE 1 MiB, MAX_FRAME_SIZE 1 MiB) behind a latency-injecting TCP relay. The relay is not optional — loopback RTT is ~0.05 ms, at which every flow-control window is thousands of times larger than the BDP, so these knobs are unmeasurable by construction without injected RTT.

Two false positives shaped the design, and both are easy to repeat:

  1. Measuring against the real edge doesn't work. With four interleaved copies of the identical baseline, the baselines spanned 29.7%. Per-config medians differed only in the p75 tail while p25 was flat at 58.5–64.5 ms across every config — that's shared-CDN jitter, not configuration.
  2. Three duplicate controls underestimate the noise floor. A 2-baseline design reported a 9.9% floor and a connectionWindowSize=16MiB result of −31.9% that did not replicate at all. Separately, a 3-baseline write-path run showed a "reproducible" +3.2% regression against a 2.0% floor that vanished when a fourth control put the floor at 4.8%.

Every reported number therefore comes from: interleaved duplicate controls (the spread is the noise floor), round-robin execution with seeded reshuffling each round (an earlier sequential version produced results that improved monotonically down the list — the first config was absorbing warmup), pre-warmed dispatchers, and median-of-rounds.

Draft because the numbers above are from the local rig; leaving it open for CI and review before marking ready. Happy to share the harness separately if it's useful for re-deriving these constants.

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Jul 30, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4627aa3

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

This PR includes changesets to release 17 packages
Name Type
@workflow/world-vercel Patch
@workflow/cli Patch
@workflow/core Patch
@workflow/web Patch
workflow Patch
@workflow/world-testing Patch
@workflow/builders Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch
@workflow/nuxt Patch

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

vercel Bot commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview Jul 30, 2026 6:36pm
example-nextjs-workflow-webpack Ready Ready Preview Jul 30, 2026 6:36pm
example-workflow Ready Ready Preview Jul 30, 2026 6:36pm
workbench-astro-workflow Ready Ready Preview Jul 30, 2026 6:36pm
workbench-express-workflow Ready Ready Preview Jul 30, 2026 6:36pm
workbench-fastify-workflow Ready Ready Preview Jul 30, 2026 6:36pm
workbench-hono-workflow Ready Ready Preview Jul 30, 2026 6:36pm
workbench-nestjs-workflow Ready Ready Preview Jul 30, 2026 6:36pm
workbench-nitro-workflow Ready Ready Preview Jul 30, 2026 6:36pm
workbench-nuxt-workflow Ready Ready Preview Jul 30, 2026 6:36pm
workbench-sveltekit-workflow Ready Ready Preview Jul 30, 2026 6:36pm
workbench-tanstack-start-workflow Ready Ready Preview Jul 30, 2026 6:36pm
workbench-vite-workflow Ready Ready Preview Jul 30, 2026 6:36pm
workflow-docs Ready Ready Preview, v0 Jul 30, 2026 6:36pm
workflow-swc-playground Ready Ready Preview Jul 30, 2026 6:36pm
workflow-tarballs Ready Ready Preview Jul 30, 2026 6:36pm
workflow-web Ready Ready Preview Jul 30, 2026 6:36pm

@github-actions

github-actions Bot commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

❌ Some tests failed

❌ Failed E2E Tests

📦 Local Production (1 failed)

nextjs-webpack-stable (1 failed):

  • webhookWorkflow | wrun_41KYT4ZK4K0GRDH5RQRVJS2DPX

E2E Test Summary

Summary
Passed Failed Skipped Total
✅ ▲ Vercel Production 1455 0 239 1694
✅ 💻 Local Development 1621 0 227 1848
❌ 📦 Local Production 1620 1 227 1848
✅ 🐘 Local Postgres 1621 0 227 1848
✅ 🪟 Windows 154 0 0 154
✅ 📋 Other 1020 0 212 1232
✅ vercel-multi-region 27 0 0 27
Total 7518 1 1132 8651
Details by Category

✅ ▲ Vercel Production

App Passed Failed Skipped
✅ astro 126 0 28
✅ example 126 0 28
✅ express 126 0 28
✅ fastify 126 0 28
✅ hono 126 0 28
✅ nextjs-turbopack 151 0 3
✅ nextjs-webpack 151 0 3
✅ nitro 126 0 28
✅ nuxt 126 0 28
✅ sveltekit 145 0 9
✅ vite 126 0 28

✅ 💻 Local Development

App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26

❌ 📦 Local Production

App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
❌ nextjs-webpack-stable 153 1 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26

✅ 🐘 Local Postgres

App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26

✅ 🪟 Windows

App Passed Failed Skipped
✅ nextjs-turbopack 154 0 0

✅ 📋 Other

App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 128 0 26
✅ e2e-local-dev-tanstack-start- 128 0 26
✅ e2e-local-postgres-nest-stable 128 0 26
✅ e2e-local-postgres-tanstack-start- 128 0 26
✅ e2e-local-prod-nest-stable 128 0 26
✅ e2e-local-prod-tanstack-start- 128 0 26
✅ e2e-vercel-prod-nest 126 0 28
✅ e2e-vercel-prod-tanstack-start 126 0 28

✅ vercel-multi-region

App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

@github-actions

github-actions Bot commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 4627aa3 · Thu, 30 Jul 2026 18:55:46 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 213 (-3.6%) 1387 🔴 (+30%) 🔻 1428 🔴 (+30%) 🔻 1458 🔴 (-5.0%) 30
TTFS stream 200 (-12%) 1357 🔴 (+40%) 🔻 1371 🔴 (+33%) 🔻 1531 🔴 (+40%) 🔻 30
TTFS hook + stream 442 (+5.0%) 1614 🔴 (+33%) 🔻 1638 🔴 (+31%) 🔻 1747 🔴 (+22%) 🔻 30
STSO 1020 steps (1-20) 195 (+10%) 315 🔴 (+6.8%) 439 🔴 (+29%) 🔻 560 🔴 (+38%) 🔻 19
STSO 1020 steps (101-120) 197 (-6.2%) 300 🔴 (-13%) 333 🔴 (-21%) 💚 483 🔴 (-57%) 💚 19
STSO 1020 steps (1001-1020) 484 (-8.2%) 554 🔴 (-3.0%) 702 🔴 (+4.8%) 711 🔴 (±0%) 19
WO 1020 steps 422824 (±0%) 422824 (±0%) 422824 (±0%) 422824 (±0%) 1
SL stream latency 120 (+41%) 🔻 182 🔴 (+40%) 🔻 190 🔴 (+14%) 407 🔴 (+12%) 30
SO stream overhead (text) 174 (+45%) 🔻 686 🔴 (+200%) 🔻 815 🔴 (+211%) 🔻 3144 🔴 (+783%) 🔻 30
SO stream overhead (structured) 168 (+34%) 🔻 301 🔴 (+20%) 🔻 428 (+34%) 🔻 539 (+28%) 🔻 30
📜 Previous results (1)

b2d0939

Thu, 30 Jul 2026 18:25:10 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1317 (+496%) 🔻 1431 🔴 (+34%) 🔻 1525 🔴 (+39%) 🔻 1565 🔴 (+2.0%) 30
TTFS stream 250 (+10%) 1381 🔴 (+42%) 🔻 1436 🔴 (+39%) 🔻 1541 🔴 (+41%) 🔻 30
TTFS hook + stream 1332 (+216%) 🔻 1714 🔴 (+42%) 🔻 1853 🔴 (+48%) 🔻 2111 🔴 (+48%) 🔻 30
STSO 1020 steps (1-20) 188 (+6.2%) 294 🔴 (±0%) 359 🔴 (+5.6%) 373 🔴 (-8.1%) 19
STSO 1020 steps (101-120) 200 (-4.8%) 280 🔴 (-19%) 💚 334 🔴 (-20%) 💚 349 🔴 (-69%) 💚 19
STSO 1020 steps (1001-1020) 520 (-1.3%) 594 🔴 (+4.0%) 682 🔴 (+1.8%) 845 🔴 (+19%) 🔻 19
WO 1020 steps 437460 (+3.7%) 437460 (+3.7%) 437460 (+3.7%) 437460 (+3.7%) 1
SL stream latency 110 (+29%) 🔻 156 🔴 (+20%) 🔻 273 🔴 (+64%) 🔻 469 🔴 (+30%) 🔻 30
SO stream overhead (text) 118 (-1.7%) 171 (-25%) 💚 193 (-26%) 💚 341 (-4.2%) 30
SO stream overhead (structured) 112 (-10%) 162 (-35%) 💚 174 (-45%) 💚 598 (+42%) 🔻 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.

Comment thread .changeset/h2-receive-windows.md Outdated
undici defaults to a 256 KiB stream window and a 512 KiB connection window,
both far below the bandwidth-delay product of the function-to-edge path
(~9 ms RTT measured from iad1). Event-log reads on replay exceed them, so the
origin stalls for a round trip per window's worth of body.

Both windows have to be raised together: a single read is capped by the stream
window, concurrent reads share the connection window, and raising either alone
leaves the other binding. Measured against a loopback H2 origin mirroring the
edge's SETTINGS at 10 ms RTT, 4 MiB/16 MiB cuts read wall time 78-89% across 1,
4 and 32 concurrent reads, against 1.8-2.6% noise floors.

This also removes an interaction with multiplexing: concentrating streams onto
one connection makes them share its receive window, so before this change
pipelining=1 measured ~70% faster than pipelining=100 on downloads purely
because 8 connections gave 8 separate windows.

The write path is unaffected — receive windows do not govern uploads.
maxConcurrentStreams is left alone; it is inert, since undici only uses it to
seed peerMaxConcurrentStreams at connect and the server's SETTINGS then
overwrite it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@VaguelySerious
VaguelySerious force-pushed the peter/h2-agent-tuning branch from b2d0939 to f28e947 Compare July 30, 2026 18:32
Signed-off-by: Peter Wielander <mittgfu@gmail.com>

@karthikscale3 karthikscale3 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 receive-window tuning and its tests. The options and flow-control behavior match the pinned Undici 7.28 implementation, the change is correctly scoped to the events agent, and I found no blocking correctness or regression issues.

@VaguelySerious
VaguelySerious marked this pull request as ready for review July 30, 2026 18:37
@VaguelySerious
VaguelySerious requested review from a team and ijjk as code owners July 30, 2026 18:37
@VaguelySerious
VaguelySerious merged commit c93f6f7 into main Jul 30, 2026
103 of 105 checks passed
@VaguelySerious
VaguelySerious deleted the peter/h2-agent-tuning branch July 30, 2026 18:54
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for c93f6f7 (AI decision).

This is a performance optimization (tuning HTTP/2 receive flow-control windows to reduce read latency), not a fix for a correctness, crash, or resource-leak defect on stable — and it actually raises the memory ceiling by ~60-100 MiB under a slow consumer. It also builds on main-only work: origin/stable's packages/world-vercel/src/http-client.ts has no EVENTS_AGENT_OPTIONS at all (the multiplexing agent from #3190 is not on stable), so the trade-off this PR is tuning does not exist there.

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

c93f6f7bd08b1e885a2d60d49aae9ca9fbda5930

This branch was successfully deployed

17 active deployments
Preview – workflow-swc-playground — 4627aa34 Deployed Jul 30, 2026 by vercel[bot]
Preview – workflow-docs — 4627aa34 Deployed Jul 30, 2026 by vercel[bot]
Preview – example-nextjs-workflow-webpack — 4627aa34 Deployed Jul 30, 2026 by vercel[bot]
Preview – workbench-nuxt-workflow — 4627aa34 Deployed Jul 30, 2026 by vercel[bot]
Preview – example-nextjs-workflow-turbopack — 4627aa34 Deployed Jul 30, 2026 by vercel[bot]
Preview – workbench-sveltekit-workflow — 4627aa34 Deployed Jul 30, 2026 by vercel[bot]
Preview – example-workflow — 4627aa34 Deployed Jul 30, 2026 by vercel[bot]
Preview – workbench-vite-workflow — 4627aa34 Deployed Jul 30, 2026 by vercel[bot]
Preview – workbench-astro-workflow — 4627aa34 Deployed Jul 30, 2026 by vercel[bot]
Preview – workbench-express-workflow — 4627aa34 Deployed Jul 30, 2026 by vercel[bot]
Preview – workbench-tanstack-start-workflow — 4627aa34 Deployed Jul 30, 2026 by vercel[bot]
Preview – workbench-fastify-workflow — 4627aa34 Deployed Jul 30, 2026 by vercel[bot]
Preview – workbench-hono-workflow — 4627aa34 Deployed Jul 30, 2026 by vercel[bot]
Preview – workbench-nestjs-workflow — 4627aa34 Deployed Jul 30, 2026 by vercel[bot]
Preview – workbench-nitro-workflow — 4627aa34 Deployed Jul 30, 2026 by vercel[bot]
Preview – workflow-tarballs — 4627aa34 Deployed Jul 30, 2026 by vercel[bot]
Preview – workflow-web — 4627aa34 Deployed Jul 30, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants