Skip to content

QuickJS engine: threshold-based VM-memory snapshotting (WORKFLOW_SNAPSHOT_THRESHOLD) - #3053

Merged
TooTallNate merged 0 commit into
quickjs-vm-snapshotsfrom
quickjs-vm-threshold-snapshots
Jul 31, 2026
Merged

QuickJS engine: threshold-based VM-memory snapshotting (WORKFLOW_SNAPSHOT_THRESHOLD)#3053
TooTallNate merged 0 commit into
quickjs-vm-snapshotsfrom
quickjs-vm-threshold-snapshots

Conversation

@TooTallNate

Copy link
Copy Markdown
Member

Stacked PR — based on #3050 (quickjs-vm-snapshots) ← #3049#3048. Review only the top commit here until the bases merge.

Summary

PR 4 of the QuickJS VM roadmap: threshold-based VM-memory snapshotting — the middle ground that motivated reviving this effort (see #1298 / #1300 discussion). Instead of snapshotting at every suspension (the original branch's model, which cost ~25% on e2e wall clock), snapshots are taken only once WORKFLOW_SNAPSHOT_THRESHOLD events have been processed since the last one:

  • Short-lived runs never snapshot — they keep PR 1/2's pure replay behavior with zero snapshot overhead.
  • Long/forever runs stop scaling their resume cost with event-log length — a resumption restores the VM heap and replays only the delta events since the snapshot's cursor.

How it works

  • WORKFLOW_SNAPSHOT_THRESHOLD env var (default 0 = disabled) or per-run executionContext.snapshotThreshold, stamped at start() for run affinity like WORKFLOW_VM.
  • Save (suspension exit, threshold met): capture live VM memory (session.snapshot()) → compress (zstd/gzip via the shared serialization pipeline; QuickJS heaps compress ~4×, measured 16.5 MB → 3.9 MB) → encrypt with the run's key when configured → world.snapshots.save with the events cursor at the VM's feed frontier.
  • Restore (subsequent invocation): world.snapshots.load → decrypt → decompress → QuickJS.restore over the cached WASM module, re-register host callbacks, fetch events from the snapshot's cursor and feed only the delta. Runs seamlessly through PR 2's inline continuation loop.
  • Delete on run completion/failure.
  • Fallback is always full replay: missing snapshot, load error, corrupt bytes, or restore failure logs a warning and boots fresh against the full event log — the log remains the source of truth; snapshots are strictly an optimization.

Determinism model (restore + partial replay)

The threshold model's new mechanism vs. the original branch: a resumption may restore a snapshot older than the log head (suspensions since the snapshot weren't persisted) and must deterministically re-derive everything in between:

  • The PRNG seed mixes in the restored snapshot's eventsCursor: the heap already consumed pre-snapshot draws, so re-seeding from the base would replay the first-N draws and collide with recorded correlationIds. The cursor is identical for every resume from the same snapshot (concurrent resumes still collide ids for the world's dedup) and advances only when a newer snapshot is taken.
  • Feeding already-consumed events is harmless by construction (consumed resolvers are gone; hook deliveries are deduped by eventId in the VM heap, which travels with the snapshot), so imprecise cursors only cost redundant scanning.
  • Covered by dedicated unit tests, including restore-from-older-snapshot with multi-suspension partial replay and identical post-restore correlationIds across concurrent resumes.

Validation

  • 135/135 e2e on nextjs-turbopack with WORKFLOW_SNAPSHOT_THRESHOLD=1 (maximum churn: snapshot on every qualifying suspension), wall clock within ~10% of the node baseline
  • Verified via debug diagnostics: restored: true resumptions, save/restore/delete lifecycle, and threshold gating (threshold=100 short run ⇒ zero snapshots, pure replay)
  • Full core unit suite green (1,573 tests); new tests for the config knobs and the snapshot/restore/partial-replay determinism
  • CI: new quickjs-snapshot matrix leg (nextjs-turbopack, threshold=1) across local dev/prod/postgres e2e jobs

Notes / follow-ups

  • Version skew: snapshot bytes are tied to the quickjs-wasi build that produced them. Per the deployment contract (runs continue on the version they started on — free on Vercel), this is a non-issue in production; environments without skew protection are covered by the restore-failure fallback to full replay.
  • An ID-divergence window exists when concurrent invocations resume from different snapshot generations; the world's per-(run, correlation) uniqueness rejects duplicates and the log-consistent invocation drives progress, with full-replay convergence as the backstop. Noted in code comments.
  • Docs: WORKFLOW_SNAPSHOT_THRESHOLD section added to v5 Runtime Tuning.

@changeset-bot

changeset-botBot commented Jul 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f1b1e10

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

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

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

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

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewJul 31, 2026 1:13am
example-nextjs-workflow-webpackReadyReadyPreviewJul 31, 2026 1:13am
example-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-astro-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-express-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-fastify-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-hono-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-nestjs-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-nitro-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-nuxt-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-sveltekit-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-tanstack-start-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-vite-workflowReadyReadyPreviewJul 31, 2026 1:13am
workflow-docsBuildingBuildingPreview, v0Jul 31, 2026 1:13am
workflow-swc-playgroundReadyReadyPreviewJul 31, 2026 1:13am
workflow-tarballsReadyReadyPreviewJul 31, 2026 1:13am
workflow-webReadyReadyPreviewJul 31, 2026 1:13am

@github-actions

github-actionsBot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development312604163542
✅ 📦 Local Production339604543850
✅ 🐘 Local Postgres339604543850
✅ 🪟 Windows30800308
✅ 📋 Other178803682156
✅ vercel-multi-region270027
Total134960193115427
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node126028
✅ example-node126028
✅ express-node126028
✅ fastify-node126028
✅ hono-node126028
✅ nextjs-turbopack-node15103
✅ nextjs-webpack-node15103
✅ nitro-node126028
✅ nuxt-node126028
✅ sveltekit-node14509
✅ vite-node126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node128026
✅ astro-stable-quickjs128026
✅ express-stable-node128026
✅ express-stable-quickjs128026
✅ fastify-stable-node128026
✅ fastify-stable-quickjs128026
✅ hono-stable-node128026
✅ hono-stable-quickjs128026
✅ nextjs-turbopack-canary-node135019
✅ nextjs-turbopack-canary-quickjs135019
✅ nextjs-turbopack-quickjs-snapshot15400
✅ nextjs-turbopack-stable-node15400
✅ nextjs-turbopack-stable-quickjs15400
✅ nextjs-webpack-stable-node15400
✅ nextjs-webpack-stable-quickjs15400
✅ nitro-stable-node128026
✅ nitro-stable-quickjs128026
✅ nuxt-stable-node128026
✅ nuxt-stable-quickjs128026
✅ sveltekit-stable-node14707
✅ sveltekit-stable-quickjs14707
✅ vite-stable-node128026
✅ vite-stable-quickjs128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node128026
✅ astro-stable-quickjs128026
✅ express-stable-node128026
✅ express-stable-quickjs128026
✅ fastify-stable-node128026
✅ fastify-stable-quickjs128026
✅ hono-stable-node128026
✅ hono-stable-quickjs128026
✅ nextjs-turbopack-canary-node135019
✅ nextjs-turbopack-canary-quickjs135019
✅ nextjs-turbopack-quickjs-snapshot15400
✅ nextjs-turbopack-stable-node15400
✅ nextjs-turbopack-stable-quickjs15400
✅ nextjs-webpack-canary-node135019
✅ nextjs-webpack-canary-quickjs135019
✅ nextjs-webpack-stable-node15400
✅ nextjs-webpack-stable-quickjs15400
✅ nitro-stable-node128026
✅ nitro-stable-quickjs128026
✅ nuxt-stable-node128026
✅ nuxt-stable-quickjs128026
✅ sveltekit-stable-node14707
✅ sveltekit-stable-quickjs14707
✅ vite-stable-node128026
✅ vite-stable-quickjs128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node128026
✅ astro-stable-quickjs128026
✅ express-stable-node128026
✅ express-stable-quickjs128026
✅ fastify-stable-node128026
✅ fastify-stable-quickjs128026
✅ hono-stable-node128026
✅ hono-stable-quickjs128026
✅ nextjs-turbopack-canary-node135019
✅ nextjs-turbopack-canary-quickjs135019
✅ nextjs-turbopack-quickjs-snapshot15400
✅ nextjs-turbopack-stable-node15400
✅ nextjs-turbopack-stable-quickjs15400
✅ nextjs-webpack-canary-node135019
✅ nextjs-webpack-canary-quickjs135019
✅ nextjs-webpack-stable-node15400
✅ nextjs-webpack-stable-quickjs15400
✅ nitro-stable-node128026
✅ nitro-stable-quickjs128026
✅ nuxt-stable-node128026
✅ nuxt-stable-quickjs128026
✅ sveltekit-stable-node14707
✅ sveltekit-stable-quickjs14707
✅ vite-stable-node128026
✅ vite-stable-quickjs128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15400
✅ nextjs-turbopack-quickjs15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable-node128026
✅ e2e-local-dev-nest-stable-quickjs128026
✅ e2e-local-dev-tanstack-start-node128026
✅ e2e-local-dev-tanstack-start-quickjs128026
✅ e2e-local-postgres-nest-stable-node128026
✅ e2e-local-postgres-nest-stable-quickjs128026
✅ e2e-local-postgres-tanstack-start-node128026
✅ e2e-local-postgres-tanstack-start-quickjs128026
✅ e2e-local-prod-nest-stable-node128026
✅ e2e-local-prod-nest-stable-quickjs128026
✅ e2e-local-prod-tanstack-start-node128026
✅ e2e-local-prod-tanstack-start-quickjs128026
✅ e2e-vercel-prod-nest-node126028
✅ e2e-vercel-prod-tanstack-start-node126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@TooTallNate
TooTallNateforce-pushed the quickjs-vm-snapshots branch from 1e0b6e6 to f43ee63CompareJuly 31, 2026 01:07
@TooTallNate
TooTallNateforce-pushed the quickjs-vm-threshold-snapshots branch from 64f40c3 to f1b1e10CompareJuly 31, 2026 01:09
@TooTallNate
TooTallNate merged commit f64932e into quickjs-vm-snapshotsJul 31, 2026
@TooTallNate
TooTallNateforce-pushed the quickjs-vm-snapshots branch from f43ee63 to f64932eCompareJuly 31, 2026 02:15
@TooTallNate
TooTallNate deleted the quickjs-vm-threshold-snapshots branch July 31, 2026 02:15
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.

1 participant

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

QuickJS engine: threshold-based VM-memory snapshotting (WORKFLOW_SNAPSHOT_THRESHOLD) - #3053

Merged
TooTallNate merged 0 commit into
quickjs-vm-snapshotsfrom
quickjs-vm-threshold-snapshots
Jul 31, 2026
Merged

QuickJS engine: threshold-based VM-memory snapshotting (WORKFLOW_SNAPSHOT_THRESHOLD)#3053
TooTallNate merged 0 commit into
quickjs-vm-snapshotsfrom
quickjs-vm-threshold-snapshots

Conversation

@TooTallNate

Copy link
Copy Markdown
Member

Stacked PR — based on #3050 (quickjs-vm-snapshots) ← #3049#3048. Review only the top commit here until the bases merge.

Summary

PR 4 of the QuickJS VM roadmap: threshold-based VM-memory snapshotting — the middle ground that motivated reviving this effort (see #1298 / #1300 discussion). Instead of snapshotting at every suspension (the original branch's model, which cost ~25% on e2e wall clock), snapshots are taken only once WORKFLOW_SNAPSHOT_THRESHOLD events have been processed since the last one:

  • Short-lived runs never snapshot — they keep PR 1/2's pure replay behavior with zero snapshot overhead.
  • Long/forever runs stop scaling their resume cost with event-log length — a resumption restores the VM heap and replays only the delta events since the snapshot's cursor.

How it works

  • WORKFLOW_SNAPSHOT_THRESHOLD env var (default 0 = disabled) or per-run executionContext.snapshotThreshold, stamped at start() for run affinity like WORKFLOW_VM.
  • Save (suspension exit, threshold met): capture live VM memory (session.snapshot()) → compress (zstd/gzip via the shared serialization pipeline; QuickJS heaps compress ~4×, measured 16.5 MB → 3.9 MB) → encrypt with the run's key when configured → world.snapshots.save with the events cursor at the VM's feed frontier.
  • Restore (subsequent invocation): world.snapshots.load → decrypt → decompress → QuickJS.restore over the cached WASM module, re-register host callbacks, fetch events from the snapshot's cursor and feed only the delta. Runs seamlessly through PR 2's inline continuation loop.
  • Delete on run completion/failure.
  • Fallback is always full replay: missing snapshot, load error, corrupt bytes, or restore failure logs a warning and boots fresh against the full event log — the log remains the source of truth; snapshots are strictly an optimization.

Determinism model (restore + partial replay)

The threshold model's new mechanism vs. the original branch: a resumption may restore a snapshot older than the log head (suspensions since the snapshot weren't persisted) and must deterministically re-derive everything in between:

  • The PRNG seed mixes in the restored snapshot's eventsCursor: the heap already consumed pre-snapshot draws, so re-seeding from the base would replay the first-N draws and collide with recorded correlationIds. The cursor is identical for every resume from the same snapshot (concurrent resumes still collide ids for the world's dedup) and advances only when a newer snapshot is taken.
  • Feeding already-consumed events is harmless by construction (consumed resolvers are gone; hook deliveries are deduped by eventId in the VM heap, which travels with the snapshot), so imprecise cursors only cost redundant scanning.
  • Covered by dedicated unit tests, including restore-from-older-snapshot with multi-suspension partial replay and identical post-restore correlationIds across concurrent resumes.

Validation

  • 135/135 e2e on nextjs-turbopack with WORKFLOW_SNAPSHOT_THRESHOLD=1 (maximum churn: snapshot on every qualifying suspension), wall clock within ~10% of the node baseline
  • Verified via debug diagnostics: restored: true resumptions, save/restore/delete lifecycle, and threshold gating (threshold=100 short run ⇒ zero snapshots, pure replay)
  • Full core unit suite green (1,573 tests); new tests for the config knobs and the snapshot/restore/partial-replay determinism
  • CI: new quickjs-snapshot matrix leg (nextjs-turbopack, threshold=1) across local dev/prod/postgres e2e jobs

Notes / follow-ups

  • Version skew: snapshot bytes are tied to the quickjs-wasi build that produced them. Per the deployment contract (runs continue on the version they started on — free on Vercel), this is a non-issue in production; environments without skew protection are covered by the restore-failure fallback to full replay.
  • An ID-divergence window exists when concurrent invocations resume from different snapshot generations; the world's per-(run, correlation) uniqueness rejects duplicates and the log-consistent invocation drives progress, with full-replay convergence as the backstop. Noted in code comments.
  • Docs: WORKFLOW_SNAPSHOT_THRESHOLD section added to v5 Runtime Tuning.

@changeset-bot

changeset-botBot commented Jul 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f1b1e10

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

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

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

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

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewJul 31, 2026 1:13am
example-nextjs-workflow-webpackReadyReadyPreviewJul 31, 2026 1:13am
example-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-astro-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-express-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-fastify-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-hono-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-nestjs-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-nitro-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-nuxt-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-sveltekit-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-tanstack-start-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-vite-workflowReadyReadyPreviewJul 31, 2026 1:13am
workflow-docsBuildingBuildingPreview, v0Jul 31, 2026 1:13am
workflow-swc-playgroundReadyReadyPreviewJul 31, 2026 1:13am
workflow-tarballsReadyReadyPreviewJul 31, 2026 1:13am
workflow-webReadyReadyPreviewJul 31, 2026 1:13am

@github-actions

github-actionsBot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development312604163542
✅ 📦 Local Production339604543850
✅ 🐘 Local Postgres339604543850
✅ 🪟 Windows30800308
✅ 📋 Other178803682156
✅ vercel-multi-region270027
Total134960193115427
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node126028
✅ example-node126028
✅ express-node126028
✅ fastify-node126028
✅ hono-node126028
✅ nextjs-turbopack-node15103
✅ nextjs-webpack-node15103
✅ nitro-node126028
✅ nuxt-node126028
✅ sveltekit-node14509
✅ vite-node126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node128026
✅ astro-stable-quickjs128026
✅ express-stable-node128026
✅ express-stable-quickjs128026
✅ fastify-stable-node128026
✅ fastify-stable-quickjs128026
✅ hono-stable-node128026
✅ hono-stable-quickjs128026
✅ nextjs-turbopack-canary-node135019
✅ nextjs-turbopack-canary-quickjs135019
✅ nextjs-turbopack-quickjs-snapshot15400
✅ nextjs-turbopack-stable-node15400
✅ nextjs-turbopack-stable-quickjs15400
✅ nextjs-webpack-stable-node15400
✅ nextjs-webpack-stable-quickjs15400
✅ nitro-stable-node128026
✅ nitro-stable-quickjs128026
✅ nuxt-stable-node128026
✅ nuxt-stable-quickjs128026
✅ sveltekit-stable-node14707
✅ sveltekit-stable-quickjs14707
✅ vite-stable-node128026
✅ vite-stable-quickjs128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node128026
✅ astro-stable-quickjs128026
✅ express-stable-node128026
✅ express-stable-quickjs128026
✅ fastify-stable-node128026
✅ fastify-stable-quickjs128026
✅ hono-stable-node128026
✅ hono-stable-quickjs128026
✅ nextjs-turbopack-canary-node135019
✅ nextjs-turbopack-canary-quickjs135019
✅ nextjs-turbopack-quickjs-snapshot15400
✅ nextjs-turbopack-stable-node15400
✅ nextjs-turbopack-stable-quickjs15400
✅ nextjs-webpack-canary-node135019
✅ nextjs-webpack-canary-quickjs135019
✅ nextjs-webpack-stable-node15400
✅ nextjs-webpack-stable-quickjs15400
✅ nitro-stable-node128026
✅ nitro-stable-quickjs128026
✅ nuxt-stable-node128026
✅ nuxt-stable-quickjs128026
✅ sveltekit-stable-node14707
✅ sveltekit-stable-quickjs14707
✅ vite-stable-node128026
✅ vite-stable-quickjs128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node128026
✅ astro-stable-quickjs128026
✅ express-stable-node128026
✅ express-stable-quickjs128026
✅ fastify-stable-node128026
✅ fastify-stable-quickjs128026
✅ hono-stable-node128026
✅ hono-stable-quickjs128026
✅ nextjs-turbopack-canary-node135019
✅ nextjs-turbopack-canary-quickjs135019
✅ nextjs-turbopack-quickjs-snapshot15400
✅ nextjs-turbopack-stable-node15400
✅ nextjs-turbopack-stable-quickjs15400
✅ nextjs-webpack-canary-node135019
✅ nextjs-webpack-canary-quickjs135019
✅ nextjs-webpack-stable-node15400
✅ nextjs-webpack-stable-quickjs15400
✅ nitro-stable-node128026
✅ nitro-stable-quickjs128026
✅ nuxt-stable-node128026
✅ nuxt-stable-quickjs128026
✅ sveltekit-stable-node14707
✅ sveltekit-stable-quickjs14707
✅ vite-stable-node128026
✅ vite-stable-quickjs128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15400
✅ nextjs-turbopack-quickjs15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable-node128026
✅ e2e-local-dev-nest-stable-quickjs128026
✅ e2e-local-dev-tanstack-start-node128026
✅ e2e-local-dev-tanstack-start-quickjs128026
✅ e2e-local-postgres-nest-stable-node128026
✅ e2e-local-postgres-nest-stable-quickjs128026
✅ e2e-local-postgres-tanstack-start-node128026
✅ e2e-local-postgres-tanstack-start-quickjs128026
✅ e2e-local-prod-nest-stable-node128026
✅ e2e-local-prod-nest-stable-quickjs128026
✅ e2e-local-prod-tanstack-start-node128026
✅ e2e-local-prod-tanstack-start-quickjs128026
✅ e2e-vercel-prod-nest-node126028
✅ e2e-vercel-prod-tanstack-start-node126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@TooTallNate
TooTallNateforce-pushed the quickjs-vm-snapshots branch from 1e0b6e6 to f43ee63CompareJuly 31, 2026 01:07
@TooTallNate
TooTallNateforce-pushed the quickjs-vm-threshold-snapshots branch from 64f40c3 to f1b1e10CompareJuly 31, 2026 01:09
@TooTallNate
TooTallNate merged commit f64932e into quickjs-vm-snapshotsJul 31, 2026
@TooTallNate
TooTallNateforce-pushed the quickjs-vm-snapshots branch from f43ee63 to f64932eCompareJuly 31, 2026 02:15
@TooTallNate
TooTallNate deleted the quickjs-vm-threshold-snapshots branch July 31, 2026 02:15
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.

1 participant

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

QuickJS engine: threshold-based VM-memory snapshotting (WORKFLOW_SNAPSHOT_THRESHOLD) - #3053

Merged
TooTallNate merged 0 commit into
quickjs-vm-snapshotsfrom
quickjs-vm-threshold-snapshots
Jul 31, 2026
Merged

QuickJS engine: threshold-based VM-memory snapshotting (WORKFLOW_SNAPSHOT_THRESHOLD)#3053
TooTallNate merged 0 commit into
quickjs-vm-snapshotsfrom
quickjs-vm-threshold-snapshots

Conversation

@TooTallNate

Copy link
Copy Markdown
Member

Stacked PR — based on #3050 (quickjs-vm-snapshots) ← #3049#3048. Review only the top commit here until the bases merge.

Summary

PR 4 of the QuickJS VM roadmap: threshold-based VM-memory snapshotting — the middle ground that motivated reviving this effort (see #1298 / #1300 discussion). Instead of snapshotting at every suspension (the original branch's model, which cost ~25% on e2e wall clock), snapshots are taken only once WORKFLOW_SNAPSHOT_THRESHOLD events have been processed since the last one:

  • Short-lived runs never snapshot — they keep PR 1/2's pure replay behavior with zero snapshot overhead.
  • Long/forever runs stop scaling their resume cost with event-log length — a resumption restores the VM heap and replays only the delta events since the snapshot's cursor.

How it works

  • WORKFLOW_SNAPSHOT_THRESHOLD env var (default 0 = disabled) or per-run executionContext.snapshotThreshold, stamped at start() for run affinity like WORKFLOW_VM.
  • Save (suspension exit, threshold met): capture live VM memory (session.snapshot()) → compress (zstd/gzip via the shared serialization pipeline; QuickJS heaps compress ~4×, measured 16.5 MB → 3.9 MB) → encrypt with the run's key when configured → world.snapshots.save with the events cursor at the VM's feed frontier.
  • Restore (subsequent invocation): world.snapshots.load → decrypt → decompress → QuickJS.restore over the cached WASM module, re-register host callbacks, fetch events from the snapshot's cursor and feed only the delta. Runs seamlessly through PR 2's inline continuation loop.
  • Delete on run completion/failure.
  • Fallback is always full replay: missing snapshot, load error, corrupt bytes, or restore failure logs a warning and boots fresh against the full event log — the log remains the source of truth; snapshots are strictly an optimization.

Determinism model (restore + partial replay)

The threshold model's new mechanism vs. the original branch: a resumption may restore a snapshot older than the log head (suspensions since the snapshot weren't persisted) and must deterministically re-derive everything in between:

  • The PRNG seed mixes in the restored snapshot's eventsCursor: the heap already consumed pre-snapshot draws, so re-seeding from the base would replay the first-N draws and collide with recorded correlationIds. The cursor is identical for every resume from the same snapshot (concurrent resumes still collide ids for the world's dedup) and advances only when a newer snapshot is taken.
  • Feeding already-consumed events is harmless by construction (consumed resolvers are gone; hook deliveries are deduped by eventId in the VM heap, which travels with the snapshot), so imprecise cursors only cost redundant scanning.
  • Covered by dedicated unit tests, including restore-from-older-snapshot with multi-suspension partial replay and identical post-restore correlationIds across concurrent resumes.

Validation

  • 135/135 e2e on nextjs-turbopack with WORKFLOW_SNAPSHOT_THRESHOLD=1 (maximum churn: snapshot on every qualifying suspension), wall clock within ~10% of the node baseline
  • Verified via debug diagnostics: restored: true resumptions, save/restore/delete lifecycle, and threshold gating (threshold=100 short run ⇒ zero snapshots, pure replay)
  • Full core unit suite green (1,573 tests); new tests for the config knobs and the snapshot/restore/partial-replay determinism
  • CI: new quickjs-snapshot matrix leg (nextjs-turbopack, threshold=1) across local dev/prod/postgres e2e jobs

Notes / follow-ups

  • Version skew: snapshot bytes are tied to the quickjs-wasi build that produced them. Per the deployment contract (runs continue on the version they started on — free on Vercel), this is a non-issue in production; environments without skew protection are covered by the restore-failure fallback to full replay.
  • An ID-divergence window exists when concurrent invocations resume from different snapshot generations; the world's per-(run, correlation) uniqueness rejects duplicates and the log-consistent invocation drives progress, with full-replay convergence as the backstop. Noted in code comments.
  • Docs: WORKFLOW_SNAPSHOT_THRESHOLD section added to v5 Runtime Tuning.

@changeset-bot

changeset-botBot commented Jul 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f1b1e10

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

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

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

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

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewJul 31, 2026 1:13am
example-nextjs-workflow-webpackReadyReadyPreviewJul 31, 2026 1:13am
example-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-astro-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-express-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-fastify-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-hono-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-nestjs-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-nitro-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-nuxt-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-sveltekit-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-tanstack-start-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-vite-workflowReadyReadyPreviewJul 31, 2026 1:13am
workflow-docsBuildingBuildingPreview, v0Jul 31, 2026 1:13am
workflow-swc-playgroundReadyReadyPreviewJul 31, 2026 1:13am
workflow-tarballsReadyReadyPreviewJul 31, 2026 1:13am
workflow-webReadyReadyPreviewJul 31, 2026 1:13am

@github-actions

github-actionsBot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development312604163542
✅ 📦 Local Production339604543850
✅ 🐘 Local Postgres339604543850
✅ 🪟 Windows30800308
✅ 📋 Other178803682156
✅ vercel-multi-region270027
Total134960193115427
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node126028
✅ example-node126028
✅ express-node126028
✅ fastify-node126028
✅ hono-node126028
✅ nextjs-turbopack-node15103
✅ nextjs-webpack-node15103
✅ nitro-node126028
✅ nuxt-node126028
✅ sveltekit-node14509
✅ vite-node126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node128026
✅ astro-stable-quickjs128026
✅ express-stable-node128026
✅ express-stable-quickjs128026
✅ fastify-stable-node128026
✅ fastify-stable-quickjs128026
✅ hono-stable-node128026
✅ hono-stable-quickjs128026
✅ nextjs-turbopack-canary-node135019
✅ nextjs-turbopack-canary-quickjs135019
✅ nextjs-turbopack-quickjs-snapshot15400
✅ nextjs-turbopack-stable-node15400
✅ nextjs-turbopack-stable-quickjs15400
✅ nextjs-webpack-stable-node15400
✅ nextjs-webpack-stable-quickjs15400
✅ nitro-stable-node128026
✅ nitro-stable-quickjs128026
✅ nuxt-stable-node128026
✅ nuxt-stable-quickjs128026
✅ sveltekit-stable-node14707
✅ sveltekit-stable-quickjs14707
✅ vite-stable-node128026
✅ vite-stable-quickjs128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node128026
✅ astro-stable-quickjs128026
✅ express-stable-node128026
✅ express-stable-quickjs128026
✅ fastify-stable-node128026
✅ fastify-stable-quickjs128026
✅ hono-stable-node128026
✅ hono-stable-quickjs128026
✅ nextjs-turbopack-canary-node135019
✅ nextjs-turbopack-canary-quickjs135019
✅ nextjs-turbopack-quickjs-snapshot15400
✅ nextjs-turbopack-stable-node15400
✅ nextjs-turbopack-stable-quickjs15400
✅ nextjs-webpack-canary-node135019
✅ nextjs-webpack-canary-quickjs135019
✅ nextjs-webpack-stable-node15400
✅ nextjs-webpack-stable-quickjs15400
✅ nitro-stable-node128026
✅ nitro-stable-quickjs128026
✅ nuxt-stable-node128026
✅ nuxt-stable-quickjs128026
✅ sveltekit-stable-node14707
✅ sveltekit-stable-quickjs14707
✅ vite-stable-node128026
✅ vite-stable-quickjs128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node128026
✅ astro-stable-quickjs128026
✅ express-stable-node128026
✅ express-stable-quickjs128026
✅ fastify-stable-node128026
✅ fastify-stable-quickjs128026
✅ hono-stable-node128026
✅ hono-stable-quickjs128026
✅ nextjs-turbopack-canary-node135019
✅ nextjs-turbopack-canary-quickjs135019
✅ nextjs-turbopack-quickjs-snapshot15400
✅ nextjs-turbopack-stable-node15400
✅ nextjs-turbopack-stable-quickjs15400
✅ nextjs-webpack-canary-node135019
✅ nextjs-webpack-canary-quickjs135019
✅ nextjs-webpack-stable-node15400
✅ nextjs-webpack-stable-quickjs15400
✅ nitro-stable-node128026
✅ nitro-stable-quickjs128026
✅ nuxt-stable-node128026
✅ nuxt-stable-quickjs128026
✅ sveltekit-stable-node14707
✅ sveltekit-stable-quickjs14707
✅ vite-stable-node128026
✅ vite-stable-quickjs128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15400
✅ nextjs-turbopack-quickjs15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable-node128026
✅ e2e-local-dev-nest-stable-quickjs128026
✅ e2e-local-dev-tanstack-start-node128026
✅ e2e-local-dev-tanstack-start-quickjs128026
✅ e2e-local-postgres-nest-stable-node128026
✅ e2e-local-postgres-nest-stable-quickjs128026
✅ e2e-local-postgres-tanstack-start-node128026
✅ e2e-local-postgres-tanstack-start-quickjs128026
✅ e2e-local-prod-nest-stable-node128026
✅ e2e-local-prod-nest-stable-quickjs128026
✅ e2e-local-prod-tanstack-start-node128026
✅ e2e-local-prod-tanstack-start-quickjs128026
✅ e2e-vercel-prod-nest-node126028
✅ e2e-vercel-prod-tanstack-start-node126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@TooTallNate
TooTallNateforce-pushed the quickjs-vm-snapshots branch from 1e0b6e6 to f43ee63CompareJuly 31, 2026 01:07
@TooTallNate
TooTallNateforce-pushed the quickjs-vm-threshold-snapshots branch from 64f40c3 to f1b1e10CompareJuly 31, 2026 01:09
@TooTallNate
TooTallNate merged commit f64932e into quickjs-vm-snapshotsJul 31, 2026
@TooTallNate
TooTallNateforce-pushed the quickjs-vm-snapshots branch from f43ee63 to f64932eCompareJuly 31, 2026 02:15
@TooTallNate
TooTallNate deleted the quickjs-vm-threshold-snapshots branch July 31, 2026 02:15
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.

1 participant

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

QuickJS engine: threshold-based VM-memory snapshotting (WORKFLOW_SNAPSHOT_THRESHOLD) - #3053

Merged
TooTallNate merged 0 commit into
quickjs-vm-snapshotsfrom
quickjs-vm-threshold-snapshots
Jul 31, 2026
Merged

QuickJS engine: threshold-based VM-memory snapshotting (WORKFLOW_SNAPSHOT_THRESHOLD)#3053
TooTallNate merged 0 commit into
quickjs-vm-snapshotsfrom
quickjs-vm-threshold-snapshots

Conversation

@TooTallNate

Copy link
Copy Markdown
Member

Stacked PR — based on #3050 (quickjs-vm-snapshots) ← #3049#3048. Review only the top commit here until the bases merge.

Summary

PR 4 of the QuickJS VM roadmap: threshold-based VM-memory snapshotting — the middle ground that motivated reviving this effort (see #1298 / #1300 discussion). Instead of snapshotting at every suspension (the original branch's model, which cost ~25% on e2e wall clock), snapshots are taken only once WORKFLOW_SNAPSHOT_THRESHOLD events have been processed since the last one:

  • Short-lived runs never snapshot — they keep PR 1/2's pure replay behavior with zero snapshot overhead.
  • Long/forever runs stop scaling their resume cost with event-log length — a resumption restores the VM heap and replays only the delta events since the snapshot's cursor.

How it works

  • WORKFLOW_SNAPSHOT_THRESHOLD env var (default 0 = disabled) or per-run executionContext.snapshotThreshold, stamped at start() for run affinity like WORKFLOW_VM.
  • Save (suspension exit, threshold met): capture live VM memory (session.snapshot()) → compress (zstd/gzip via the shared serialization pipeline; QuickJS heaps compress ~4×, measured 16.5 MB → 3.9 MB) → encrypt with the run's key when configured → world.snapshots.save with the events cursor at the VM's feed frontier.
  • Restore (subsequent invocation): world.snapshots.load → decrypt → decompress → QuickJS.restore over the cached WASM module, re-register host callbacks, fetch events from the snapshot's cursor and feed only the delta. Runs seamlessly through PR 2's inline continuation loop.
  • Delete on run completion/failure.
  • Fallback is always full replay: missing snapshot, load error, corrupt bytes, or restore failure logs a warning and boots fresh against the full event log — the log remains the source of truth; snapshots are strictly an optimization.

Determinism model (restore + partial replay)

The threshold model's new mechanism vs. the original branch: a resumption may restore a snapshot older than the log head (suspensions since the snapshot weren't persisted) and must deterministically re-derive everything in between:

  • The PRNG seed mixes in the restored snapshot's eventsCursor: the heap already consumed pre-snapshot draws, so re-seeding from the base would replay the first-N draws and collide with recorded correlationIds. The cursor is identical for every resume from the same snapshot (concurrent resumes still collide ids for the world's dedup) and advances only when a newer snapshot is taken.
  • Feeding already-consumed events is harmless by construction (consumed resolvers are gone; hook deliveries are deduped by eventId in the VM heap, which travels with the snapshot), so imprecise cursors only cost redundant scanning.
  • Covered by dedicated unit tests, including restore-from-older-snapshot with multi-suspension partial replay and identical post-restore correlationIds across concurrent resumes.

Validation

  • 135/135 e2e on nextjs-turbopack with WORKFLOW_SNAPSHOT_THRESHOLD=1 (maximum churn: snapshot on every qualifying suspension), wall clock within ~10% of the node baseline
  • Verified via debug diagnostics: restored: true resumptions, save/restore/delete lifecycle, and threshold gating (threshold=100 short run ⇒ zero snapshots, pure replay)
  • Full core unit suite green (1,573 tests); new tests for the config knobs and the snapshot/restore/partial-replay determinism
  • CI: new quickjs-snapshot matrix leg (nextjs-turbopack, threshold=1) across local dev/prod/postgres e2e jobs

Notes / follow-ups

  • Version skew: snapshot bytes are tied to the quickjs-wasi build that produced them. Per the deployment contract (runs continue on the version they started on — free on Vercel), this is a non-issue in production; environments without skew protection are covered by the restore-failure fallback to full replay.
  • An ID-divergence window exists when concurrent invocations resume from different snapshot generations; the world's per-(run, correlation) uniqueness rejects duplicates and the log-consistent invocation drives progress, with full-replay convergence as the backstop. Noted in code comments.
  • Docs: WORKFLOW_SNAPSHOT_THRESHOLD section added to v5 Runtime Tuning.

@changeset-bot

changeset-botBot commented Jul 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f1b1e10

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

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

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

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

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewJul 31, 2026 1:13am
example-nextjs-workflow-webpackReadyReadyPreviewJul 31, 2026 1:13am
example-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-astro-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-express-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-fastify-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-hono-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-nestjs-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-nitro-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-nuxt-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-sveltekit-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-tanstack-start-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-vite-workflowReadyReadyPreviewJul 31, 2026 1:13am
workflow-docsBuildingBuildingPreview, v0Jul 31, 2026 1:13am
workflow-swc-playgroundReadyReadyPreviewJul 31, 2026 1:13am
workflow-tarballsReadyReadyPreviewJul 31, 2026 1:13am
workflow-webReadyReadyPreviewJul 31, 2026 1:13am

@github-actions

github-actionsBot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development312604163542
✅ 📦 Local Production339604543850
✅ 🐘 Local Postgres339604543850
✅ 🪟 Windows30800308
✅ 📋 Other178803682156
✅ vercel-multi-region270027
Total134960193115427
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node126028
✅ example-node126028
✅ express-node126028
✅ fastify-node126028
✅ hono-node126028
✅ nextjs-turbopack-node15103
✅ nextjs-webpack-node15103
✅ nitro-node126028
✅ nuxt-node126028
✅ sveltekit-node14509
✅ vite-node126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node128026
✅ astro-stable-quickjs128026
✅ express-stable-node128026
✅ express-stable-quickjs128026
✅ fastify-stable-node128026
✅ fastify-stable-quickjs128026
✅ hono-stable-node128026
✅ hono-stable-quickjs128026
✅ nextjs-turbopack-canary-node135019
✅ nextjs-turbopack-canary-quickjs135019
✅ nextjs-turbopack-quickjs-snapshot15400
✅ nextjs-turbopack-stable-node15400
✅ nextjs-turbopack-stable-quickjs15400
✅ nextjs-webpack-stable-node15400
✅ nextjs-webpack-stable-quickjs15400
✅ nitro-stable-node128026
✅ nitro-stable-quickjs128026
✅ nuxt-stable-node128026
✅ nuxt-stable-quickjs128026
✅ sveltekit-stable-node14707
✅ sveltekit-stable-quickjs14707
✅ vite-stable-node128026
✅ vite-stable-quickjs128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node128026
✅ astro-stable-quickjs128026
✅ express-stable-node128026
✅ express-stable-quickjs128026
✅ fastify-stable-node128026
✅ fastify-stable-quickjs128026
✅ hono-stable-node128026
✅ hono-stable-quickjs128026
✅ nextjs-turbopack-canary-node135019
✅ nextjs-turbopack-canary-quickjs135019
✅ nextjs-turbopack-quickjs-snapshot15400
✅ nextjs-turbopack-stable-node15400
✅ nextjs-turbopack-stable-quickjs15400
✅ nextjs-webpack-canary-node135019
✅ nextjs-webpack-canary-quickjs135019
✅ nextjs-webpack-stable-node15400
✅ nextjs-webpack-stable-quickjs15400
✅ nitro-stable-node128026
✅ nitro-stable-quickjs128026
✅ nuxt-stable-node128026
✅ nuxt-stable-quickjs128026
✅ sveltekit-stable-node14707
✅ sveltekit-stable-quickjs14707
✅ vite-stable-node128026
✅ vite-stable-quickjs128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node128026
✅ astro-stable-quickjs128026
✅ express-stable-node128026
✅ express-stable-quickjs128026
✅ fastify-stable-node128026
✅ fastify-stable-quickjs128026
✅ hono-stable-node128026
✅ hono-stable-quickjs128026
✅ nextjs-turbopack-canary-node135019
✅ nextjs-turbopack-canary-quickjs135019
✅ nextjs-turbopack-quickjs-snapshot15400
✅ nextjs-turbopack-stable-node15400
✅ nextjs-turbopack-stable-quickjs15400
✅ nextjs-webpack-canary-node135019
✅ nextjs-webpack-canary-quickjs135019
✅ nextjs-webpack-stable-node15400
✅ nextjs-webpack-stable-quickjs15400
✅ nitro-stable-node128026
✅ nitro-stable-quickjs128026
✅ nuxt-stable-node128026
✅ nuxt-stable-quickjs128026
✅ sveltekit-stable-node14707
✅ sveltekit-stable-quickjs14707
✅ vite-stable-node128026
✅ vite-stable-quickjs128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15400
✅ nextjs-turbopack-quickjs15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable-node128026
✅ e2e-local-dev-nest-stable-quickjs128026
✅ e2e-local-dev-tanstack-start-node128026
✅ e2e-local-dev-tanstack-start-quickjs128026
✅ e2e-local-postgres-nest-stable-node128026
✅ e2e-local-postgres-nest-stable-quickjs128026
✅ e2e-local-postgres-tanstack-start-node128026
✅ e2e-local-postgres-tanstack-start-quickjs128026
✅ e2e-local-prod-nest-stable-node128026
✅ e2e-local-prod-nest-stable-quickjs128026
✅ e2e-local-prod-tanstack-start-node128026
✅ e2e-local-prod-tanstack-start-quickjs128026
✅ e2e-vercel-prod-nest-node126028
✅ e2e-vercel-prod-tanstack-start-node126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@TooTallNate
TooTallNateforce-pushed the quickjs-vm-snapshots branch from 1e0b6e6 to f43ee63CompareJuly 31, 2026 01:07
@TooTallNate
TooTallNateforce-pushed the quickjs-vm-threshold-snapshots branch from 64f40c3 to f1b1e10CompareJuly 31, 2026 01:09
@TooTallNate
TooTallNate merged commit f64932e into quickjs-vm-snapshotsJul 31, 2026
@TooTallNate
TooTallNateforce-pushed the quickjs-vm-snapshots branch from f43ee63 to f64932eCompareJuly 31, 2026 02:15
@TooTallNate
TooTallNate deleted the quickjs-vm-threshold-snapshots branch July 31, 2026 02:15
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.

1 participant

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

QuickJS engine: threshold-based VM-memory snapshotting (WORKFLOW_SNAPSHOT_THRESHOLD) - #3053

Merged
TooTallNate merged 0 commit into
quickjs-vm-snapshotsfrom
quickjs-vm-threshold-snapshots
Jul 31, 2026
Merged

QuickJS engine: threshold-based VM-memory snapshotting (WORKFLOW_SNAPSHOT_THRESHOLD)#3053
TooTallNate merged 0 commit into
quickjs-vm-snapshotsfrom
quickjs-vm-threshold-snapshots

Conversation

@TooTallNate

Copy link
Copy Markdown
Member

Stacked PR — based on #3050 (quickjs-vm-snapshots) ← #3049#3048. Review only the top commit here until the bases merge.

Summary

PR 4 of the QuickJS VM roadmap: threshold-based VM-memory snapshotting — the middle ground that motivated reviving this effort (see #1298 / #1300 discussion). Instead of snapshotting at every suspension (the original branch's model, which cost ~25% on e2e wall clock), snapshots are taken only once WORKFLOW_SNAPSHOT_THRESHOLD events have been processed since the last one:

  • Short-lived runs never snapshot — they keep PR 1/2's pure replay behavior with zero snapshot overhead.
  • Long/forever runs stop scaling their resume cost with event-log length — a resumption restores the VM heap and replays only the delta events since the snapshot's cursor.

How it works

  • WORKFLOW_SNAPSHOT_THRESHOLD env var (default 0 = disabled) or per-run executionContext.snapshotThreshold, stamped at start() for run affinity like WORKFLOW_VM.
  • Save (suspension exit, threshold met): capture live VM memory (session.snapshot()) → compress (zstd/gzip via the shared serialization pipeline; QuickJS heaps compress ~4×, measured 16.5 MB → 3.9 MB) → encrypt with the run's key when configured → world.snapshots.save with the events cursor at the VM's feed frontier.
  • Restore (subsequent invocation): world.snapshots.load → decrypt → decompress → QuickJS.restore over the cached WASM module, re-register host callbacks, fetch events from the snapshot's cursor and feed only the delta. Runs seamlessly through PR 2's inline continuation loop.
  • Delete on run completion/failure.
  • Fallback is always full replay: missing snapshot, load error, corrupt bytes, or restore failure logs a warning and boots fresh against the full event log — the log remains the source of truth; snapshots are strictly an optimization.

Determinism model (restore + partial replay)

The threshold model's new mechanism vs. the original branch: a resumption may restore a snapshot older than the log head (suspensions since the snapshot weren't persisted) and must deterministically re-derive everything in between:

  • The PRNG seed mixes in the restored snapshot's eventsCursor: the heap already consumed pre-snapshot draws, so re-seeding from the base would replay the first-N draws and collide with recorded correlationIds. The cursor is identical for every resume from the same snapshot (concurrent resumes still collide ids for the world's dedup) and advances only when a newer snapshot is taken.
  • Feeding already-consumed events is harmless by construction (consumed resolvers are gone; hook deliveries are deduped by eventId in the VM heap, which travels with the snapshot), so imprecise cursors only cost redundant scanning.
  • Covered by dedicated unit tests, including restore-from-older-snapshot with multi-suspension partial replay and identical post-restore correlationIds across concurrent resumes.

Validation

  • 135/135 e2e on nextjs-turbopack with WORKFLOW_SNAPSHOT_THRESHOLD=1 (maximum churn: snapshot on every qualifying suspension), wall clock within ~10% of the node baseline
  • Verified via debug diagnostics: restored: true resumptions, save/restore/delete lifecycle, and threshold gating (threshold=100 short run ⇒ zero snapshots, pure replay)
  • Full core unit suite green (1,573 tests); new tests for the config knobs and the snapshot/restore/partial-replay determinism
  • CI: new quickjs-snapshot matrix leg (nextjs-turbopack, threshold=1) across local dev/prod/postgres e2e jobs

Notes / follow-ups

  • Version skew: snapshot bytes are tied to the quickjs-wasi build that produced them. Per the deployment contract (runs continue on the version they started on — free on Vercel), this is a non-issue in production; environments without skew protection are covered by the restore-failure fallback to full replay.
  • An ID-divergence window exists when concurrent invocations resume from different snapshot generations; the world's per-(run, correlation) uniqueness rejects duplicates and the log-consistent invocation drives progress, with full-replay convergence as the backstop. Noted in code comments.
  • Docs: WORKFLOW_SNAPSHOT_THRESHOLD section added to v5 Runtime Tuning.

@changeset-bot

changeset-botBot commented Jul 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f1b1e10

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

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

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

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

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewJul 31, 2026 1:13am
example-nextjs-workflow-webpackReadyReadyPreviewJul 31, 2026 1:13am
example-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-astro-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-express-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-fastify-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-hono-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-nestjs-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-nitro-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-nuxt-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-sveltekit-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-tanstack-start-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-vite-workflowReadyReadyPreviewJul 31, 2026 1:13am
workflow-docsBuildingBuildingPreview, v0Jul 31, 2026 1:13am
workflow-swc-playgroundReadyReadyPreviewJul 31, 2026 1:13am
workflow-tarballsReadyReadyPreviewJul 31, 2026 1:13am
workflow-webReadyReadyPreviewJul 31, 2026 1:13am

@github-actions

github-actionsBot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development312604163542
✅ 📦 Local Production339604543850
✅ 🐘 Local Postgres339604543850
✅ 🪟 Windows30800308
✅ 📋 Other178803682156
✅ vercel-multi-region270027
Total134960193115427
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node126028
✅ example-node126028
✅ express-node126028
✅ fastify-node126028
✅ hono-node126028
✅ nextjs-turbopack-node15103
✅ nextjs-webpack-node15103
✅ nitro-node126028
✅ nuxt-node126028
✅ sveltekit-node14509
✅ vite-node126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node128026
✅ astro-stable-quickjs128026
✅ express-stable-node128026
✅ express-stable-quickjs128026
✅ fastify-stable-node128026
✅ fastify-stable-quickjs128026
✅ hono-stable-node128026
✅ hono-stable-quickjs128026
✅ nextjs-turbopack-canary-node135019
✅ nextjs-turbopack-canary-quickjs135019
✅ nextjs-turbopack-quickjs-snapshot15400
✅ nextjs-turbopack-stable-node15400
✅ nextjs-turbopack-stable-quickjs15400
✅ nextjs-webpack-stable-node15400
✅ nextjs-webpack-stable-quickjs15400
✅ nitro-stable-node128026
✅ nitro-stable-quickjs128026
✅ nuxt-stable-node128026
✅ nuxt-stable-quickjs128026
✅ sveltekit-stable-node14707
✅ sveltekit-stable-quickjs14707
✅ vite-stable-node128026
✅ vite-stable-quickjs128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node128026
✅ astro-stable-quickjs128026
✅ express-stable-node128026
✅ express-stable-quickjs128026
✅ fastify-stable-node128026
✅ fastify-stable-quickjs128026
✅ hono-stable-node128026
✅ hono-stable-quickjs128026
✅ nextjs-turbopack-canary-node135019
✅ nextjs-turbopack-canary-quickjs135019
✅ nextjs-turbopack-quickjs-snapshot15400
✅ nextjs-turbopack-stable-node15400
✅ nextjs-turbopack-stable-quickjs15400
✅ nextjs-webpack-canary-node135019
✅ nextjs-webpack-canary-quickjs135019
✅ nextjs-webpack-stable-node15400
✅ nextjs-webpack-stable-quickjs15400
✅ nitro-stable-node128026
✅ nitro-stable-quickjs128026
✅ nuxt-stable-node128026
✅ nuxt-stable-quickjs128026
✅ sveltekit-stable-node14707
✅ sveltekit-stable-quickjs14707
✅ vite-stable-node128026
✅ vite-stable-quickjs128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node128026
✅ astro-stable-quickjs128026
✅ express-stable-node128026
✅ express-stable-quickjs128026
✅ fastify-stable-node128026
✅ fastify-stable-quickjs128026
✅ hono-stable-node128026
✅ hono-stable-quickjs128026
✅ nextjs-turbopack-canary-node135019
✅ nextjs-turbopack-canary-quickjs135019
✅ nextjs-turbopack-quickjs-snapshot15400
✅ nextjs-turbopack-stable-node15400
✅ nextjs-turbopack-stable-quickjs15400
✅ nextjs-webpack-canary-node135019
✅ nextjs-webpack-canary-quickjs135019
✅ nextjs-webpack-stable-node15400
✅ nextjs-webpack-stable-quickjs15400
✅ nitro-stable-node128026
✅ nitro-stable-quickjs128026
✅ nuxt-stable-node128026
✅ nuxt-stable-quickjs128026
✅ sveltekit-stable-node14707
✅ sveltekit-stable-quickjs14707
✅ vite-stable-node128026
✅ vite-stable-quickjs128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15400
✅ nextjs-turbopack-quickjs15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable-node128026
✅ e2e-local-dev-nest-stable-quickjs128026
✅ e2e-local-dev-tanstack-start-node128026
✅ e2e-local-dev-tanstack-start-quickjs128026
✅ e2e-local-postgres-nest-stable-node128026
✅ e2e-local-postgres-nest-stable-quickjs128026
✅ e2e-local-postgres-tanstack-start-node128026
✅ e2e-local-postgres-tanstack-start-quickjs128026
✅ e2e-local-prod-nest-stable-node128026
✅ e2e-local-prod-nest-stable-quickjs128026
✅ e2e-local-prod-tanstack-start-node128026
✅ e2e-local-prod-tanstack-start-quickjs128026
✅ e2e-vercel-prod-nest-node126028
✅ e2e-vercel-prod-tanstack-start-node126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@TooTallNate
TooTallNateforce-pushed the quickjs-vm-snapshots branch from 1e0b6e6 to f43ee63CompareJuly 31, 2026 01:07
@TooTallNate
TooTallNateforce-pushed the quickjs-vm-threshold-snapshots branch from 64f40c3 to f1b1e10CompareJuly 31, 2026 01:09
@TooTallNate
TooTallNate merged commit f64932e into quickjs-vm-snapshotsJul 31, 2026
@TooTallNate
TooTallNateforce-pushed the quickjs-vm-snapshots branch from f43ee63 to f64932eCompareJuly 31, 2026 02:15
@TooTallNate
TooTallNate deleted the quickjs-vm-threshold-snapshots branch July 31, 2026 02:15
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.

1 participant

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

QuickJS engine: threshold-based VM-memory snapshotting (WORKFLOW_SNAPSHOT_THRESHOLD) - #3053

Merged
TooTallNate merged 0 commit into
quickjs-vm-snapshotsfrom
quickjs-vm-threshold-snapshots
Jul 31, 2026
Merged

QuickJS engine: threshold-based VM-memory snapshotting (WORKFLOW_SNAPSHOT_THRESHOLD)#3053
TooTallNate merged 0 commit into
quickjs-vm-snapshotsfrom
quickjs-vm-threshold-snapshots

Conversation

@TooTallNate

Copy link
Copy Markdown
Member

Stacked PR — based on #3050 (quickjs-vm-snapshots) ← #3049#3048. Review only the top commit here until the bases merge.

Summary

PR 4 of the QuickJS VM roadmap: threshold-based VM-memory snapshotting — the middle ground that motivated reviving this effort (see #1298 / #1300 discussion). Instead of snapshotting at every suspension (the original branch's model, which cost ~25% on e2e wall clock), snapshots are taken only once WORKFLOW_SNAPSHOT_THRESHOLD events have been processed since the last one:

  • Short-lived runs never snapshot — they keep PR 1/2's pure replay behavior with zero snapshot overhead.
  • Long/forever runs stop scaling their resume cost with event-log length — a resumption restores the VM heap and replays only the delta events since the snapshot's cursor.

How it works

  • WORKFLOW_SNAPSHOT_THRESHOLD env var (default 0 = disabled) or per-run executionContext.snapshotThreshold, stamped at start() for run affinity like WORKFLOW_VM.
  • Save (suspension exit, threshold met): capture live VM memory (session.snapshot()) → compress (zstd/gzip via the shared serialization pipeline; QuickJS heaps compress ~4×, measured 16.5 MB → 3.9 MB) → encrypt with the run's key when configured → world.snapshots.save with the events cursor at the VM's feed frontier.
  • Restore (subsequent invocation): world.snapshots.load → decrypt → decompress → QuickJS.restore over the cached WASM module, re-register host callbacks, fetch events from the snapshot's cursor and feed only the delta. Runs seamlessly through PR 2's inline continuation loop.
  • Delete on run completion/failure.
  • Fallback is always full replay: missing snapshot, load error, corrupt bytes, or restore failure logs a warning and boots fresh against the full event log — the log remains the source of truth; snapshots are strictly an optimization.

Determinism model (restore + partial replay)

The threshold model's new mechanism vs. the original branch: a resumption may restore a snapshot older than the log head (suspensions since the snapshot weren't persisted) and must deterministically re-derive everything in between:

  • The PRNG seed mixes in the restored snapshot's eventsCursor: the heap already consumed pre-snapshot draws, so re-seeding from the base would replay the first-N draws and collide with recorded correlationIds. The cursor is identical for every resume from the same snapshot (concurrent resumes still collide ids for the world's dedup) and advances only when a newer snapshot is taken.
  • Feeding already-consumed events is harmless by construction (consumed resolvers are gone; hook deliveries are deduped by eventId in the VM heap, which travels with the snapshot), so imprecise cursors only cost redundant scanning.
  • Covered by dedicated unit tests, including restore-from-older-snapshot with multi-suspension partial replay and identical post-restore correlationIds across concurrent resumes.

Validation

  • 135/135 e2e on nextjs-turbopack with WORKFLOW_SNAPSHOT_THRESHOLD=1 (maximum churn: snapshot on every qualifying suspension), wall clock within ~10% of the node baseline
  • Verified via debug diagnostics: restored: true resumptions, save/restore/delete lifecycle, and threshold gating (threshold=100 short run ⇒ zero snapshots, pure replay)
  • Full core unit suite green (1,573 tests); new tests for the config knobs and the snapshot/restore/partial-replay determinism
  • CI: new quickjs-snapshot matrix leg (nextjs-turbopack, threshold=1) across local dev/prod/postgres e2e jobs

Notes / follow-ups

  • Version skew: snapshot bytes are tied to the quickjs-wasi build that produced them. Per the deployment contract (runs continue on the version they started on — free on Vercel), this is a non-issue in production; environments without skew protection are covered by the restore-failure fallback to full replay.
  • An ID-divergence window exists when concurrent invocations resume from different snapshot generations; the world's per-(run, correlation) uniqueness rejects duplicates and the log-consistent invocation drives progress, with full-replay convergence as the backstop. Noted in code comments.
  • Docs: WORKFLOW_SNAPSHOT_THRESHOLD section added to v5 Runtime Tuning.

@changeset-bot

changeset-botBot commented Jul 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f1b1e10

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

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

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

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

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewJul 31, 2026 1:13am
example-nextjs-workflow-webpackReadyReadyPreviewJul 31, 2026 1:13am
example-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-astro-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-express-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-fastify-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-hono-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-nestjs-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-nitro-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-nuxt-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-sveltekit-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-tanstack-start-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-vite-workflowReadyReadyPreviewJul 31, 2026 1:13am
workflow-docsBuildingBuildingPreview, v0Jul 31, 2026 1:13am
workflow-swc-playgroundReadyReadyPreviewJul 31, 2026 1:13am
workflow-tarballsReadyReadyPreviewJul 31, 2026 1:13am
workflow-webReadyReadyPreviewJul 31, 2026 1:13am

@github-actions

github-actionsBot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development312604163542
✅ 📦 Local Production339604543850
✅ 🐘 Local Postgres339604543850
✅ 🪟 Windows30800308
✅ 📋 Other178803682156
✅ vercel-multi-region270027
Total134960193115427
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node126028
✅ example-node126028
✅ express-node126028
✅ fastify-node126028
✅ hono-node126028
✅ nextjs-turbopack-node15103
✅ nextjs-webpack-node15103
✅ nitro-node126028
✅ nuxt-node126028
✅ sveltekit-node14509
✅ vite-node126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node128026
✅ astro-stable-quickjs128026
✅ express-stable-node128026
✅ express-stable-quickjs128026
✅ fastify-stable-node128026
✅ fastify-stable-quickjs128026
✅ hono-stable-node128026
✅ hono-stable-quickjs128026
✅ nextjs-turbopack-canary-node135019
✅ nextjs-turbopack-canary-quickjs135019
✅ nextjs-turbopack-quickjs-snapshot15400
✅ nextjs-turbopack-stable-node15400
✅ nextjs-turbopack-stable-quickjs15400
✅ nextjs-webpack-stable-node15400
✅ nextjs-webpack-stable-quickjs15400
✅ nitro-stable-node128026
✅ nitro-stable-quickjs128026
✅ nuxt-stable-node128026
✅ nuxt-stable-quickjs128026
✅ sveltekit-stable-node14707
✅ sveltekit-stable-quickjs14707
✅ vite-stable-node128026
✅ vite-stable-quickjs128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node128026
✅ astro-stable-quickjs128026
✅ express-stable-node128026
✅ express-stable-quickjs128026
✅ fastify-stable-node128026
✅ fastify-stable-quickjs128026
✅ hono-stable-node128026
✅ hono-stable-quickjs128026
✅ nextjs-turbopack-canary-node135019
✅ nextjs-turbopack-canary-quickjs135019
✅ nextjs-turbopack-quickjs-snapshot15400
✅ nextjs-turbopack-stable-node15400
✅ nextjs-turbopack-stable-quickjs15400
✅ nextjs-webpack-canary-node135019
✅ nextjs-webpack-canary-quickjs135019
✅ nextjs-webpack-stable-node15400
✅ nextjs-webpack-stable-quickjs15400
✅ nitro-stable-node128026
✅ nitro-stable-quickjs128026
✅ nuxt-stable-node128026
✅ nuxt-stable-quickjs128026
✅ sveltekit-stable-node14707
✅ sveltekit-stable-quickjs14707
✅ vite-stable-node128026
✅ vite-stable-quickjs128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node128026
✅ astro-stable-quickjs128026
✅ express-stable-node128026
✅ express-stable-quickjs128026
✅ fastify-stable-node128026
✅ fastify-stable-quickjs128026
✅ hono-stable-node128026
✅ hono-stable-quickjs128026
✅ nextjs-turbopack-canary-node135019
✅ nextjs-turbopack-canary-quickjs135019
✅ nextjs-turbopack-quickjs-snapshot15400
✅ nextjs-turbopack-stable-node15400
✅ nextjs-turbopack-stable-quickjs15400
✅ nextjs-webpack-canary-node135019
✅ nextjs-webpack-canary-quickjs135019
✅ nextjs-webpack-stable-node15400
✅ nextjs-webpack-stable-quickjs15400
✅ nitro-stable-node128026
✅ nitro-stable-quickjs128026
✅ nuxt-stable-node128026
✅ nuxt-stable-quickjs128026
✅ sveltekit-stable-node14707
✅ sveltekit-stable-quickjs14707
✅ vite-stable-node128026
✅ vite-stable-quickjs128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15400
✅ nextjs-turbopack-quickjs15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable-node128026
✅ e2e-local-dev-nest-stable-quickjs128026
✅ e2e-local-dev-tanstack-start-node128026
✅ e2e-local-dev-tanstack-start-quickjs128026
✅ e2e-local-postgres-nest-stable-node128026
✅ e2e-local-postgres-nest-stable-quickjs128026
✅ e2e-local-postgres-tanstack-start-node128026
✅ e2e-local-postgres-tanstack-start-quickjs128026
✅ e2e-local-prod-nest-stable-node128026
✅ e2e-local-prod-nest-stable-quickjs128026
✅ e2e-local-prod-tanstack-start-node128026
✅ e2e-local-prod-tanstack-start-quickjs128026
✅ e2e-vercel-prod-nest-node126028
✅ e2e-vercel-prod-tanstack-start-node126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@TooTallNate
TooTallNateforce-pushed the quickjs-vm-snapshots branch from 1e0b6e6 to f43ee63CompareJuly 31, 2026 01:07
@TooTallNate
TooTallNateforce-pushed the quickjs-vm-threshold-snapshots branch from 64f40c3 to f1b1e10CompareJuly 31, 2026 01:09
@TooTallNate
TooTallNate merged commit f64932e into quickjs-vm-snapshotsJul 31, 2026
@TooTallNate
TooTallNateforce-pushed the quickjs-vm-snapshots branch from f43ee63 to f64932eCompareJuly 31, 2026 02:15
@TooTallNate
TooTallNate deleted the quickjs-vm-threshold-snapshots branch July 31, 2026 02:15
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.

1 participant

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

QuickJS engine: threshold-based VM-memory snapshotting (WORKFLOW_SNAPSHOT_THRESHOLD) - #3053

Merged
TooTallNate merged 0 commit into
quickjs-vm-snapshotsfrom
quickjs-vm-threshold-snapshots
Jul 31, 2026
Merged

QuickJS engine: threshold-based VM-memory snapshotting (WORKFLOW_SNAPSHOT_THRESHOLD)#3053
TooTallNate merged 0 commit into
quickjs-vm-snapshotsfrom
quickjs-vm-threshold-snapshots

Conversation

@TooTallNate

Copy link
Copy Markdown
Member

Stacked PR — based on #3050 (quickjs-vm-snapshots) ← #3049#3048. Review only the top commit here until the bases merge.

Summary

PR 4 of the QuickJS VM roadmap: threshold-based VM-memory snapshotting — the middle ground that motivated reviving this effort (see #1298 / #1300 discussion). Instead of snapshotting at every suspension (the original branch's model, which cost ~25% on e2e wall clock), snapshots are taken only once WORKFLOW_SNAPSHOT_THRESHOLD events have been processed since the last one:

  • Short-lived runs never snapshot — they keep PR 1/2's pure replay behavior with zero snapshot overhead.
  • Long/forever runs stop scaling their resume cost with event-log length — a resumption restores the VM heap and replays only the delta events since the snapshot's cursor.

How it works

  • WORKFLOW_SNAPSHOT_THRESHOLD env var (default 0 = disabled) or per-run executionContext.snapshotThreshold, stamped at start() for run affinity like WORKFLOW_VM.
  • Save (suspension exit, threshold met): capture live VM memory (session.snapshot()) → compress (zstd/gzip via the shared serialization pipeline; QuickJS heaps compress ~4×, measured 16.5 MB → 3.9 MB) → encrypt with the run's key when configured → world.snapshots.save with the events cursor at the VM's feed frontier.
  • Restore (subsequent invocation): world.snapshots.load → decrypt → decompress → QuickJS.restore over the cached WASM module, re-register host callbacks, fetch events from the snapshot's cursor and feed only the delta. Runs seamlessly through PR 2's inline continuation loop.
  • Delete on run completion/failure.
  • Fallback is always full replay: missing snapshot, load error, corrupt bytes, or restore failure logs a warning and boots fresh against the full event log — the log remains the source of truth; snapshots are strictly an optimization.

Determinism model (restore + partial replay)

The threshold model's new mechanism vs. the original branch: a resumption may restore a snapshot older than the log head (suspensions since the snapshot weren't persisted) and must deterministically re-derive everything in between:

  • The PRNG seed mixes in the restored snapshot's eventsCursor: the heap already consumed pre-snapshot draws, so re-seeding from the base would replay the first-N draws and collide with recorded correlationIds. The cursor is identical for every resume from the same snapshot (concurrent resumes still collide ids for the world's dedup) and advances only when a newer snapshot is taken.
  • Feeding already-consumed events is harmless by construction (consumed resolvers are gone; hook deliveries are deduped by eventId in the VM heap, which travels with the snapshot), so imprecise cursors only cost redundant scanning.
  • Covered by dedicated unit tests, including restore-from-older-snapshot with multi-suspension partial replay and identical post-restore correlationIds across concurrent resumes.

Validation

  • 135/135 e2e on nextjs-turbopack with WORKFLOW_SNAPSHOT_THRESHOLD=1 (maximum churn: snapshot on every qualifying suspension), wall clock within ~10% of the node baseline
  • Verified via debug diagnostics: restored: true resumptions, save/restore/delete lifecycle, and threshold gating (threshold=100 short run ⇒ zero snapshots, pure replay)
  • Full core unit suite green (1,573 tests); new tests for the config knobs and the snapshot/restore/partial-replay determinism
  • CI: new quickjs-snapshot matrix leg (nextjs-turbopack, threshold=1) across local dev/prod/postgres e2e jobs

Notes / follow-ups

  • Version skew: snapshot bytes are tied to the quickjs-wasi build that produced them. Per the deployment contract (runs continue on the version they started on — free on Vercel), this is a non-issue in production; environments without skew protection are covered by the restore-failure fallback to full replay.
  • An ID-divergence window exists when concurrent invocations resume from different snapshot generations; the world's per-(run, correlation) uniqueness rejects duplicates and the log-consistent invocation drives progress, with full-replay convergence as the backstop. Noted in code comments.
  • Docs: WORKFLOW_SNAPSHOT_THRESHOLD section added to v5 Runtime Tuning.

@changeset-bot

changeset-botBot commented Jul 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f1b1e10

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

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

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

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

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewJul 31, 2026 1:13am
example-nextjs-workflow-webpackReadyReadyPreviewJul 31, 2026 1:13am
example-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-astro-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-express-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-fastify-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-hono-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-nestjs-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-nitro-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-nuxt-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-sveltekit-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-tanstack-start-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-vite-workflowReadyReadyPreviewJul 31, 2026 1:13am
workflow-docsBuildingBuildingPreview, v0Jul 31, 2026 1:13am
workflow-swc-playgroundReadyReadyPreviewJul 31, 2026 1:13am
workflow-tarballsReadyReadyPreviewJul 31, 2026 1:13am
workflow-webReadyReadyPreviewJul 31, 2026 1:13am

@github-actions

github-actionsBot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development312604163542
✅ 📦 Local Production339604543850
✅ 🐘 Local Postgres339604543850
✅ 🪟 Windows30800308
✅ 📋 Other178803682156
✅ vercel-multi-region270027
Total134960193115427
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node126028
✅ example-node126028
✅ express-node126028
✅ fastify-node126028
✅ hono-node126028
✅ nextjs-turbopack-node15103
✅ nextjs-webpack-node15103
✅ nitro-node126028
✅ nuxt-node126028
✅ sveltekit-node14509
✅ vite-node126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node128026
✅ astro-stable-quickjs128026
✅ express-stable-node128026
✅ express-stable-quickjs128026
✅ fastify-stable-node128026
✅ fastify-stable-quickjs128026
✅ hono-stable-node128026
✅ hono-stable-quickjs128026
✅ nextjs-turbopack-canary-node135019
✅ nextjs-turbopack-canary-quickjs135019
✅ nextjs-turbopack-quickjs-snapshot15400
✅ nextjs-turbopack-stable-node15400
✅ nextjs-turbopack-stable-quickjs15400
✅ nextjs-webpack-stable-node15400
✅ nextjs-webpack-stable-quickjs15400
✅ nitro-stable-node128026
✅ nitro-stable-quickjs128026
✅ nuxt-stable-node128026
✅ nuxt-stable-quickjs128026
✅ sveltekit-stable-node14707
✅ sveltekit-stable-quickjs14707
✅ vite-stable-node128026
✅ vite-stable-quickjs128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node128026
✅ astro-stable-quickjs128026
✅ express-stable-node128026
✅ express-stable-quickjs128026
✅ fastify-stable-node128026
✅ fastify-stable-quickjs128026
✅ hono-stable-node128026
✅ hono-stable-quickjs128026
✅ nextjs-turbopack-canary-node135019
✅ nextjs-turbopack-canary-quickjs135019
✅ nextjs-turbopack-quickjs-snapshot15400
✅ nextjs-turbopack-stable-node15400
✅ nextjs-turbopack-stable-quickjs15400
✅ nextjs-webpack-canary-node135019
✅ nextjs-webpack-canary-quickjs135019
✅ nextjs-webpack-stable-node15400
✅ nextjs-webpack-stable-quickjs15400
✅ nitro-stable-node128026
✅ nitro-stable-quickjs128026
✅ nuxt-stable-node128026
✅ nuxt-stable-quickjs128026
✅ sveltekit-stable-node14707
✅ sveltekit-stable-quickjs14707
✅ vite-stable-node128026
✅ vite-stable-quickjs128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node128026
✅ astro-stable-quickjs128026
✅ express-stable-node128026
✅ express-stable-quickjs128026
✅ fastify-stable-node128026
✅ fastify-stable-quickjs128026
✅ hono-stable-node128026
✅ hono-stable-quickjs128026
✅ nextjs-turbopack-canary-node135019
✅ nextjs-turbopack-canary-quickjs135019
✅ nextjs-turbopack-quickjs-snapshot15400
✅ nextjs-turbopack-stable-node15400
✅ nextjs-turbopack-stable-quickjs15400
✅ nextjs-webpack-canary-node135019
✅ nextjs-webpack-canary-quickjs135019
✅ nextjs-webpack-stable-node15400
✅ nextjs-webpack-stable-quickjs15400
✅ nitro-stable-node128026
✅ nitro-stable-quickjs128026
✅ nuxt-stable-node128026
✅ nuxt-stable-quickjs128026
✅ sveltekit-stable-node14707
✅ sveltekit-stable-quickjs14707
✅ vite-stable-node128026
✅ vite-stable-quickjs128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15400
✅ nextjs-turbopack-quickjs15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable-node128026
✅ e2e-local-dev-nest-stable-quickjs128026
✅ e2e-local-dev-tanstack-start-node128026
✅ e2e-local-dev-tanstack-start-quickjs128026
✅ e2e-local-postgres-nest-stable-node128026
✅ e2e-local-postgres-nest-stable-quickjs128026
✅ e2e-local-postgres-tanstack-start-node128026
✅ e2e-local-postgres-tanstack-start-quickjs128026
✅ e2e-local-prod-nest-stable-node128026
✅ e2e-local-prod-nest-stable-quickjs128026
✅ e2e-local-prod-tanstack-start-node128026
✅ e2e-local-prod-tanstack-start-quickjs128026
✅ e2e-vercel-prod-nest-node126028
✅ e2e-vercel-prod-tanstack-start-node126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@TooTallNate
TooTallNateforce-pushed the quickjs-vm-snapshots branch from 1e0b6e6 to f43ee63CompareJuly 31, 2026 01:07
@TooTallNate
TooTallNateforce-pushed the quickjs-vm-threshold-snapshots branch from 64f40c3 to f1b1e10CompareJuly 31, 2026 01:09
@TooTallNate
TooTallNate merged commit f64932e into quickjs-vm-snapshotsJul 31, 2026
@TooTallNate
TooTallNateforce-pushed the quickjs-vm-snapshots branch from f43ee63 to f64932eCompareJuly 31, 2026 02:15
@TooTallNate
TooTallNate deleted the quickjs-vm-threshold-snapshots branch July 31, 2026 02:15
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.

1 participant

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

QuickJS engine: threshold-based VM-memory snapshotting (WORKFLOW_SNAPSHOT_THRESHOLD) - #3053

Merged
TooTallNate merged 0 commit into
quickjs-vm-snapshotsfrom
quickjs-vm-threshold-snapshots
Jul 31, 2026
Merged

QuickJS engine: threshold-based VM-memory snapshotting (WORKFLOW_SNAPSHOT_THRESHOLD)#3053
TooTallNate merged 0 commit into
quickjs-vm-snapshotsfrom
quickjs-vm-threshold-snapshots

Conversation

@TooTallNate

Copy link
Copy Markdown
Member

Stacked PR — based on #3050 (quickjs-vm-snapshots) ← #3049#3048. Review only the top commit here until the bases merge.

Summary

PR 4 of the QuickJS VM roadmap: threshold-based VM-memory snapshotting — the middle ground that motivated reviving this effort (see #1298 / #1300 discussion). Instead of snapshotting at every suspension (the original branch's model, which cost ~25% on e2e wall clock), snapshots are taken only once WORKFLOW_SNAPSHOT_THRESHOLD events have been processed since the last one:

  • Short-lived runs never snapshot — they keep PR 1/2's pure replay behavior with zero snapshot overhead.
  • Long/forever runs stop scaling their resume cost with event-log length — a resumption restores the VM heap and replays only the delta events since the snapshot's cursor.

How it works

  • WORKFLOW_SNAPSHOT_THRESHOLD env var (default 0 = disabled) or per-run executionContext.snapshotThreshold, stamped at start() for run affinity like WORKFLOW_VM.
  • Save (suspension exit, threshold met): capture live VM memory (session.snapshot()) → compress (zstd/gzip via the shared serialization pipeline; QuickJS heaps compress ~4×, measured 16.5 MB → 3.9 MB) → encrypt with the run's key when configured → world.snapshots.save with the events cursor at the VM's feed frontier.
  • Restore (subsequent invocation): world.snapshots.load → decrypt → decompress → QuickJS.restore over the cached WASM module, re-register host callbacks, fetch events from the snapshot's cursor and feed only the delta. Runs seamlessly through PR 2's inline continuation loop.
  • Delete on run completion/failure.
  • Fallback is always full replay: missing snapshot, load error, corrupt bytes, or restore failure logs a warning and boots fresh against the full event log — the log remains the source of truth; snapshots are strictly an optimization.

Determinism model (restore + partial replay)

The threshold model's new mechanism vs. the original branch: a resumption may restore a snapshot older than the log head (suspensions since the snapshot weren't persisted) and must deterministically re-derive everything in between:

  • The PRNG seed mixes in the restored snapshot's eventsCursor: the heap already consumed pre-snapshot draws, so re-seeding from the base would replay the first-N draws and collide with recorded correlationIds. The cursor is identical for every resume from the same snapshot (concurrent resumes still collide ids for the world's dedup) and advances only when a newer snapshot is taken.
  • Feeding already-consumed events is harmless by construction (consumed resolvers are gone; hook deliveries are deduped by eventId in the VM heap, which travels with the snapshot), so imprecise cursors only cost redundant scanning.
  • Covered by dedicated unit tests, including restore-from-older-snapshot with multi-suspension partial replay and identical post-restore correlationIds across concurrent resumes.

Validation

  • 135/135 e2e on nextjs-turbopack with WORKFLOW_SNAPSHOT_THRESHOLD=1 (maximum churn: snapshot on every qualifying suspension), wall clock within ~10% of the node baseline
  • Verified via debug diagnostics: restored: true resumptions, save/restore/delete lifecycle, and threshold gating (threshold=100 short run ⇒ zero snapshots, pure replay)
  • Full core unit suite green (1,573 tests); new tests for the config knobs and the snapshot/restore/partial-replay determinism
  • CI: new quickjs-snapshot matrix leg (nextjs-turbopack, threshold=1) across local dev/prod/postgres e2e jobs

Notes / follow-ups

  • Version skew: snapshot bytes are tied to the quickjs-wasi build that produced them. Per the deployment contract (runs continue on the version they started on — free on Vercel), this is a non-issue in production; environments without skew protection are covered by the restore-failure fallback to full replay.
  • An ID-divergence window exists when concurrent invocations resume from different snapshot generations; the world's per-(run, correlation) uniqueness rejects duplicates and the log-consistent invocation drives progress, with full-replay convergence as the backstop. Noted in code comments.
  • Docs: WORKFLOW_SNAPSHOT_THRESHOLD section added to v5 Runtime Tuning.

@changeset-bot

changeset-botBot commented Jul 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f1b1e10

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

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

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

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

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewJul 31, 2026 1:13am
example-nextjs-workflow-webpackReadyReadyPreviewJul 31, 2026 1:13am
example-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-astro-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-express-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-fastify-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-hono-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-nestjs-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-nitro-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-nuxt-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-sveltekit-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-tanstack-start-workflowReadyReadyPreviewJul 31, 2026 1:13am
workbench-vite-workflowReadyReadyPreviewJul 31, 2026 1:13am
workflow-docsBuildingBuildingPreview, v0Jul 31, 2026 1:13am
workflow-swc-playgroundReadyReadyPreviewJul 31, 2026 1:13am
workflow-tarballsReadyReadyPreviewJul 31, 2026 1:13am
workflow-webReadyReadyPreviewJul 31, 2026 1:13am

@github-actions

github-actionsBot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development312604163542
✅ 📦 Local Production339604543850
✅ 🐘 Local Postgres339604543850
✅ 🪟 Windows30800308
✅ 📋 Other178803682156
✅ vercel-multi-region270027
Total134960193115427
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node126028
✅ example-node126028
✅ express-node126028
✅ fastify-node126028
✅ hono-node126028
✅ nextjs-turbopack-node15103
✅ nextjs-webpack-node15103
✅ nitro-node126028
✅ nuxt-node126028
✅ sveltekit-node14509
✅ vite-node126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node128026
✅ astro-stable-quickjs128026
✅ express-stable-node128026
✅ express-stable-quickjs128026
✅ fastify-stable-node128026
✅ fastify-stable-quickjs128026
✅ hono-stable-node128026
✅ hono-stable-quickjs128026
✅ nextjs-turbopack-canary-node135019
✅ nextjs-turbopack-canary-quickjs135019
✅ nextjs-turbopack-quickjs-snapshot15400
✅ nextjs-turbopack-stable-node15400
✅ nextjs-turbopack-stable-quickjs15400
✅ nextjs-webpack-stable-node15400
✅ nextjs-webpack-stable-quickjs15400
✅ nitro-stable-node128026
✅ nitro-stable-quickjs128026
✅ nuxt-stable-node128026
✅ nuxt-stable-quickjs128026
✅ sveltekit-stable-node14707
✅ sveltekit-stable-quickjs14707
✅ vite-stable-node128026
✅ vite-stable-quickjs128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node128026
✅ astro-stable-quickjs128026
✅ express-stable-node128026
✅ express-stable-quickjs128026
✅ fastify-stable-node128026
✅ fastify-stable-quickjs128026
✅ hono-stable-node128026
✅ hono-stable-quickjs128026
✅ nextjs-turbopack-canary-node135019
✅ nextjs-turbopack-canary-quickjs135019
✅ nextjs-turbopack-quickjs-snapshot15400
✅ nextjs-turbopack-stable-node15400
✅ nextjs-turbopack-stable-quickjs15400
✅ nextjs-webpack-canary-node135019
✅ nextjs-webpack-canary-quickjs135019
✅ nextjs-webpack-stable-node15400
✅ nextjs-webpack-stable-quickjs15400
✅ nitro-stable-node128026
✅ nitro-stable-quickjs128026
✅ nuxt-stable-node128026
✅ nuxt-stable-quickjs128026
✅ sveltekit-stable-node14707
✅ sveltekit-stable-quickjs14707
✅ vite-stable-node128026
✅ vite-stable-quickjs128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node128026
✅ astro-stable-quickjs128026
✅ express-stable-node128026
✅ express-stable-quickjs128026
✅ fastify-stable-node128026
✅ fastify-stable-quickjs128026
✅ hono-stable-node128026
✅ hono-stable-quickjs128026
✅ nextjs-turbopack-canary-node135019
✅ nextjs-turbopack-canary-quickjs135019
✅ nextjs-turbopack-quickjs-snapshot15400
✅ nextjs-turbopack-stable-node15400
✅ nextjs-turbopack-stable-quickjs15400
✅ nextjs-webpack-canary-node135019
✅ nextjs-webpack-canary-quickjs135019
✅ nextjs-webpack-stable-node15400
✅ nextjs-webpack-stable-quickjs15400
✅ nitro-stable-node128026
✅ nitro-stable-quickjs128026
✅ nuxt-stable-node128026
✅ nuxt-stable-quickjs128026
✅ sveltekit-stable-node14707
✅ sveltekit-stable-quickjs14707
✅ vite-stable-node128026
✅ vite-stable-quickjs128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15400
✅ nextjs-turbopack-quickjs15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable-node128026
✅ e2e-local-dev-nest-stable-quickjs128026
✅ e2e-local-dev-tanstack-start-node128026
✅ e2e-local-dev-tanstack-start-quickjs128026
✅ e2e-local-postgres-nest-stable-node128026
✅ e2e-local-postgres-nest-stable-quickjs128026
✅ e2e-local-postgres-tanstack-start-node128026
✅ e2e-local-postgres-tanstack-start-quickjs128026
✅ e2e-local-prod-nest-stable-node128026
✅ e2e-local-prod-nest-stable-quickjs128026
✅ e2e-local-prod-tanstack-start-node128026
✅ e2e-local-prod-tanstack-start-quickjs128026
✅ e2e-vercel-prod-nest-node126028
✅ e2e-vercel-prod-tanstack-start-node126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@TooTallNate
TooTallNateforce-pushed the quickjs-vm-snapshots branch from 1e0b6e6 to f43ee63CompareJuly 31, 2026 01:07
@TooTallNate
TooTallNateforce-pushed the quickjs-vm-threshold-snapshots branch from 64f40c3 to f1b1e10CompareJuly 31, 2026 01:09
@TooTallNate
TooTallNate merged commit f64932e into quickjs-vm-snapshotsJul 31, 2026
@TooTallNate
TooTallNateforce-pushed the quickjs-vm-snapshots branch from f43ee63 to f64932eCompareJuly 31, 2026 02:15
@TooTallNate
TooTallNate deleted the quickjs-vm-threshold-snapshots branch July 31, 2026 02:15
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.

1 participant

@TooTallNate