Skip to content

perf(core): path-independent stream write batching (group commit in the server writable) - #3078

Merged
karthikscale3 merged 6 commits into
mainfrom
kk/writable-batching
Jul 24, 2026
Merged

perf(core): path-independent stream write batching (group commit in the server writable)#3078
karthikscale3 merged 6 commits into
mainfrom
kk/writable-batching

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Problem

Batching was implemented in flushablePipe's coalescing loop, so it depended on which piping path ran. getWritable() batched; a raw ReadableStream crossing a workflow/step boundary uses native pipeTo(), which doesn't pull chunk N+1 until write(chunk N) resolves — and write() resolved only after the flush timer and the server round trip. The buffer never held more than one chunk: one request per token.

Fix

Batching moves into WorkflowServerWritableStream, identical on every path:

  • write() resolves when the chunk enters a bounded client buffer (WORKFLOW_STREAM_MAX_INFLIGHT_CHUNKS counts buffered and in-request chunks; new byte bound WORKFLOW_STREAM_MAX_BUFFERED_BYTES, default 8 MiB, documented).
  • The flush interval is a real group-commit window; chunks arriving while a request is in flight form the next writeMulti group. One request in flight preserves order; 1,000-chunk / 1 MiB per-request limits preserved.
  • Durability is an explicit barrier: close() drains first; flushablePipe (now a plain pump: lock-release + durability tracking only) adopts the barrier so step completion still implies durability; abort()drains the accepted prefix without closing (a producer error after acked writes can't lose data); a failed pipe drains before settling. Dispatch failures are sticky and surface at the next write/close/drain.

Trade-off

An open getWritable() stream's first chunk now waits up to the group-commit interval (10ms default) instead of dispatching eagerly — watch TTFC / buffer_dwell_ms after release.

Validation

1,572 core unit tests pass, including new regressions for: native-pipeTo batching, awaited per-chunk loops coalescing into one writeMulti, wire-cap splits, in-flight-inclusive backpressure on both bounds, source-error prefix delivery through abort, failed-pipe drain-before-reject, and turbo run-ready barrier gating. E2E tier requires a deployment and was not run here.

Addressed some of the streaming perf concerns for use cases mentioned in #1930

…eam (group commit)
Batching previously lived in flushablePipe's coalescing loop, so it only
engaged on paths that used flushablePipe (getWritable). A raw
ReadableStream crossing a workflow/step boundary is piped with native
pipeTo(), which does not pull chunk N+1 until write(chunk N) resolves —
and write() resolved only after the flush timer AND the server round
trip, so the buffer never held more than one chunk and every token
became its own server request.
The sink now group-commits:
- write() resolves when the chunk enters a bounded client buffer; the
bound counts buffered AND in-request chunks
(WORKFLOW_STREAM_MAX_INFLIGHT_CHUNKS, preserving its documented
meaning) plus a byte bound (WORKFLOW_STREAM_MAX_BUFFERED_BYTES, new,
default 8 MiB, documented in runtime-tuning). A full buffer applies
backpressure until a group lands durably.
- The flush interval is a real group-commit window; chunks arriving
while a request is in flight accumulate and form the next writeMulti
group. One request in flight at a time preserves chunk order.
- Per-request wire limits (1,000 chunks / 1 MiB) split groups exactly
as the coalescing pipe did; an oversized single chunk goes alone.
- Durability moved to an explicit barrier (STREAM_DRAIN_SYMBOL):
close() drains before closing; flushablePipe adopts the barrier so
lock-release completion (step completion) still means 'everything
written is durable'; abort() DRAINS the accepted prefix (never
closing) so a producer error after acked writes cannot lose data —
native pipeTo aborts the sink on source failure; and a failed pipe
drains before settling so a step failure is not persisted ahead of
the emitted prefix. A dispatch failure retains the group, poisons
the sink, and surfaces at the next write/close/drain.
flushablePipe is now a plain per-chunk pump responsible only for
lock-release completion and durability tracking; its coalescing
machinery and STREAM_WRITE_BATCH_SYMBOL are removed.
Covered: native-pipeTo batching (the regression), awaited per-chunk
loops coalescing into one writeMulti, in-flight accumulation, wire-cap
splits (count/byte/oversized), in-flight-inclusive backpressure for
both bounds, sequential fallback without writeMulti, source-error
prefix delivery through abort, failed-pipe drain-before-reject,
early-ack sticky errors, turbo run-ready barrier gating (incl. dwell
telemetry), drain-barrier adoption/rejection, and group-level flush
spans. 1,572 core unit tests pass; e2e tier requires a deployment and
was not run here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3 requested review from a team and ijjk as code ownersJuly 23, 2026 23:52
@changeset-bot

changeset-botBot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a57ba7c

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

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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 23, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit a57ba7c · Fri, 24 Jul 2026 05:30:38 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep291 (-69%) 💚1419 🔴 (+25%) 🔻1447 🔴 (+24%) 🔻1770 🔴 (+31%) 🔻30
TTFSstream281 (-72%) 💚1406 🔴 (+22%) 🔻1442 🔴 (+23%) 🔻1613 🔴 (+31%) 🔻30
TTFShook + stream497 (+18%) 🔻1645 🔴 (+14%)1662 🔴 (+14%)1843 🔴 (+16%) 🔻30
STSO1020 steps (1-20)166 (-10%)267 🔴 (-27%) 💚319 🔴 (-33%) 💚331 🔴 (-52%) 💚19
STSO1020 steps (101-120)200 (-18%) 💚279 🔴 (-30%) 💚306 🔴 (-61%) 💚330 🔴 (-66%) 💚19
STSO1020 steps (1001-1020)490 (-0.8%)568 🔴 (-2.7%)714 🔴 (+14%)881 🔴 (+27%) 🔻19
WO1020 steps407715 (-8.3%)407715 (-8.3%)407715 (-8.3%)407715 (-8.3%)1
SLstream latency98 (-2.0%)178 🔴 (+23%) 🔻230 🔴 (+43%) 🔻253 🔴 (+29%) 🔻30
SOstream overhead (text)124 (-13%)222 (-18%) 💚403 (+24%) 🔻530 (+5.8%)30
SOstream overhead (structured)115 (-9.4%)215 (-15%) 💚263 (-13%)820 (+90%) 🔻30
📜 Previous results (4)

5f57e09

Fri, 24 Jul 2026 05:05:30 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1201 (+29%) 🔻1272 🔴 (+12%)1297 🔴 (+11%)1590 🔴 (+17%) 🔻30
TTFSstream1207 (+18%) 🔻1265 🔴 (+9.7%)1278 🔴 (+9.4%)1337 🔴 (+8.6%)30
TTFShook + stream527 (+25%) 🔻1540 🔴 (+7.2%)1687 🔴 (+16%) 🔻1837 🔴 (+16%) 🔻30
STSO1020 steps (1-20)167 (-9.7%)270 🔴 (-26%) 💚318 🔴 (-33%) 💚494 🔴 (-28%) 💚19
STSO1020 steps (101-120)183 (-25%) 💚239 🔴 (-40%) 💚316 🔴 (-60%) 💚644 🔴 (-33%) 💚19
STSO1020 steps (1001-1020)451 (-8.7%)535 🔴 (-8.4%)821 🔴 (+31%) 🔻3616 🔴 (+423%) 🔻19
WO1020 steps374593 (-16%) 💚374593 (-16%) 💚374593 (-16%) 💚374593 (-16%) 💚1
SLstream latency84 (-16%) 💚125 🔴 (-14%)161 🔴 (±0%)218 🔴 (+11%)30
SOstream overhead (text)98 (-31%) 💚141 (-48%) 💚182 (-44%) 💚277 (-45%) 💚30
SOstream overhead (structured)101 (-20%) 💚148 (-42%) 💚174 (-42%) 💚210 (-51%) 💚30

b5718b0

Fri, 24 Jul 2026 01:05:26 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1286 (+34%) 🔻1373 🔴 (+28%) 🔻1393 🔴 (+23%) 🔻1449 🔴 (+1.7%)30
TTFSstream1273 (+32%) 🔻1360 🔴 (+30%) 🔻1373 🔴 (+29%) 🔻1544 🔴 (+37%) 🔻30
TTFShook + stream1456 (+20%) 🔻1583 🔴 (+22%) 🔻1621 🔴 (+20%) 🔻1683 🔴 (+19%) 🔻30
STSO1020 steps (1-20)162 (-6.9%)256 🔴 (+3.6%)305 🔴 (+9.3%)380 🔴 (+10%)19
STSO1020 steps (101-120)188 (-0.5%)256 🔴 (-7.2%)318 🔴 (-3.6%)320 🔴 (-15%) 💚19
STSO1020 steps (1001-1020)500 (+9.4%)583 🔴 (+13%)658 🔴 (+24%) 🔻787 🔴 (+27%) 🔻19
WO1020 steps395092 (+2.2%)395092 (+2.2%)395092 (+2.2%)395092 (+2.2%)1
SLstream latency86 (+7.5%)148 🔴 (+12%)169 🔴 (-5.6%)371 🔴 (-1.1%)30
SOstream overhead111 (+2.8%)165 (-13%)207 (-5.0%)320 (+25%) 🔻30

7e5ad94

Fri, 24 Jul 2026 00:29:08 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1249 (+30%) 🔻1330 🔴 (+24%) 🔻1343 🔴 (+19%) 🔻1380 🔴 (-3.2%)30
TTFSstream333 (-66%) 💚1327 🔴 (+27%) 🔻1345 🔴 (+26%) 🔻1437 🔴 (+27%) 🔻30
TTFShook + stream1454 (+20%) 🔻1691 🔴 (+30%) 🔻1777 🔴 (+31%) 🔻2084 🔴 (+47%) 🔻30
STSO1020 steps (1-20)183 (+5.2%)278 🔴 (+13%)318 🔴 (+14%)387 🔴 (+13%)19
STSO1020 steps (101-120)188 (-0.5%)239 🔴 (-13%)257 🔴 (-22%) 💚279 🔴 (-26%) 💚19
STSO1020 steps (1001-1020)480 (+5.0%)621 🔴 (+21%) 🔻745 🔴 (+40%) 🔻776 🔴 (+25%) 🔻19
WO1020 steps395743 (+2.4%)395743 (+2.4%)395743 (+2.4%)395743 (+2.4%)1
SLstream latency117 (+46%) 🔻175 🔴 (+33%) 🔻204 🔴 (+14%)419 🔴 (+12%)30

9792b58

Fri, 24 Jul 2026 00:09:07 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1281 (+267%) 🔻1362 🔴 (+34%) 🔻1381 🔴 (+33%) 🔻1479 🔴 (+6.5%)30
TTFSstream1265 (+38%) 🔻1321 🔴 (+26%) 🔻1369 🔴 (+29%) 🔻1447 🔴 (±0%)30
TTFShook + stream1597 (+41%) 🔻1677 🔴 (+26%) 🔻1816 🔴 (+28%) 🔻1873 🔴 (+24%) 🔻30
STSO1020 steps (1-20)175 (-0.6%)260 🔴 (-6.1%)313 🔴 (-30%) 💚341 🔴 (-24%) 💚19
STSO1020 steps (101-120)179 (-4.8%)273 🔴 (-1.1%)521 🔴 (+61%) 🔻572 🔴 (+71%) 🔻19
STSO1020 steps (1001-1020)530 (+17%) 🔻616 🔴 (+8.5%)647 🔴 (-6.5%)762 🔴 (-8.7%)19
WO1020 steps419068 (+6.7%)419068 (+6.7%)419068 (+6.7%)419068 (+6.7%)1
SLstream latency120 (+50%) 🔻174 🔴 (+38%) 🔻185 🔴 (+27%) 🔻224 🔴 (-47%) 💚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 23, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

📦 Local Production (1 failed)

nextjs-turbopack-stable (1 failed):

  • webhookWorkflow | wrun_41KY98HTJK0GKYSP0S315E2QCH

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
❌ 📦 Local Production162012271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7518111328651
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-stable15400
✅ 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 threadpackages/core/src/serialization.ts
Review (bot): a write landing between the dispatch loop's empty-buffer
exit and the reaction clearing the in-flight marker armed no timer
(scheduleGroupCommit saw the marker set) and was never dispatched on an
open stream — only a later write/close/drain would pick it up. The
settle reaction now re-dispatches when the buffer is non-empty, treating
the chunk as an in-request arrival; drain waiters settle with the new
chain. Regression test aims a write at the settle gap and asserts both
chunks flush without a close.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t handling
Review note: the abort-path drain is deliberately un-timeboxed (a bound
would drop acked chunks); its worst case is owned by the World
transport's finite timeout/retry budget, and a teardown-driven drain
into an already-terminal run rejects into the existing catch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
karthikscale3and others added 2 commits July 23, 2026 21:44
The native-pipeTo batching test flaked on a slow CI runner: a fixed
25ms wait raced the 10ms commit window plus scheduler jitter. All
'dispatch has happened' assertions now poll the expectation (bounded);
intentional negatives keep their fixed windows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actionsgithub-actionsBot mentioned this pull request Jul 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for b610c46 (AI decision).

This rework builds directly on main-only streaming infrastructure: stable's @workflow/core has no world.streams.writeMulti API, no STREAM_WRITE_BATCH_SYMBOL/coalescing pipe, and none of the WORKFLOW_STREAM_MAX_* tuning knobs or flush telemetry this patch refactors (verified via git grep on origin/stable). The docs change also targets docs/content/docs/v5/configuration/runtime-tuning.mdx, which does not exist on stable. The diff would not apply meaningfully to stable's much older per-write streaming implementation.

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

b610c46f8143afe3c862fb9957f5b4b48e754b42

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@karthikscale3@VaguelySerious
, '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" + '
perf(core): path-independent stream write batching (group commit in the server writable) by karthikscale3 · Pull Request #3078 · vercel/workflow · GitHub
Skip to content

perf(core): path-independent stream write batching (group commit in the server writable) - #3078

Merged
karthikscale3 merged 6 commits into
mainfrom
kk/writable-batching
Jul 24, 2026
Merged

perf(core): path-independent stream write batching (group commit in the server writable)#3078
karthikscale3 merged 6 commits into
mainfrom
kk/writable-batching

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Problem

Batching was implemented in flushablePipe's coalescing loop, so it depended on which piping path ran. getWritable() batched; a raw ReadableStream crossing a workflow/step boundary uses native pipeTo(), which doesn't pull chunk N+1 until write(chunk N) resolves — and write() resolved only after the flush timer and the server round trip. The buffer never held more than one chunk: one request per token.

Fix

Batching moves into WorkflowServerWritableStream, identical on every path:

  • write() resolves when the chunk enters a bounded client buffer (WORKFLOW_STREAM_MAX_INFLIGHT_CHUNKS counts buffered and in-request chunks; new byte bound WORKFLOW_STREAM_MAX_BUFFERED_BYTES, default 8 MiB, documented).
  • The flush interval is a real group-commit window; chunks arriving while a request is in flight form the next writeMulti group. One request in flight preserves order; 1,000-chunk / 1 MiB per-request limits preserved.
  • Durability is an explicit barrier: close() drains first; flushablePipe (now a plain pump: lock-release + durability tracking only) adopts the barrier so step completion still implies durability; abort()drains the accepted prefix without closing (a producer error after acked writes can't lose data); a failed pipe drains before settling. Dispatch failures are sticky and surface at the next write/close/drain.

Trade-off

An open getWritable() stream's first chunk now waits up to the group-commit interval (10ms default) instead of dispatching eagerly — watch TTFC / buffer_dwell_ms after release.

Validation

1,572 core unit tests pass, including new regressions for: native-pipeTo batching, awaited per-chunk loops coalescing into one writeMulti, wire-cap splits, in-flight-inclusive backpressure on both bounds, source-error prefix delivery through abort, failed-pipe drain-before-reject, and turbo run-ready barrier gating. E2E tier requires a deployment and was not run here.

Addressed some of the streaming perf concerns for use cases mentioned in #1930

…eam (group commit)
Batching previously lived in flushablePipe's coalescing loop, so it only
engaged on paths that used flushablePipe (getWritable). A raw
ReadableStream crossing a workflow/step boundary is piped with native
pipeTo(), which does not pull chunk N+1 until write(chunk N) resolves —
and write() resolved only after the flush timer AND the server round
trip, so the buffer never held more than one chunk and every token
became its own server request.
The sink now group-commits:
- write() resolves when the chunk enters a bounded client buffer; the
bound counts buffered AND in-request chunks
(WORKFLOW_STREAM_MAX_INFLIGHT_CHUNKS, preserving its documented
meaning) plus a byte bound (WORKFLOW_STREAM_MAX_BUFFERED_BYTES, new,
default 8 MiB, documented in runtime-tuning). A full buffer applies
backpressure until a group lands durably.
- The flush interval is a real group-commit window; chunks arriving
while a request is in flight accumulate and form the next writeMulti
group. One request in flight at a time preserves chunk order.
- Per-request wire limits (1,000 chunks / 1 MiB) split groups exactly
as the coalescing pipe did; an oversized single chunk goes alone.
- Durability moved to an explicit barrier (STREAM_DRAIN_SYMBOL):
close() drains before closing; flushablePipe adopts the barrier so
lock-release completion (step completion) still means 'everything
written is durable'; abort() DRAINS the accepted prefix (never
closing) so a producer error after acked writes cannot lose data —
native pipeTo aborts the sink on source failure; and a failed pipe
drains before settling so a step failure is not persisted ahead of
the emitted prefix. A dispatch failure retains the group, poisons
the sink, and surfaces at the next write/close/drain.
flushablePipe is now a plain per-chunk pump responsible only for
lock-release completion and durability tracking; its coalescing
machinery and STREAM_WRITE_BATCH_SYMBOL are removed.
Covered: native-pipeTo batching (the regression), awaited per-chunk
loops coalescing into one writeMulti, in-flight accumulation, wire-cap
splits (count/byte/oversized), in-flight-inclusive backpressure for
both bounds, sequential fallback without writeMulti, source-error
prefix delivery through abort, failed-pipe drain-before-reject,
early-ack sticky errors, turbo run-ready barrier gating (incl. dwell
telemetry), drain-barrier adoption/rejection, and group-level flush
spans. 1,572 core unit tests pass; e2e tier requires a deployment and
was not run here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3 requested review from a team and ijjk as code ownersJuly 23, 2026 23:52
@changeset-bot

changeset-botBot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a57ba7c

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

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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 23, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit a57ba7c · Fri, 24 Jul 2026 05:30:38 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep291 (-69%) 💚1419 🔴 (+25%) 🔻1447 🔴 (+24%) 🔻1770 🔴 (+31%) 🔻30
TTFSstream281 (-72%) 💚1406 🔴 (+22%) 🔻1442 🔴 (+23%) 🔻1613 🔴 (+31%) 🔻30
TTFShook + stream497 (+18%) 🔻1645 🔴 (+14%)1662 🔴 (+14%)1843 🔴 (+16%) 🔻30
STSO1020 steps (1-20)166 (-10%)267 🔴 (-27%) 💚319 🔴 (-33%) 💚331 🔴 (-52%) 💚19
STSO1020 steps (101-120)200 (-18%) 💚279 🔴 (-30%) 💚306 🔴 (-61%) 💚330 🔴 (-66%) 💚19
STSO1020 steps (1001-1020)490 (-0.8%)568 🔴 (-2.7%)714 🔴 (+14%)881 🔴 (+27%) 🔻19
WO1020 steps407715 (-8.3%)407715 (-8.3%)407715 (-8.3%)407715 (-8.3%)1
SLstream latency98 (-2.0%)178 🔴 (+23%) 🔻230 🔴 (+43%) 🔻253 🔴 (+29%) 🔻30
SOstream overhead (text)124 (-13%)222 (-18%) 💚403 (+24%) 🔻530 (+5.8%)30
SOstream overhead (structured)115 (-9.4%)215 (-15%) 💚263 (-13%)820 (+90%) 🔻30
📜 Previous results (4)

5f57e09

Fri, 24 Jul 2026 05:05:30 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1201 (+29%) 🔻1272 🔴 (+12%)1297 🔴 (+11%)1590 🔴 (+17%) 🔻30
TTFSstream1207 (+18%) 🔻1265 🔴 (+9.7%)1278 🔴 (+9.4%)1337 🔴 (+8.6%)30
TTFShook + stream527 (+25%) 🔻1540 🔴 (+7.2%)1687 🔴 (+16%) 🔻1837 🔴 (+16%) 🔻30
STSO1020 steps (1-20)167 (-9.7%)270 🔴 (-26%) 💚318 🔴 (-33%) 💚494 🔴 (-28%) 💚19
STSO1020 steps (101-120)183 (-25%) 💚239 🔴 (-40%) 💚316 🔴 (-60%) 💚644 🔴 (-33%) 💚19
STSO1020 steps (1001-1020)451 (-8.7%)535 🔴 (-8.4%)821 🔴 (+31%) 🔻3616 🔴 (+423%) 🔻19
WO1020 steps374593 (-16%) 💚374593 (-16%) 💚374593 (-16%) 💚374593 (-16%) 💚1
SLstream latency84 (-16%) 💚125 🔴 (-14%)161 🔴 (±0%)218 🔴 (+11%)30
SOstream overhead (text)98 (-31%) 💚141 (-48%) 💚182 (-44%) 💚277 (-45%) 💚30
SOstream overhead (structured)101 (-20%) 💚148 (-42%) 💚174 (-42%) 💚210 (-51%) 💚30

b5718b0

Fri, 24 Jul 2026 01:05:26 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1286 (+34%) 🔻1373 🔴 (+28%) 🔻1393 🔴 (+23%) 🔻1449 🔴 (+1.7%)30
TTFSstream1273 (+32%) 🔻1360 🔴 (+30%) 🔻1373 🔴 (+29%) 🔻1544 🔴 (+37%) 🔻30
TTFShook + stream1456 (+20%) 🔻1583 🔴 (+22%) 🔻1621 🔴 (+20%) 🔻1683 🔴 (+19%) 🔻30
STSO1020 steps (1-20)162 (-6.9%)256 🔴 (+3.6%)305 🔴 (+9.3%)380 🔴 (+10%)19
STSO1020 steps (101-120)188 (-0.5%)256 🔴 (-7.2%)318 🔴 (-3.6%)320 🔴 (-15%) 💚19
STSO1020 steps (1001-1020)500 (+9.4%)583 🔴 (+13%)658 🔴 (+24%) 🔻787 🔴 (+27%) 🔻19
WO1020 steps395092 (+2.2%)395092 (+2.2%)395092 (+2.2%)395092 (+2.2%)1
SLstream latency86 (+7.5%)148 🔴 (+12%)169 🔴 (-5.6%)371 🔴 (-1.1%)30
SOstream overhead111 (+2.8%)165 (-13%)207 (-5.0%)320 (+25%) 🔻30

7e5ad94

Fri, 24 Jul 2026 00:29:08 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1249 (+30%) 🔻1330 🔴 (+24%) 🔻1343 🔴 (+19%) 🔻1380 🔴 (-3.2%)30
TTFSstream333 (-66%) 💚1327 🔴 (+27%) 🔻1345 🔴 (+26%) 🔻1437 🔴 (+27%) 🔻30
TTFShook + stream1454 (+20%) 🔻1691 🔴 (+30%) 🔻1777 🔴 (+31%) 🔻2084 🔴 (+47%) 🔻30
STSO1020 steps (1-20)183 (+5.2%)278 🔴 (+13%)318 🔴 (+14%)387 🔴 (+13%)19
STSO1020 steps (101-120)188 (-0.5%)239 🔴 (-13%)257 🔴 (-22%) 💚279 🔴 (-26%) 💚19
STSO1020 steps (1001-1020)480 (+5.0%)621 🔴 (+21%) 🔻745 🔴 (+40%) 🔻776 🔴 (+25%) 🔻19
WO1020 steps395743 (+2.4%)395743 (+2.4%)395743 (+2.4%)395743 (+2.4%)1
SLstream latency117 (+46%) 🔻175 🔴 (+33%) 🔻204 🔴 (+14%)419 🔴 (+12%)30

9792b58

Fri, 24 Jul 2026 00:09:07 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1281 (+267%) 🔻1362 🔴 (+34%) 🔻1381 🔴 (+33%) 🔻1479 🔴 (+6.5%)30
TTFSstream1265 (+38%) 🔻1321 🔴 (+26%) 🔻1369 🔴 (+29%) 🔻1447 🔴 (±0%)30
TTFShook + stream1597 (+41%) 🔻1677 🔴 (+26%) 🔻1816 🔴 (+28%) 🔻1873 🔴 (+24%) 🔻30
STSO1020 steps (1-20)175 (-0.6%)260 🔴 (-6.1%)313 🔴 (-30%) 💚341 🔴 (-24%) 💚19
STSO1020 steps (101-120)179 (-4.8%)273 🔴 (-1.1%)521 🔴 (+61%) 🔻572 🔴 (+71%) 🔻19
STSO1020 steps (1001-1020)530 (+17%) 🔻616 🔴 (+8.5%)647 🔴 (-6.5%)762 🔴 (-8.7%)19
WO1020 steps419068 (+6.7%)419068 (+6.7%)419068 (+6.7%)419068 (+6.7%)1
SLstream latency120 (+50%) 🔻174 🔴 (+38%) 🔻185 🔴 (+27%) 🔻224 🔴 (-47%) 💚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 23, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

📦 Local Production (1 failed)

nextjs-turbopack-stable (1 failed):

  • webhookWorkflow | wrun_41KY98HTJK0GKYSP0S315E2QCH

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
❌ 📦 Local Production162012271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7518111328651
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-stable15400
✅ 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 threadpackages/core/src/serialization.ts
Review (bot): a write landing between the dispatch loop's empty-buffer
exit and the reaction clearing the in-flight marker armed no timer
(scheduleGroupCommit saw the marker set) and was never dispatched on an
open stream — only a later write/close/drain would pick it up. The
settle reaction now re-dispatches when the buffer is non-empty, treating
the chunk as an in-request arrival; drain waiters settle with the new
chain. Regression test aims a write at the settle gap and asserts both
chunks flush without a close.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t handling
Review note: the abort-path drain is deliberately un-timeboxed (a bound
would drop acked chunks); its worst case is owned by the World
transport's finite timeout/retry budget, and a teardown-driven drain
into an already-terminal run rejects into the existing catch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
karthikscale3and others added 2 commits July 23, 2026 21:44
The native-pipeTo batching test flaked on a slow CI runner: a fixed
25ms wait raced the 10ms commit window plus scheduler jitter. All
'dispatch has happened' assertions now poll the expectation (bounded);
intentional negatives keep their fixed windows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actionsgithub-actionsBot mentioned this pull request Jul 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for b610c46 (AI decision).

This rework builds directly on main-only streaming infrastructure: stable's @workflow/core has no world.streams.writeMulti API, no STREAM_WRITE_BATCH_SYMBOL/coalescing pipe, and none of the WORKFLOW_STREAM_MAX_* tuning knobs or flush telemetry this patch refactors (verified via git grep on origin/stable). The docs change also targets docs/content/docs/v5/configuration/runtime-tuning.mdx, which does not exist on stable. The diff would not apply meaningfully to stable's much older per-write streaming implementation.

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

b610c46f8143afe3c862fb9957f5b4b48e754b42

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@karthikscale3@VaguelySerious
, '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('^' + ".*" + ' perf(core): path-independent stream write batching (group commit in the server writable) by karthikscale3 · Pull Request #3078 · vercel/workflow · GitHub
Skip to content

perf(core): path-independent stream write batching (group commit in the server writable) - #3078

Merged
karthikscale3 merged 6 commits into
mainfrom
kk/writable-batching
Jul 24, 2026
Merged

perf(core): path-independent stream write batching (group commit in the server writable)#3078
karthikscale3 merged 6 commits into
mainfrom
kk/writable-batching

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Problem

Batching was implemented in flushablePipe's coalescing loop, so it depended on which piping path ran. getWritable() batched; a raw ReadableStream crossing a workflow/step boundary uses native pipeTo(), which doesn't pull chunk N+1 until write(chunk N) resolves — and write() resolved only after the flush timer and the server round trip. The buffer never held more than one chunk: one request per token.

Fix

Batching moves into WorkflowServerWritableStream, identical on every path:

  • write() resolves when the chunk enters a bounded client buffer (WORKFLOW_STREAM_MAX_INFLIGHT_CHUNKS counts buffered and in-request chunks; new byte bound WORKFLOW_STREAM_MAX_BUFFERED_BYTES, default 8 MiB, documented).
  • The flush interval is a real group-commit window; chunks arriving while a request is in flight form the next writeMulti group. One request in flight preserves order; 1,000-chunk / 1 MiB per-request limits preserved.
  • Durability is an explicit barrier: close() drains first; flushablePipe (now a plain pump: lock-release + durability tracking only) adopts the barrier so step completion still implies durability; abort()drains the accepted prefix without closing (a producer error after acked writes can't lose data); a failed pipe drains before settling. Dispatch failures are sticky and surface at the next write/close/drain.

Trade-off

An open getWritable() stream's first chunk now waits up to the group-commit interval (10ms default) instead of dispatching eagerly — watch TTFC / buffer_dwell_ms after release.

Validation

1,572 core unit tests pass, including new regressions for: native-pipeTo batching, awaited per-chunk loops coalescing into one writeMulti, wire-cap splits, in-flight-inclusive backpressure on both bounds, source-error prefix delivery through abort, failed-pipe drain-before-reject, and turbo run-ready barrier gating. E2E tier requires a deployment and was not run here.

Addressed some of the streaming perf concerns for use cases mentioned in #1930

…eam (group commit)
Batching previously lived in flushablePipe's coalescing loop, so it only
engaged on paths that used flushablePipe (getWritable). A raw
ReadableStream crossing a workflow/step boundary is piped with native
pipeTo(), which does not pull chunk N+1 until write(chunk N) resolves —
and write() resolved only after the flush timer AND the server round
trip, so the buffer never held more than one chunk and every token
became its own server request.
The sink now group-commits:
- write() resolves when the chunk enters a bounded client buffer; the
bound counts buffered AND in-request chunks
(WORKFLOW_STREAM_MAX_INFLIGHT_CHUNKS, preserving its documented
meaning) plus a byte bound (WORKFLOW_STREAM_MAX_BUFFERED_BYTES, new,
default 8 MiB, documented in runtime-tuning). A full buffer applies
backpressure until a group lands durably.
- The flush interval is a real group-commit window; chunks arriving
while a request is in flight accumulate and form the next writeMulti
group. One request in flight at a time preserves chunk order.
- Per-request wire limits (1,000 chunks / 1 MiB) split groups exactly
as the coalescing pipe did; an oversized single chunk goes alone.
- Durability moved to an explicit barrier (STREAM_DRAIN_SYMBOL):
close() drains before closing; flushablePipe adopts the barrier so
lock-release completion (step completion) still means 'everything
written is durable'; abort() DRAINS the accepted prefix (never
closing) so a producer error after acked writes cannot lose data —
native pipeTo aborts the sink on source failure; and a failed pipe
drains before settling so a step failure is not persisted ahead of
the emitted prefix. A dispatch failure retains the group, poisons
the sink, and surfaces at the next write/close/drain.
flushablePipe is now a plain per-chunk pump responsible only for
lock-release completion and durability tracking; its coalescing
machinery and STREAM_WRITE_BATCH_SYMBOL are removed.
Covered: native-pipeTo batching (the regression), awaited per-chunk
loops coalescing into one writeMulti, in-flight accumulation, wire-cap
splits (count/byte/oversized), in-flight-inclusive backpressure for
both bounds, sequential fallback without writeMulti, source-error
prefix delivery through abort, failed-pipe drain-before-reject,
early-ack sticky errors, turbo run-ready barrier gating (incl. dwell
telemetry), drain-barrier adoption/rejection, and group-level flush
spans. 1,572 core unit tests pass; e2e tier requires a deployment and
was not run here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3 requested review from a team and ijjk as code ownersJuly 23, 2026 23:52
@changeset-bot

changeset-botBot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a57ba7c

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

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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 23, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit a57ba7c · Fri, 24 Jul 2026 05:30:38 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep291 (-69%) 💚1419 🔴 (+25%) 🔻1447 🔴 (+24%) 🔻1770 🔴 (+31%) 🔻30
TTFSstream281 (-72%) 💚1406 🔴 (+22%) 🔻1442 🔴 (+23%) 🔻1613 🔴 (+31%) 🔻30
TTFShook + stream497 (+18%) 🔻1645 🔴 (+14%)1662 🔴 (+14%)1843 🔴 (+16%) 🔻30
STSO1020 steps (1-20)166 (-10%)267 🔴 (-27%) 💚319 🔴 (-33%) 💚331 🔴 (-52%) 💚19
STSO1020 steps (101-120)200 (-18%) 💚279 🔴 (-30%) 💚306 🔴 (-61%) 💚330 🔴 (-66%) 💚19
STSO1020 steps (1001-1020)490 (-0.8%)568 🔴 (-2.7%)714 🔴 (+14%)881 🔴 (+27%) 🔻19
WO1020 steps407715 (-8.3%)407715 (-8.3%)407715 (-8.3%)407715 (-8.3%)1
SLstream latency98 (-2.0%)178 🔴 (+23%) 🔻230 🔴 (+43%) 🔻253 🔴 (+29%) 🔻30
SOstream overhead (text)124 (-13%)222 (-18%) 💚403 (+24%) 🔻530 (+5.8%)30
SOstream overhead (structured)115 (-9.4%)215 (-15%) 💚263 (-13%)820 (+90%) 🔻30
📜 Previous results (4)

5f57e09

Fri, 24 Jul 2026 05:05:30 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1201 (+29%) 🔻1272 🔴 (+12%)1297 🔴 (+11%)1590 🔴 (+17%) 🔻30
TTFSstream1207 (+18%) 🔻1265 🔴 (+9.7%)1278 🔴 (+9.4%)1337 🔴 (+8.6%)30
TTFShook + stream527 (+25%) 🔻1540 🔴 (+7.2%)1687 🔴 (+16%) 🔻1837 🔴 (+16%) 🔻30
STSO1020 steps (1-20)167 (-9.7%)270 🔴 (-26%) 💚318 🔴 (-33%) 💚494 🔴 (-28%) 💚19
STSO1020 steps (101-120)183 (-25%) 💚239 🔴 (-40%) 💚316 🔴 (-60%) 💚644 🔴 (-33%) 💚19
STSO1020 steps (1001-1020)451 (-8.7%)535 🔴 (-8.4%)821 🔴 (+31%) 🔻3616 🔴 (+423%) 🔻19
WO1020 steps374593 (-16%) 💚374593 (-16%) 💚374593 (-16%) 💚374593 (-16%) 💚1
SLstream latency84 (-16%) 💚125 🔴 (-14%)161 🔴 (±0%)218 🔴 (+11%)30
SOstream overhead (text)98 (-31%) 💚141 (-48%) 💚182 (-44%) 💚277 (-45%) 💚30
SOstream overhead (structured)101 (-20%) 💚148 (-42%) 💚174 (-42%) 💚210 (-51%) 💚30

b5718b0

Fri, 24 Jul 2026 01:05:26 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1286 (+34%) 🔻1373 🔴 (+28%) 🔻1393 🔴 (+23%) 🔻1449 🔴 (+1.7%)30
TTFSstream1273 (+32%) 🔻1360 🔴 (+30%) 🔻1373 🔴 (+29%) 🔻1544 🔴 (+37%) 🔻30
TTFShook + stream1456 (+20%) 🔻1583 🔴 (+22%) 🔻1621 🔴 (+20%) 🔻1683 🔴 (+19%) 🔻30
STSO1020 steps (1-20)162 (-6.9%)256 🔴 (+3.6%)305 🔴 (+9.3%)380 🔴 (+10%)19
STSO1020 steps (101-120)188 (-0.5%)256 🔴 (-7.2%)318 🔴 (-3.6%)320 🔴 (-15%) 💚19
STSO1020 steps (1001-1020)500 (+9.4%)583 🔴 (+13%)658 🔴 (+24%) 🔻787 🔴 (+27%) 🔻19
WO1020 steps395092 (+2.2%)395092 (+2.2%)395092 (+2.2%)395092 (+2.2%)1
SLstream latency86 (+7.5%)148 🔴 (+12%)169 🔴 (-5.6%)371 🔴 (-1.1%)30
SOstream overhead111 (+2.8%)165 (-13%)207 (-5.0%)320 (+25%) 🔻30

7e5ad94

Fri, 24 Jul 2026 00:29:08 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1249 (+30%) 🔻1330 🔴 (+24%) 🔻1343 🔴 (+19%) 🔻1380 🔴 (-3.2%)30
TTFSstream333 (-66%) 💚1327 🔴 (+27%) 🔻1345 🔴 (+26%) 🔻1437 🔴 (+27%) 🔻30
TTFShook + stream1454 (+20%) 🔻1691 🔴 (+30%) 🔻1777 🔴 (+31%) 🔻2084 🔴 (+47%) 🔻30
STSO1020 steps (1-20)183 (+5.2%)278 🔴 (+13%)318 🔴 (+14%)387 🔴 (+13%)19
STSO1020 steps (101-120)188 (-0.5%)239 🔴 (-13%)257 🔴 (-22%) 💚279 🔴 (-26%) 💚19
STSO1020 steps (1001-1020)480 (+5.0%)621 🔴 (+21%) 🔻745 🔴 (+40%) 🔻776 🔴 (+25%) 🔻19
WO1020 steps395743 (+2.4%)395743 (+2.4%)395743 (+2.4%)395743 (+2.4%)1
SLstream latency117 (+46%) 🔻175 🔴 (+33%) 🔻204 🔴 (+14%)419 🔴 (+12%)30

9792b58

Fri, 24 Jul 2026 00:09:07 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1281 (+267%) 🔻1362 🔴 (+34%) 🔻1381 🔴 (+33%) 🔻1479 🔴 (+6.5%)30
TTFSstream1265 (+38%) 🔻1321 🔴 (+26%) 🔻1369 🔴 (+29%) 🔻1447 🔴 (±0%)30
TTFShook + stream1597 (+41%) 🔻1677 🔴 (+26%) 🔻1816 🔴 (+28%) 🔻1873 🔴 (+24%) 🔻30
STSO1020 steps (1-20)175 (-0.6%)260 🔴 (-6.1%)313 🔴 (-30%) 💚341 🔴 (-24%) 💚19
STSO1020 steps (101-120)179 (-4.8%)273 🔴 (-1.1%)521 🔴 (+61%) 🔻572 🔴 (+71%) 🔻19
STSO1020 steps (1001-1020)530 (+17%) 🔻616 🔴 (+8.5%)647 🔴 (-6.5%)762 🔴 (-8.7%)19
WO1020 steps419068 (+6.7%)419068 (+6.7%)419068 (+6.7%)419068 (+6.7%)1
SLstream latency120 (+50%) 🔻174 🔴 (+38%) 🔻185 🔴 (+27%) 🔻224 🔴 (-47%) 💚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 23, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

📦 Local Production (1 failed)

nextjs-turbopack-stable (1 failed):

  • webhookWorkflow | wrun_41KY98HTJK0GKYSP0S315E2QCH

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
❌ 📦 Local Production162012271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7518111328651
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-stable15400
✅ 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 threadpackages/core/src/serialization.ts
Review (bot): a write landing between the dispatch loop's empty-buffer
exit and the reaction clearing the in-flight marker armed no timer
(scheduleGroupCommit saw the marker set) and was never dispatched on an
open stream — only a later write/close/drain would pick it up. The
settle reaction now re-dispatches when the buffer is non-empty, treating
the chunk as an in-request arrival; drain waiters settle with the new
chain. Regression test aims a write at the settle gap and asserts both
chunks flush without a close.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t handling
Review note: the abort-path drain is deliberately un-timeboxed (a bound
would drop acked chunks); its worst case is owned by the World
transport's finite timeout/retry budget, and a teardown-driven drain
into an already-terminal run rejects into the existing catch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
karthikscale3and others added 2 commits July 23, 2026 21:44
The native-pipeTo batching test flaked on a slow CI runner: a fixed
25ms wait raced the 10ms commit window plus scheduler jitter. All
'dispatch has happened' assertions now poll the expectation (bounded);
intentional negatives keep their fixed windows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actionsgithub-actionsBot mentioned this pull request Jul 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for b610c46 (AI decision).

This rework builds directly on main-only streaming infrastructure: stable's @workflow/core has no world.streams.writeMulti API, no STREAM_WRITE_BATCH_SYMBOL/coalescing pipe, and none of the WORKFLOW_STREAM_MAX_* tuning knobs or flush telemetry this patch refactors (verified via git grep on origin/stable). The docs change also targets docs/content/docs/v5/configuration/runtime-tuning.mdx, which does not exist on stable. The diff would not apply meaningfully to stable's much older per-write streaming implementation.

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

b610c46f8143afe3c862fb9957f5b4b48e754b42

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@karthikscale3@VaguelySerious
, '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('^' + ".*" + ' perf(core): path-independent stream write batching (group commit in the server writable) by karthikscale3 · Pull Request #3078 · vercel/workflow · GitHub
Skip to content

perf(core): path-independent stream write batching (group commit in the server writable) - #3078

Merged
karthikscale3 merged 6 commits into
mainfrom
kk/writable-batching
Jul 24, 2026
Merged

perf(core): path-independent stream write batching (group commit in the server writable)#3078
karthikscale3 merged 6 commits into
mainfrom
kk/writable-batching

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Problem

Batching was implemented in flushablePipe's coalescing loop, so it depended on which piping path ran. getWritable() batched; a raw ReadableStream crossing a workflow/step boundary uses native pipeTo(), which doesn't pull chunk N+1 until write(chunk N) resolves — and write() resolved only after the flush timer and the server round trip. The buffer never held more than one chunk: one request per token.

Fix

Batching moves into WorkflowServerWritableStream, identical on every path:

  • write() resolves when the chunk enters a bounded client buffer (WORKFLOW_STREAM_MAX_INFLIGHT_CHUNKS counts buffered and in-request chunks; new byte bound WORKFLOW_STREAM_MAX_BUFFERED_BYTES, default 8 MiB, documented).
  • The flush interval is a real group-commit window; chunks arriving while a request is in flight form the next writeMulti group. One request in flight preserves order; 1,000-chunk / 1 MiB per-request limits preserved.
  • Durability is an explicit barrier: close() drains first; flushablePipe (now a plain pump: lock-release + durability tracking only) adopts the barrier so step completion still implies durability; abort()drains the accepted prefix without closing (a producer error after acked writes can't lose data); a failed pipe drains before settling. Dispatch failures are sticky and surface at the next write/close/drain.

Trade-off

An open getWritable() stream's first chunk now waits up to the group-commit interval (10ms default) instead of dispatching eagerly — watch TTFC / buffer_dwell_ms after release.

Validation

1,572 core unit tests pass, including new regressions for: native-pipeTo batching, awaited per-chunk loops coalescing into one writeMulti, wire-cap splits, in-flight-inclusive backpressure on both bounds, source-error prefix delivery through abort, failed-pipe drain-before-reject, and turbo run-ready barrier gating. E2E tier requires a deployment and was not run here.

Addressed some of the streaming perf concerns for use cases mentioned in #1930

…eam (group commit)
Batching previously lived in flushablePipe's coalescing loop, so it only
engaged on paths that used flushablePipe (getWritable). A raw
ReadableStream crossing a workflow/step boundary is piped with native
pipeTo(), which does not pull chunk N+1 until write(chunk N) resolves —
and write() resolved only after the flush timer AND the server round
trip, so the buffer never held more than one chunk and every token
became its own server request.
The sink now group-commits:
- write() resolves when the chunk enters a bounded client buffer; the
bound counts buffered AND in-request chunks
(WORKFLOW_STREAM_MAX_INFLIGHT_CHUNKS, preserving its documented
meaning) plus a byte bound (WORKFLOW_STREAM_MAX_BUFFERED_BYTES, new,
default 8 MiB, documented in runtime-tuning). A full buffer applies
backpressure until a group lands durably.
- The flush interval is a real group-commit window; chunks arriving
while a request is in flight accumulate and form the next writeMulti
group. One request in flight at a time preserves chunk order.
- Per-request wire limits (1,000 chunks / 1 MiB) split groups exactly
as the coalescing pipe did; an oversized single chunk goes alone.
- Durability moved to an explicit barrier (STREAM_DRAIN_SYMBOL):
close() drains before closing; flushablePipe adopts the barrier so
lock-release completion (step completion) still means 'everything
written is durable'; abort() DRAINS the accepted prefix (never
closing) so a producer error after acked writes cannot lose data —
native pipeTo aborts the sink on source failure; and a failed pipe
drains before settling so a step failure is not persisted ahead of
the emitted prefix. A dispatch failure retains the group, poisons
the sink, and surfaces at the next write/close/drain.
flushablePipe is now a plain per-chunk pump responsible only for
lock-release completion and durability tracking; its coalescing
machinery and STREAM_WRITE_BATCH_SYMBOL are removed.
Covered: native-pipeTo batching (the regression), awaited per-chunk
loops coalescing into one writeMulti, in-flight accumulation, wire-cap
splits (count/byte/oversized), in-flight-inclusive backpressure for
both bounds, sequential fallback without writeMulti, source-error
prefix delivery through abort, failed-pipe drain-before-reject,
early-ack sticky errors, turbo run-ready barrier gating (incl. dwell
telemetry), drain-barrier adoption/rejection, and group-level flush
spans. 1,572 core unit tests pass; e2e tier requires a deployment and
was not run here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3 requested review from a team and ijjk as code ownersJuly 23, 2026 23:52
@changeset-bot

changeset-botBot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a57ba7c

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

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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 23, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit a57ba7c · Fri, 24 Jul 2026 05:30:38 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep291 (-69%) 💚1419 🔴 (+25%) 🔻1447 🔴 (+24%) 🔻1770 🔴 (+31%) 🔻30
TTFSstream281 (-72%) 💚1406 🔴 (+22%) 🔻1442 🔴 (+23%) 🔻1613 🔴 (+31%) 🔻30
TTFShook + stream497 (+18%) 🔻1645 🔴 (+14%)1662 🔴 (+14%)1843 🔴 (+16%) 🔻30
STSO1020 steps (1-20)166 (-10%)267 🔴 (-27%) 💚319 🔴 (-33%) 💚331 🔴 (-52%) 💚19
STSO1020 steps (101-120)200 (-18%) 💚279 🔴 (-30%) 💚306 🔴 (-61%) 💚330 🔴 (-66%) 💚19
STSO1020 steps (1001-1020)490 (-0.8%)568 🔴 (-2.7%)714 🔴 (+14%)881 🔴 (+27%) 🔻19
WO1020 steps407715 (-8.3%)407715 (-8.3%)407715 (-8.3%)407715 (-8.3%)1
SLstream latency98 (-2.0%)178 🔴 (+23%) 🔻230 🔴 (+43%) 🔻253 🔴 (+29%) 🔻30
SOstream overhead (text)124 (-13%)222 (-18%) 💚403 (+24%) 🔻530 (+5.8%)30
SOstream overhead (structured)115 (-9.4%)215 (-15%) 💚263 (-13%)820 (+90%) 🔻30
📜 Previous results (4)

5f57e09

Fri, 24 Jul 2026 05:05:30 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1201 (+29%) 🔻1272 🔴 (+12%)1297 🔴 (+11%)1590 🔴 (+17%) 🔻30
TTFSstream1207 (+18%) 🔻1265 🔴 (+9.7%)1278 🔴 (+9.4%)1337 🔴 (+8.6%)30
TTFShook + stream527 (+25%) 🔻1540 🔴 (+7.2%)1687 🔴 (+16%) 🔻1837 🔴 (+16%) 🔻30
STSO1020 steps (1-20)167 (-9.7%)270 🔴 (-26%) 💚318 🔴 (-33%) 💚494 🔴 (-28%) 💚19
STSO1020 steps (101-120)183 (-25%) 💚239 🔴 (-40%) 💚316 🔴 (-60%) 💚644 🔴 (-33%) 💚19
STSO1020 steps (1001-1020)451 (-8.7%)535 🔴 (-8.4%)821 🔴 (+31%) 🔻3616 🔴 (+423%) 🔻19
WO1020 steps374593 (-16%) 💚374593 (-16%) 💚374593 (-16%) 💚374593 (-16%) 💚1
SLstream latency84 (-16%) 💚125 🔴 (-14%)161 🔴 (±0%)218 🔴 (+11%)30
SOstream overhead (text)98 (-31%) 💚141 (-48%) 💚182 (-44%) 💚277 (-45%) 💚30
SOstream overhead (structured)101 (-20%) 💚148 (-42%) 💚174 (-42%) 💚210 (-51%) 💚30

b5718b0

Fri, 24 Jul 2026 01:05:26 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1286 (+34%) 🔻1373 🔴 (+28%) 🔻1393 🔴 (+23%) 🔻1449 🔴 (+1.7%)30
TTFSstream1273 (+32%) 🔻1360 🔴 (+30%) 🔻1373 🔴 (+29%) 🔻1544 🔴 (+37%) 🔻30
TTFShook + stream1456 (+20%) 🔻1583 🔴 (+22%) 🔻1621 🔴 (+20%) 🔻1683 🔴 (+19%) 🔻30
STSO1020 steps (1-20)162 (-6.9%)256 🔴 (+3.6%)305 🔴 (+9.3%)380 🔴 (+10%)19
STSO1020 steps (101-120)188 (-0.5%)256 🔴 (-7.2%)318 🔴 (-3.6%)320 🔴 (-15%) 💚19
STSO1020 steps (1001-1020)500 (+9.4%)583 🔴 (+13%)658 🔴 (+24%) 🔻787 🔴 (+27%) 🔻19
WO1020 steps395092 (+2.2%)395092 (+2.2%)395092 (+2.2%)395092 (+2.2%)1
SLstream latency86 (+7.5%)148 🔴 (+12%)169 🔴 (-5.6%)371 🔴 (-1.1%)30
SOstream overhead111 (+2.8%)165 (-13%)207 (-5.0%)320 (+25%) 🔻30

7e5ad94

Fri, 24 Jul 2026 00:29:08 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1249 (+30%) 🔻1330 🔴 (+24%) 🔻1343 🔴 (+19%) 🔻1380 🔴 (-3.2%)30
TTFSstream333 (-66%) 💚1327 🔴 (+27%) 🔻1345 🔴 (+26%) 🔻1437 🔴 (+27%) 🔻30
TTFShook + stream1454 (+20%) 🔻1691 🔴 (+30%) 🔻1777 🔴 (+31%) 🔻2084 🔴 (+47%) 🔻30
STSO1020 steps (1-20)183 (+5.2%)278 🔴 (+13%)318 🔴 (+14%)387 🔴 (+13%)19
STSO1020 steps (101-120)188 (-0.5%)239 🔴 (-13%)257 🔴 (-22%) 💚279 🔴 (-26%) 💚19
STSO1020 steps (1001-1020)480 (+5.0%)621 🔴 (+21%) 🔻745 🔴 (+40%) 🔻776 🔴 (+25%) 🔻19
WO1020 steps395743 (+2.4%)395743 (+2.4%)395743 (+2.4%)395743 (+2.4%)1
SLstream latency117 (+46%) 🔻175 🔴 (+33%) 🔻204 🔴 (+14%)419 🔴 (+12%)30

9792b58

Fri, 24 Jul 2026 00:09:07 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1281 (+267%) 🔻1362 🔴 (+34%) 🔻1381 🔴 (+33%) 🔻1479 🔴 (+6.5%)30
TTFSstream1265 (+38%) 🔻1321 🔴 (+26%) 🔻1369 🔴 (+29%) 🔻1447 🔴 (±0%)30
TTFShook + stream1597 (+41%) 🔻1677 🔴 (+26%) 🔻1816 🔴 (+28%) 🔻1873 🔴 (+24%) 🔻30
STSO1020 steps (1-20)175 (-0.6%)260 🔴 (-6.1%)313 🔴 (-30%) 💚341 🔴 (-24%) 💚19
STSO1020 steps (101-120)179 (-4.8%)273 🔴 (-1.1%)521 🔴 (+61%) 🔻572 🔴 (+71%) 🔻19
STSO1020 steps (1001-1020)530 (+17%) 🔻616 🔴 (+8.5%)647 🔴 (-6.5%)762 🔴 (-8.7%)19
WO1020 steps419068 (+6.7%)419068 (+6.7%)419068 (+6.7%)419068 (+6.7%)1
SLstream latency120 (+50%) 🔻174 🔴 (+38%) 🔻185 🔴 (+27%) 🔻224 🔴 (-47%) 💚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 23, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

📦 Local Production (1 failed)

nextjs-turbopack-stable (1 failed):

  • webhookWorkflow | wrun_41KY98HTJK0GKYSP0S315E2QCH

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
❌ 📦 Local Production162012271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7518111328651
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-stable15400
✅ 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 threadpackages/core/src/serialization.ts
Review (bot): a write landing between the dispatch loop's empty-buffer
exit and the reaction clearing the in-flight marker armed no timer
(scheduleGroupCommit saw the marker set) and was never dispatched on an
open stream — only a later write/close/drain would pick it up. The
settle reaction now re-dispatches when the buffer is non-empty, treating
the chunk as an in-request arrival; drain waiters settle with the new
chain. Regression test aims a write at the settle gap and asserts both
chunks flush without a close.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t handling
Review note: the abort-path drain is deliberately un-timeboxed (a bound
would drop acked chunks); its worst case is owned by the World
transport's finite timeout/retry budget, and a teardown-driven drain
into an already-terminal run rejects into the existing catch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
karthikscale3and others added 2 commits July 23, 2026 21:44
The native-pipeTo batching test flaked on a slow CI runner: a fixed
25ms wait raced the 10ms commit window plus scheduler jitter. All
'dispatch has happened' assertions now poll the expectation (bounded);
intentional negatives keep their fixed windows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actionsgithub-actionsBot mentioned this pull request Jul 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for b610c46 (AI decision).

This rework builds directly on main-only streaming infrastructure: stable's @workflow/core has no world.streams.writeMulti API, no STREAM_WRITE_BATCH_SYMBOL/coalescing pipe, and none of the WORKFLOW_STREAM_MAX_* tuning knobs or flush telemetry this patch refactors (verified via git grep on origin/stable). The docs change also targets docs/content/docs/v5/configuration/runtime-tuning.mdx, which does not exist on stable. The diff would not apply meaningfully to stable's much older per-write streaming implementation.

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

b610c46f8143afe3c862fb9957f5b4b48e754b42

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@karthikscale3@VaguelySerious
, '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" + ' perf(core): path-independent stream write batching (group commit in the server writable) by karthikscale3 · Pull Request #3078 · vercel/workflow · GitHub
Skip to content

perf(core): path-independent stream write batching (group commit in the server writable) - #3078

Merged
karthikscale3 merged 6 commits into
mainfrom
kk/writable-batching
Jul 24, 2026
Merged

perf(core): path-independent stream write batching (group commit in the server writable)#3078
karthikscale3 merged 6 commits into
mainfrom
kk/writable-batching

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Problem

Batching was implemented in flushablePipe's coalescing loop, so it depended on which piping path ran. getWritable() batched; a raw ReadableStream crossing a workflow/step boundary uses native pipeTo(), which doesn't pull chunk N+1 until write(chunk N) resolves — and write() resolved only after the flush timer and the server round trip. The buffer never held more than one chunk: one request per token.

Fix

Batching moves into WorkflowServerWritableStream, identical on every path:

  • write() resolves when the chunk enters a bounded client buffer (WORKFLOW_STREAM_MAX_INFLIGHT_CHUNKS counts buffered and in-request chunks; new byte bound WORKFLOW_STREAM_MAX_BUFFERED_BYTES, default 8 MiB, documented).
  • The flush interval is a real group-commit window; chunks arriving while a request is in flight form the next writeMulti group. One request in flight preserves order; 1,000-chunk / 1 MiB per-request limits preserved.
  • Durability is an explicit barrier: close() drains first; flushablePipe (now a plain pump: lock-release + durability tracking only) adopts the barrier so step completion still implies durability; abort()drains the accepted prefix without closing (a producer error after acked writes can't lose data); a failed pipe drains before settling. Dispatch failures are sticky and surface at the next write/close/drain.

Trade-off

An open getWritable() stream's first chunk now waits up to the group-commit interval (10ms default) instead of dispatching eagerly — watch TTFC / buffer_dwell_ms after release.

Validation

1,572 core unit tests pass, including new regressions for: native-pipeTo batching, awaited per-chunk loops coalescing into one writeMulti, wire-cap splits, in-flight-inclusive backpressure on both bounds, source-error prefix delivery through abort, failed-pipe drain-before-reject, and turbo run-ready barrier gating. E2E tier requires a deployment and was not run here.

Addressed some of the streaming perf concerns for use cases mentioned in #1930

…eam (group commit)
Batching previously lived in flushablePipe's coalescing loop, so it only
engaged on paths that used flushablePipe (getWritable). A raw
ReadableStream crossing a workflow/step boundary is piped with native
pipeTo(), which does not pull chunk N+1 until write(chunk N) resolves —
and write() resolved only after the flush timer AND the server round
trip, so the buffer never held more than one chunk and every token
became its own server request.
The sink now group-commits:
- write() resolves when the chunk enters a bounded client buffer; the
bound counts buffered AND in-request chunks
(WORKFLOW_STREAM_MAX_INFLIGHT_CHUNKS, preserving its documented
meaning) plus a byte bound (WORKFLOW_STREAM_MAX_BUFFERED_BYTES, new,
default 8 MiB, documented in runtime-tuning). A full buffer applies
backpressure until a group lands durably.
- The flush interval is a real group-commit window; chunks arriving
while a request is in flight accumulate and form the next writeMulti
group. One request in flight at a time preserves chunk order.
- Per-request wire limits (1,000 chunks / 1 MiB) split groups exactly
as the coalescing pipe did; an oversized single chunk goes alone.
- Durability moved to an explicit barrier (STREAM_DRAIN_SYMBOL):
close() drains before closing; flushablePipe adopts the barrier so
lock-release completion (step completion) still means 'everything
written is durable'; abort() DRAINS the accepted prefix (never
closing) so a producer error after acked writes cannot lose data —
native pipeTo aborts the sink on source failure; and a failed pipe
drains before settling so a step failure is not persisted ahead of
the emitted prefix. A dispatch failure retains the group, poisons
the sink, and surfaces at the next write/close/drain.
flushablePipe is now a plain per-chunk pump responsible only for
lock-release completion and durability tracking; its coalescing
machinery and STREAM_WRITE_BATCH_SYMBOL are removed.
Covered: native-pipeTo batching (the regression), awaited per-chunk
loops coalescing into one writeMulti, in-flight accumulation, wire-cap
splits (count/byte/oversized), in-flight-inclusive backpressure for
both bounds, sequential fallback without writeMulti, source-error
prefix delivery through abort, failed-pipe drain-before-reject,
early-ack sticky errors, turbo run-ready barrier gating (incl. dwell
telemetry), drain-barrier adoption/rejection, and group-level flush
spans. 1,572 core unit tests pass; e2e tier requires a deployment and
was not run here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3 requested review from a team and ijjk as code ownersJuly 23, 2026 23:52
@changeset-bot

changeset-botBot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a57ba7c

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

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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 23, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit a57ba7c · Fri, 24 Jul 2026 05:30:38 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep291 (-69%) 💚1419 🔴 (+25%) 🔻1447 🔴 (+24%) 🔻1770 🔴 (+31%) 🔻30
TTFSstream281 (-72%) 💚1406 🔴 (+22%) 🔻1442 🔴 (+23%) 🔻1613 🔴 (+31%) 🔻30
TTFShook + stream497 (+18%) 🔻1645 🔴 (+14%)1662 🔴 (+14%)1843 🔴 (+16%) 🔻30
STSO1020 steps (1-20)166 (-10%)267 🔴 (-27%) 💚319 🔴 (-33%) 💚331 🔴 (-52%) 💚19
STSO1020 steps (101-120)200 (-18%) 💚279 🔴 (-30%) 💚306 🔴 (-61%) 💚330 🔴 (-66%) 💚19
STSO1020 steps (1001-1020)490 (-0.8%)568 🔴 (-2.7%)714 🔴 (+14%)881 🔴 (+27%) 🔻19
WO1020 steps407715 (-8.3%)407715 (-8.3%)407715 (-8.3%)407715 (-8.3%)1
SLstream latency98 (-2.0%)178 🔴 (+23%) 🔻230 🔴 (+43%) 🔻253 🔴 (+29%) 🔻30
SOstream overhead (text)124 (-13%)222 (-18%) 💚403 (+24%) 🔻530 (+5.8%)30
SOstream overhead (structured)115 (-9.4%)215 (-15%) 💚263 (-13%)820 (+90%) 🔻30
📜 Previous results (4)

5f57e09

Fri, 24 Jul 2026 05:05:30 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1201 (+29%) 🔻1272 🔴 (+12%)1297 🔴 (+11%)1590 🔴 (+17%) 🔻30
TTFSstream1207 (+18%) 🔻1265 🔴 (+9.7%)1278 🔴 (+9.4%)1337 🔴 (+8.6%)30
TTFShook + stream527 (+25%) 🔻1540 🔴 (+7.2%)1687 🔴 (+16%) 🔻1837 🔴 (+16%) 🔻30
STSO1020 steps (1-20)167 (-9.7%)270 🔴 (-26%) 💚318 🔴 (-33%) 💚494 🔴 (-28%) 💚19
STSO1020 steps (101-120)183 (-25%) 💚239 🔴 (-40%) 💚316 🔴 (-60%) 💚644 🔴 (-33%) 💚19
STSO1020 steps (1001-1020)451 (-8.7%)535 🔴 (-8.4%)821 🔴 (+31%) 🔻3616 🔴 (+423%) 🔻19
WO1020 steps374593 (-16%) 💚374593 (-16%) 💚374593 (-16%) 💚374593 (-16%) 💚1
SLstream latency84 (-16%) 💚125 🔴 (-14%)161 🔴 (±0%)218 🔴 (+11%)30
SOstream overhead (text)98 (-31%) 💚141 (-48%) 💚182 (-44%) 💚277 (-45%) 💚30
SOstream overhead (structured)101 (-20%) 💚148 (-42%) 💚174 (-42%) 💚210 (-51%) 💚30

b5718b0

Fri, 24 Jul 2026 01:05:26 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1286 (+34%) 🔻1373 🔴 (+28%) 🔻1393 🔴 (+23%) 🔻1449 🔴 (+1.7%)30
TTFSstream1273 (+32%) 🔻1360 🔴 (+30%) 🔻1373 🔴 (+29%) 🔻1544 🔴 (+37%) 🔻30
TTFShook + stream1456 (+20%) 🔻1583 🔴 (+22%) 🔻1621 🔴 (+20%) 🔻1683 🔴 (+19%) 🔻30
STSO1020 steps (1-20)162 (-6.9%)256 🔴 (+3.6%)305 🔴 (+9.3%)380 🔴 (+10%)19
STSO1020 steps (101-120)188 (-0.5%)256 🔴 (-7.2%)318 🔴 (-3.6%)320 🔴 (-15%) 💚19
STSO1020 steps (1001-1020)500 (+9.4%)583 🔴 (+13%)658 🔴 (+24%) 🔻787 🔴 (+27%) 🔻19
WO1020 steps395092 (+2.2%)395092 (+2.2%)395092 (+2.2%)395092 (+2.2%)1
SLstream latency86 (+7.5%)148 🔴 (+12%)169 🔴 (-5.6%)371 🔴 (-1.1%)30
SOstream overhead111 (+2.8%)165 (-13%)207 (-5.0%)320 (+25%) 🔻30

7e5ad94

Fri, 24 Jul 2026 00:29:08 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1249 (+30%) 🔻1330 🔴 (+24%) 🔻1343 🔴 (+19%) 🔻1380 🔴 (-3.2%)30
TTFSstream333 (-66%) 💚1327 🔴 (+27%) 🔻1345 🔴 (+26%) 🔻1437 🔴 (+27%) 🔻30
TTFShook + stream1454 (+20%) 🔻1691 🔴 (+30%) 🔻1777 🔴 (+31%) 🔻2084 🔴 (+47%) 🔻30
STSO1020 steps (1-20)183 (+5.2%)278 🔴 (+13%)318 🔴 (+14%)387 🔴 (+13%)19
STSO1020 steps (101-120)188 (-0.5%)239 🔴 (-13%)257 🔴 (-22%) 💚279 🔴 (-26%) 💚19
STSO1020 steps (1001-1020)480 (+5.0%)621 🔴 (+21%) 🔻745 🔴 (+40%) 🔻776 🔴 (+25%) 🔻19
WO1020 steps395743 (+2.4%)395743 (+2.4%)395743 (+2.4%)395743 (+2.4%)1
SLstream latency117 (+46%) 🔻175 🔴 (+33%) 🔻204 🔴 (+14%)419 🔴 (+12%)30

9792b58

Fri, 24 Jul 2026 00:09:07 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1281 (+267%) 🔻1362 🔴 (+34%) 🔻1381 🔴 (+33%) 🔻1479 🔴 (+6.5%)30
TTFSstream1265 (+38%) 🔻1321 🔴 (+26%) 🔻1369 🔴 (+29%) 🔻1447 🔴 (±0%)30
TTFShook + stream1597 (+41%) 🔻1677 🔴 (+26%) 🔻1816 🔴 (+28%) 🔻1873 🔴 (+24%) 🔻30
STSO1020 steps (1-20)175 (-0.6%)260 🔴 (-6.1%)313 🔴 (-30%) 💚341 🔴 (-24%) 💚19
STSO1020 steps (101-120)179 (-4.8%)273 🔴 (-1.1%)521 🔴 (+61%) 🔻572 🔴 (+71%) 🔻19
STSO1020 steps (1001-1020)530 (+17%) 🔻616 🔴 (+8.5%)647 🔴 (-6.5%)762 🔴 (-8.7%)19
WO1020 steps419068 (+6.7%)419068 (+6.7%)419068 (+6.7%)419068 (+6.7%)1
SLstream latency120 (+50%) 🔻174 🔴 (+38%) 🔻185 🔴 (+27%) 🔻224 🔴 (-47%) 💚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 23, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

📦 Local Production (1 failed)

nextjs-turbopack-stable (1 failed):

  • webhookWorkflow | wrun_41KY98HTJK0GKYSP0S315E2QCH

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
❌ 📦 Local Production162012271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7518111328651
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-stable15400
✅ 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 threadpackages/core/src/serialization.ts
Review (bot): a write landing between the dispatch loop's empty-buffer
exit and the reaction clearing the in-flight marker armed no timer
(scheduleGroupCommit saw the marker set) and was never dispatched on an
open stream — only a later write/close/drain would pick it up. The
settle reaction now re-dispatches when the buffer is non-empty, treating
the chunk as an in-request arrival; drain waiters settle with the new
chain. Regression test aims a write at the settle gap and asserts both
chunks flush without a close.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t handling
Review note: the abort-path drain is deliberately un-timeboxed (a bound
would drop acked chunks); its worst case is owned by the World
transport's finite timeout/retry budget, and a teardown-driven drain
into an already-terminal run rejects into the existing catch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
karthikscale3and others added 2 commits July 23, 2026 21:44
The native-pipeTo batching test flaked on a slow CI runner: a fixed
25ms wait raced the 10ms commit window plus scheduler jitter. All
'dispatch has happened' assertions now poll the expectation (bounded);
intentional negatives keep their fixed windows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actionsgithub-actionsBot mentioned this pull request Jul 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for b610c46 (AI decision).

This rework builds directly on main-only streaming infrastructure: stable's @workflow/core has no world.streams.writeMulti API, no STREAM_WRITE_BATCH_SYMBOL/coalescing pipe, and none of the WORKFLOW_STREAM_MAX_* tuning knobs or flush telemetry this patch refactors (verified via git grep on origin/stable). The docs change also targets docs/content/docs/v5/configuration/runtime-tuning.mdx, which does not exist on stable. The diff would not apply meaningfully to stable's much older per-write streaming implementation.

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

b610c46f8143afe3c862fb9957f5b4b48e754b42

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@karthikscale3@VaguelySerious
, '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('^' + ".*" + ' perf(core): path-independent stream write batching (group commit in the server writable) by karthikscale3 · Pull Request #3078 · vercel/workflow · GitHub
Skip to content

perf(core): path-independent stream write batching (group commit in the server writable) - #3078

Merged
karthikscale3 merged 6 commits into
mainfrom
kk/writable-batching
Jul 24, 2026
Merged

perf(core): path-independent stream write batching (group commit in the server writable)#3078
karthikscale3 merged 6 commits into
mainfrom
kk/writable-batching

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Problem

Batching was implemented in flushablePipe's coalescing loop, so it depended on which piping path ran. getWritable() batched; a raw ReadableStream crossing a workflow/step boundary uses native pipeTo(), which doesn't pull chunk N+1 until write(chunk N) resolves — and write() resolved only after the flush timer and the server round trip. The buffer never held more than one chunk: one request per token.

Fix

Batching moves into WorkflowServerWritableStream, identical on every path:

  • write() resolves when the chunk enters a bounded client buffer (WORKFLOW_STREAM_MAX_INFLIGHT_CHUNKS counts buffered and in-request chunks; new byte bound WORKFLOW_STREAM_MAX_BUFFERED_BYTES, default 8 MiB, documented).
  • The flush interval is a real group-commit window; chunks arriving while a request is in flight form the next writeMulti group. One request in flight preserves order; 1,000-chunk / 1 MiB per-request limits preserved.
  • Durability is an explicit barrier: close() drains first; flushablePipe (now a plain pump: lock-release + durability tracking only) adopts the barrier so step completion still implies durability; abort()drains the accepted prefix without closing (a producer error after acked writes can't lose data); a failed pipe drains before settling. Dispatch failures are sticky and surface at the next write/close/drain.

Trade-off

An open getWritable() stream's first chunk now waits up to the group-commit interval (10ms default) instead of dispatching eagerly — watch TTFC / buffer_dwell_ms after release.

Validation

1,572 core unit tests pass, including new regressions for: native-pipeTo batching, awaited per-chunk loops coalescing into one writeMulti, wire-cap splits, in-flight-inclusive backpressure on both bounds, source-error prefix delivery through abort, failed-pipe drain-before-reject, and turbo run-ready barrier gating. E2E tier requires a deployment and was not run here.

Addressed some of the streaming perf concerns for use cases mentioned in #1930

…eam (group commit)
Batching previously lived in flushablePipe's coalescing loop, so it only
engaged on paths that used flushablePipe (getWritable). A raw
ReadableStream crossing a workflow/step boundary is piped with native
pipeTo(), which does not pull chunk N+1 until write(chunk N) resolves —
and write() resolved only after the flush timer AND the server round
trip, so the buffer never held more than one chunk and every token
became its own server request.
The sink now group-commits:
- write() resolves when the chunk enters a bounded client buffer; the
bound counts buffered AND in-request chunks
(WORKFLOW_STREAM_MAX_INFLIGHT_CHUNKS, preserving its documented
meaning) plus a byte bound (WORKFLOW_STREAM_MAX_BUFFERED_BYTES, new,
default 8 MiB, documented in runtime-tuning). A full buffer applies
backpressure until a group lands durably.
- The flush interval is a real group-commit window; chunks arriving
while a request is in flight accumulate and form the next writeMulti
group. One request in flight at a time preserves chunk order.
- Per-request wire limits (1,000 chunks / 1 MiB) split groups exactly
as the coalescing pipe did; an oversized single chunk goes alone.
- Durability moved to an explicit barrier (STREAM_DRAIN_SYMBOL):
close() drains before closing; flushablePipe adopts the barrier so
lock-release completion (step completion) still means 'everything
written is durable'; abort() DRAINS the accepted prefix (never
closing) so a producer error after acked writes cannot lose data —
native pipeTo aborts the sink on source failure; and a failed pipe
drains before settling so a step failure is not persisted ahead of
the emitted prefix. A dispatch failure retains the group, poisons
the sink, and surfaces at the next write/close/drain.
flushablePipe is now a plain per-chunk pump responsible only for
lock-release completion and durability tracking; its coalescing
machinery and STREAM_WRITE_BATCH_SYMBOL are removed.
Covered: native-pipeTo batching (the regression), awaited per-chunk
loops coalescing into one writeMulti, in-flight accumulation, wire-cap
splits (count/byte/oversized), in-flight-inclusive backpressure for
both bounds, sequential fallback without writeMulti, source-error
prefix delivery through abort, failed-pipe drain-before-reject,
early-ack sticky errors, turbo run-ready barrier gating (incl. dwell
telemetry), drain-barrier adoption/rejection, and group-level flush
spans. 1,572 core unit tests pass; e2e tier requires a deployment and
was not run here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3 requested review from a team and ijjk as code ownersJuly 23, 2026 23:52
@changeset-bot

changeset-botBot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a57ba7c

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

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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 23, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit a57ba7c · Fri, 24 Jul 2026 05:30:38 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep291 (-69%) 💚1419 🔴 (+25%) 🔻1447 🔴 (+24%) 🔻1770 🔴 (+31%) 🔻30
TTFSstream281 (-72%) 💚1406 🔴 (+22%) 🔻1442 🔴 (+23%) 🔻1613 🔴 (+31%) 🔻30
TTFShook + stream497 (+18%) 🔻1645 🔴 (+14%)1662 🔴 (+14%)1843 🔴 (+16%) 🔻30
STSO1020 steps (1-20)166 (-10%)267 🔴 (-27%) 💚319 🔴 (-33%) 💚331 🔴 (-52%) 💚19
STSO1020 steps (101-120)200 (-18%) 💚279 🔴 (-30%) 💚306 🔴 (-61%) 💚330 🔴 (-66%) 💚19
STSO1020 steps (1001-1020)490 (-0.8%)568 🔴 (-2.7%)714 🔴 (+14%)881 🔴 (+27%) 🔻19
WO1020 steps407715 (-8.3%)407715 (-8.3%)407715 (-8.3%)407715 (-8.3%)1
SLstream latency98 (-2.0%)178 🔴 (+23%) 🔻230 🔴 (+43%) 🔻253 🔴 (+29%) 🔻30
SOstream overhead (text)124 (-13%)222 (-18%) 💚403 (+24%) 🔻530 (+5.8%)30
SOstream overhead (structured)115 (-9.4%)215 (-15%) 💚263 (-13%)820 (+90%) 🔻30
📜 Previous results (4)

5f57e09

Fri, 24 Jul 2026 05:05:30 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1201 (+29%) 🔻1272 🔴 (+12%)1297 🔴 (+11%)1590 🔴 (+17%) 🔻30
TTFSstream1207 (+18%) 🔻1265 🔴 (+9.7%)1278 🔴 (+9.4%)1337 🔴 (+8.6%)30
TTFShook + stream527 (+25%) 🔻1540 🔴 (+7.2%)1687 🔴 (+16%) 🔻1837 🔴 (+16%) 🔻30
STSO1020 steps (1-20)167 (-9.7%)270 🔴 (-26%) 💚318 🔴 (-33%) 💚494 🔴 (-28%) 💚19
STSO1020 steps (101-120)183 (-25%) 💚239 🔴 (-40%) 💚316 🔴 (-60%) 💚644 🔴 (-33%) 💚19
STSO1020 steps (1001-1020)451 (-8.7%)535 🔴 (-8.4%)821 🔴 (+31%) 🔻3616 🔴 (+423%) 🔻19
WO1020 steps374593 (-16%) 💚374593 (-16%) 💚374593 (-16%) 💚374593 (-16%) 💚1
SLstream latency84 (-16%) 💚125 🔴 (-14%)161 🔴 (±0%)218 🔴 (+11%)30
SOstream overhead (text)98 (-31%) 💚141 (-48%) 💚182 (-44%) 💚277 (-45%) 💚30
SOstream overhead (structured)101 (-20%) 💚148 (-42%) 💚174 (-42%) 💚210 (-51%) 💚30

b5718b0

Fri, 24 Jul 2026 01:05:26 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1286 (+34%) 🔻1373 🔴 (+28%) 🔻1393 🔴 (+23%) 🔻1449 🔴 (+1.7%)30
TTFSstream1273 (+32%) 🔻1360 🔴 (+30%) 🔻1373 🔴 (+29%) 🔻1544 🔴 (+37%) 🔻30
TTFShook + stream1456 (+20%) 🔻1583 🔴 (+22%) 🔻1621 🔴 (+20%) 🔻1683 🔴 (+19%) 🔻30
STSO1020 steps (1-20)162 (-6.9%)256 🔴 (+3.6%)305 🔴 (+9.3%)380 🔴 (+10%)19
STSO1020 steps (101-120)188 (-0.5%)256 🔴 (-7.2%)318 🔴 (-3.6%)320 🔴 (-15%) 💚19
STSO1020 steps (1001-1020)500 (+9.4%)583 🔴 (+13%)658 🔴 (+24%) 🔻787 🔴 (+27%) 🔻19
WO1020 steps395092 (+2.2%)395092 (+2.2%)395092 (+2.2%)395092 (+2.2%)1
SLstream latency86 (+7.5%)148 🔴 (+12%)169 🔴 (-5.6%)371 🔴 (-1.1%)30
SOstream overhead111 (+2.8%)165 (-13%)207 (-5.0%)320 (+25%) 🔻30

7e5ad94

Fri, 24 Jul 2026 00:29:08 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1249 (+30%) 🔻1330 🔴 (+24%) 🔻1343 🔴 (+19%) 🔻1380 🔴 (-3.2%)30
TTFSstream333 (-66%) 💚1327 🔴 (+27%) 🔻1345 🔴 (+26%) 🔻1437 🔴 (+27%) 🔻30
TTFShook + stream1454 (+20%) 🔻1691 🔴 (+30%) 🔻1777 🔴 (+31%) 🔻2084 🔴 (+47%) 🔻30
STSO1020 steps (1-20)183 (+5.2%)278 🔴 (+13%)318 🔴 (+14%)387 🔴 (+13%)19
STSO1020 steps (101-120)188 (-0.5%)239 🔴 (-13%)257 🔴 (-22%) 💚279 🔴 (-26%) 💚19
STSO1020 steps (1001-1020)480 (+5.0%)621 🔴 (+21%) 🔻745 🔴 (+40%) 🔻776 🔴 (+25%) 🔻19
WO1020 steps395743 (+2.4%)395743 (+2.4%)395743 (+2.4%)395743 (+2.4%)1
SLstream latency117 (+46%) 🔻175 🔴 (+33%) 🔻204 🔴 (+14%)419 🔴 (+12%)30

9792b58

Fri, 24 Jul 2026 00:09:07 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1281 (+267%) 🔻1362 🔴 (+34%) 🔻1381 🔴 (+33%) 🔻1479 🔴 (+6.5%)30
TTFSstream1265 (+38%) 🔻1321 🔴 (+26%) 🔻1369 🔴 (+29%) 🔻1447 🔴 (±0%)30
TTFShook + stream1597 (+41%) 🔻1677 🔴 (+26%) 🔻1816 🔴 (+28%) 🔻1873 🔴 (+24%) 🔻30
STSO1020 steps (1-20)175 (-0.6%)260 🔴 (-6.1%)313 🔴 (-30%) 💚341 🔴 (-24%) 💚19
STSO1020 steps (101-120)179 (-4.8%)273 🔴 (-1.1%)521 🔴 (+61%) 🔻572 🔴 (+71%) 🔻19
STSO1020 steps (1001-1020)530 (+17%) 🔻616 🔴 (+8.5%)647 🔴 (-6.5%)762 🔴 (-8.7%)19
WO1020 steps419068 (+6.7%)419068 (+6.7%)419068 (+6.7%)419068 (+6.7%)1
SLstream latency120 (+50%) 🔻174 🔴 (+38%) 🔻185 🔴 (+27%) 🔻224 🔴 (-47%) 💚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 23, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

📦 Local Production (1 failed)

nextjs-turbopack-stable (1 failed):

  • webhookWorkflow | wrun_41KY98HTJK0GKYSP0S315E2QCH

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
❌ 📦 Local Production162012271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7518111328651
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-stable15400
✅ 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 threadpackages/core/src/serialization.ts
Review (bot): a write landing between the dispatch loop's empty-buffer
exit and the reaction clearing the in-flight marker armed no timer
(scheduleGroupCommit saw the marker set) and was never dispatched on an
open stream — only a later write/close/drain would pick it up. The
settle reaction now re-dispatches when the buffer is non-empty, treating
the chunk as an in-request arrival; drain waiters settle with the new
chain. Regression test aims a write at the settle gap and asserts both
chunks flush without a close.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t handling
Review note: the abort-path drain is deliberately un-timeboxed (a bound
would drop acked chunks); its worst case is owned by the World
transport's finite timeout/retry budget, and a teardown-driven drain
into an already-terminal run rejects into the existing catch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
karthikscale3and others added 2 commits July 23, 2026 21:44
The native-pipeTo batching test flaked on a slow CI runner: a fixed
25ms wait raced the 10ms commit window plus scheduler jitter. All
'dispatch has happened' assertions now poll the expectation (bounded);
intentional negatives keep their fixed windows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actionsgithub-actionsBot mentioned this pull request Jul 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for b610c46 (AI decision).

This rework builds directly on main-only streaming infrastructure: stable's @workflow/core has no world.streams.writeMulti API, no STREAM_WRITE_BATCH_SYMBOL/coalescing pipe, and none of the WORKFLOW_STREAM_MAX_* tuning knobs or flush telemetry this patch refactors (verified via git grep on origin/stable). The docs change also targets docs/content/docs/v5/configuration/runtime-tuning.mdx, which does not exist on stable. The diff would not apply meaningfully to stable's much older per-write streaming implementation.

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

b610c46f8143afe3c862fb9957f5b4b48e754b42

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@karthikscale3@VaguelySerious
, '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); } })(); })(); perf(core): path-independent stream write batching (group commit in the server writable) by karthikscale3 · Pull Request #3078 · vercel/workflow · GitHub
Skip to content

perf(core): path-independent stream write batching (group commit in the server writable) - #3078

Merged
karthikscale3 merged 6 commits into
mainfrom
kk/writable-batching
Jul 24, 2026
Merged

perf(core): path-independent stream write batching (group commit in the server writable)#3078
karthikscale3 merged 6 commits into
mainfrom
kk/writable-batching

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Problem

Batching was implemented in flushablePipe's coalescing loop, so it depended on which piping path ran. getWritable() batched; a raw ReadableStream crossing a workflow/step boundary uses native pipeTo(), which doesn't pull chunk N+1 until write(chunk N) resolves — and write() resolved only after the flush timer and the server round trip. The buffer never held more than one chunk: one request per token.

Fix

Batching moves into WorkflowServerWritableStream, identical on every path:

  • write() resolves when the chunk enters a bounded client buffer (WORKFLOW_STREAM_MAX_INFLIGHT_CHUNKS counts buffered and in-request chunks; new byte bound WORKFLOW_STREAM_MAX_BUFFERED_BYTES, default 8 MiB, documented).
  • The flush interval is a real group-commit window; chunks arriving while a request is in flight form the next writeMulti group. One request in flight preserves order; 1,000-chunk / 1 MiB per-request limits preserved.
  • Durability is an explicit barrier: close() drains first; flushablePipe (now a plain pump: lock-release + durability tracking only) adopts the barrier so step completion still implies durability; abort()drains the accepted prefix without closing (a producer error after acked writes can't lose data); a failed pipe drains before settling. Dispatch failures are sticky and surface at the next write/close/drain.

Trade-off

An open getWritable() stream's first chunk now waits up to the group-commit interval (10ms default) instead of dispatching eagerly — watch TTFC / buffer_dwell_ms after release.

Validation

1,572 core unit tests pass, including new regressions for: native-pipeTo batching, awaited per-chunk loops coalescing into one writeMulti, wire-cap splits, in-flight-inclusive backpressure on both bounds, source-error prefix delivery through abort, failed-pipe drain-before-reject, and turbo run-ready barrier gating. E2E tier requires a deployment and was not run here.

Addressed some of the streaming perf concerns for use cases mentioned in #1930

…eam (group commit)
Batching previously lived in flushablePipe's coalescing loop, so it only
engaged on paths that used flushablePipe (getWritable). A raw
ReadableStream crossing a workflow/step boundary is piped with native
pipeTo(), which does not pull chunk N+1 until write(chunk N) resolves —
and write() resolved only after the flush timer AND the server round
trip, so the buffer never held more than one chunk and every token
became its own server request.
The sink now group-commits:
- write() resolves when the chunk enters a bounded client buffer; the
bound counts buffered AND in-request chunks
(WORKFLOW_STREAM_MAX_INFLIGHT_CHUNKS, preserving its documented
meaning) plus a byte bound (WORKFLOW_STREAM_MAX_BUFFERED_BYTES, new,
default 8 MiB, documented in runtime-tuning). A full buffer applies
backpressure until a group lands durably.
- The flush interval is a real group-commit window; chunks arriving
while a request is in flight accumulate and form the next writeMulti
group. One request in flight at a time preserves chunk order.
- Per-request wire limits (1,000 chunks / 1 MiB) split groups exactly
as the coalescing pipe did; an oversized single chunk goes alone.
- Durability moved to an explicit barrier (STREAM_DRAIN_SYMBOL):
close() drains before closing; flushablePipe adopts the barrier so
lock-release completion (step completion) still means 'everything
written is durable'; abort() DRAINS the accepted prefix (never
closing) so a producer error after acked writes cannot lose data —
native pipeTo aborts the sink on source failure; and a failed pipe
drains before settling so a step failure is not persisted ahead of
the emitted prefix. A dispatch failure retains the group, poisons
the sink, and surfaces at the next write/close/drain.
flushablePipe is now a plain per-chunk pump responsible only for
lock-release completion and durability tracking; its coalescing
machinery and STREAM_WRITE_BATCH_SYMBOL are removed.
Covered: native-pipeTo batching (the regression), awaited per-chunk
loops coalescing into one writeMulti, in-flight accumulation, wire-cap
splits (count/byte/oversized), in-flight-inclusive backpressure for
both bounds, sequential fallback without writeMulti, source-error
prefix delivery through abort, failed-pipe drain-before-reject,
early-ack sticky errors, turbo run-ready barrier gating (incl. dwell
telemetry), drain-barrier adoption/rejection, and group-level flush
spans. 1,572 core unit tests pass; e2e tier requires a deployment and
was not run here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3 requested review from a team and ijjk as code ownersJuly 23, 2026 23:52
@changeset-bot

changeset-botBot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a57ba7c

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

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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 23, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit a57ba7c · Fri, 24 Jul 2026 05:30:38 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep291 (-69%) 💚1419 🔴 (+25%) 🔻1447 🔴 (+24%) 🔻1770 🔴 (+31%) 🔻30
TTFSstream281 (-72%) 💚1406 🔴 (+22%) 🔻1442 🔴 (+23%) 🔻1613 🔴 (+31%) 🔻30
TTFShook + stream497 (+18%) 🔻1645 🔴 (+14%)1662 🔴 (+14%)1843 🔴 (+16%) 🔻30
STSO1020 steps (1-20)166 (-10%)267 🔴 (-27%) 💚319 🔴 (-33%) 💚331 🔴 (-52%) 💚19
STSO1020 steps (101-120)200 (-18%) 💚279 🔴 (-30%) 💚306 🔴 (-61%) 💚330 🔴 (-66%) 💚19
STSO1020 steps (1001-1020)490 (-0.8%)568 🔴 (-2.7%)714 🔴 (+14%)881 🔴 (+27%) 🔻19
WO1020 steps407715 (-8.3%)407715 (-8.3%)407715 (-8.3%)407715 (-8.3%)1
SLstream latency98 (-2.0%)178 🔴 (+23%) 🔻230 🔴 (+43%) 🔻253 🔴 (+29%) 🔻30
SOstream overhead (text)124 (-13%)222 (-18%) 💚403 (+24%) 🔻530 (+5.8%)30
SOstream overhead (structured)115 (-9.4%)215 (-15%) 💚263 (-13%)820 (+90%) 🔻30
📜 Previous results (4)

5f57e09

Fri, 24 Jul 2026 05:05:30 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1201 (+29%) 🔻1272 🔴 (+12%)1297 🔴 (+11%)1590 🔴 (+17%) 🔻30
TTFSstream1207 (+18%) 🔻1265 🔴 (+9.7%)1278 🔴 (+9.4%)1337 🔴 (+8.6%)30
TTFShook + stream527 (+25%) 🔻1540 🔴 (+7.2%)1687 🔴 (+16%) 🔻1837 🔴 (+16%) 🔻30
STSO1020 steps (1-20)167 (-9.7%)270 🔴 (-26%) 💚318 🔴 (-33%) 💚494 🔴 (-28%) 💚19
STSO1020 steps (101-120)183 (-25%) 💚239 🔴 (-40%) 💚316 🔴 (-60%) 💚644 🔴 (-33%) 💚19
STSO1020 steps (1001-1020)451 (-8.7%)535 🔴 (-8.4%)821 🔴 (+31%) 🔻3616 🔴 (+423%) 🔻19
WO1020 steps374593 (-16%) 💚374593 (-16%) 💚374593 (-16%) 💚374593 (-16%) 💚1
SLstream latency84 (-16%) 💚125 🔴 (-14%)161 🔴 (±0%)218 🔴 (+11%)30
SOstream overhead (text)98 (-31%) 💚141 (-48%) 💚182 (-44%) 💚277 (-45%) 💚30
SOstream overhead (structured)101 (-20%) 💚148 (-42%) 💚174 (-42%) 💚210 (-51%) 💚30

b5718b0

Fri, 24 Jul 2026 01:05:26 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1286 (+34%) 🔻1373 🔴 (+28%) 🔻1393 🔴 (+23%) 🔻1449 🔴 (+1.7%)30
TTFSstream1273 (+32%) 🔻1360 🔴 (+30%) 🔻1373 🔴 (+29%) 🔻1544 🔴 (+37%) 🔻30
TTFShook + stream1456 (+20%) 🔻1583 🔴 (+22%) 🔻1621 🔴 (+20%) 🔻1683 🔴 (+19%) 🔻30
STSO1020 steps (1-20)162 (-6.9%)256 🔴 (+3.6%)305 🔴 (+9.3%)380 🔴 (+10%)19
STSO1020 steps (101-120)188 (-0.5%)256 🔴 (-7.2%)318 🔴 (-3.6%)320 🔴 (-15%) 💚19
STSO1020 steps (1001-1020)500 (+9.4%)583 🔴 (+13%)658 🔴 (+24%) 🔻787 🔴 (+27%) 🔻19
WO1020 steps395092 (+2.2%)395092 (+2.2%)395092 (+2.2%)395092 (+2.2%)1
SLstream latency86 (+7.5%)148 🔴 (+12%)169 🔴 (-5.6%)371 🔴 (-1.1%)30
SOstream overhead111 (+2.8%)165 (-13%)207 (-5.0%)320 (+25%) 🔻30

7e5ad94

Fri, 24 Jul 2026 00:29:08 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1249 (+30%) 🔻1330 🔴 (+24%) 🔻1343 🔴 (+19%) 🔻1380 🔴 (-3.2%)30
TTFSstream333 (-66%) 💚1327 🔴 (+27%) 🔻1345 🔴 (+26%) 🔻1437 🔴 (+27%) 🔻30
TTFShook + stream1454 (+20%) 🔻1691 🔴 (+30%) 🔻1777 🔴 (+31%) 🔻2084 🔴 (+47%) 🔻30
STSO1020 steps (1-20)183 (+5.2%)278 🔴 (+13%)318 🔴 (+14%)387 🔴 (+13%)19
STSO1020 steps (101-120)188 (-0.5%)239 🔴 (-13%)257 🔴 (-22%) 💚279 🔴 (-26%) 💚19
STSO1020 steps (1001-1020)480 (+5.0%)621 🔴 (+21%) 🔻745 🔴 (+40%) 🔻776 🔴 (+25%) 🔻19
WO1020 steps395743 (+2.4%)395743 (+2.4%)395743 (+2.4%)395743 (+2.4%)1
SLstream latency117 (+46%) 🔻175 🔴 (+33%) 🔻204 🔴 (+14%)419 🔴 (+12%)30

9792b58

Fri, 24 Jul 2026 00:09:07 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1281 (+267%) 🔻1362 🔴 (+34%) 🔻1381 🔴 (+33%) 🔻1479 🔴 (+6.5%)30
TTFSstream1265 (+38%) 🔻1321 🔴 (+26%) 🔻1369 🔴 (+29%) 🔻1447 🔴 (±0%)30
TTFShook + stream1597 (+41%) 🔻1677 🔴 (+26%) 🔻1816 🔴 (+28%) 🔻1873 🔴 (+24%) 🔻30
STSO1020 steps (1-20)175 (-0.6%)260 🔴 (-6.1%)313 🔴 (-30%) 💚341 🔴 (-24%) 💚19
STSO1020 steps (101-120)179 (-4.8%)273 🔴 (-1.1%)521 🔴 (+61%) 🔻572 🔴 (+71%) 🔻19
STSO1020 steps (1001-1020)530 (+17%) 🔻616 🔴 (+8.5%)647 🔴 (-6.5%)762 🔴 (-8.7%)19
WO1020 steps419068 (+6.7%)419068 (+6.7%)419068 (+6.7%)419068 (+6.7%)1
SLstream latency120 (+50%) 🔻174 🔴 (+38%) 🔻185 🔴 (+27%) 🔻224 🔴 (-47%) 💚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 23, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

📦 Local Production (1 failed)

nextjs-turbopack-stable (1 failed):

  • webhookWorkflow | wrun_41KY98HTJK0GKYSP0S315E2QCH

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
❌ 📦 Local Production162012271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7518111328651
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-stable15400
✅ 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 threadpackages/core/src/serialization.ts
Review (bot): a write landing between the dispatch loop's empty-buffer
exit and the reaction clearing the in-flight marker armed no timer
(scheduleGroupCommit saw the marker set) and was never dispatched on an
open stream — only a later write/close/drain would pick it up. The
settle reaction now re-dispatches when the buffer is non-empty, treating
the chunk as an in-request arrival; drain waiters settle with the new
chain. Regression test aims a write at the settle gap and asserts both
chunks flush without a close.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t handling
Review note: the abort-path drain is deliberately un-timeboxed (a bound
would drop acked chunks); its worst case is owned by the World
transport's finite timeout/retry budget, and a teardown-driven drain
into an already-terminal run rejects into the existing catch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
karthikscale3and others added 2 commits July 23, 2026 21:44
The native-pipeTo batching test flaked on a slow CI runner: a fixed
25ms wait raced the 10ms commit window plus scheduler jitter. All
'dispatch has happened' assertions now poll the expectation (bounded);
intentional negatives keep their fixed windows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actionsgithub-actionsBot mentioned this pull request Jul 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for b610c46 (AI decision).

This rework builds directly on main-only streaming infrastructure: stable's @workflow/core has no world.streams.writeMulti API, no STREAM_WRITE_BATCH_SYMBOL/coalescing pipe, and none of the WORKFLOW_STREAM_MAX_* tuning knobs or flush telemetry this patch refactors (verified via git grep on origin/stable). The docs change also targets docs/content/docs/v5/configuration/runtime-tuning.mdx, which does not exist on stable. The diff would not apply meaningfully to stable's much older per-write streaming implementation.

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

b610c46f8143afe3c862fb9957f5b4b48e754b42

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@karthikscale3@VaguelySerious