Skip to content

Add World.getEncryptionKeyForRun and thread encryption key through serialization#979

Merged
TooTallNate merged 4 commits into
mainfrom
nate/encryptor-interface
Feb 18, 2026
Merged

Add World.getEncryptionKeyForRun and thread encryption key through serialization#979
TooTallNate merged 4 commits into
mainfrom
nate/encryptor-interface

Conversation

@TooTallNate
Copy link
Copy Markdown
Member

@TooTallNate TooTallNate commented Feb 8, 2026

Summary

  • Adds World.getEncryptionKeyForRun(run) returning Uint8Array | undefined as the interface for retrieving per-run encryption keys
  • Updates all 8 dehydrate/hydrate serialization functions to accept key: Uint8Array | undefined
  • Updates runtime callers, CLI, and tests to thread the key parameter through

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 8, 2026

🧪 E2E Test Results

Some tests failed

Summary

Passed Failed Skipped Total
✅ ▲ Vercel Production 534 0 38 572
✅ 💻 Local Development 556 0 68 624
✅ 📦 Local Production 556 0 68 624
✅ 🐘 Local Postgres 556 0 68 624
✅ 🪟 Windows 49 0 3 52
❌ 🌍 Community Worlds 111 45 9 165
✅ 📋 Other 135 0 21 156
Total 2497 45 275 2817

❌ Failed Tests

🌍 Community Worlds (45 failed)

turso (45 failed):

  • addTenWorkflow
  • addTenWorkflow
  • should work with react rendering in step
  • promiseAllWorkflow
  • promiseRaceWorkflow
  • promiseAnyWorkflow
  • hookWorkflow
  • webhookWorkflow
  • sleepingWorkflow
  • parallelSleepWorkflow
  • nullByteWorkflow
  • workflowAndStepMetadataWorkflow
  • fetchWorkflow
  • promiseRaceStressTestWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling retry behavior workflow completes despite transient 5xx on step_completed
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • closureVariableWorkflow - nested step functions with closure variables
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument
  • cancelRun - cancelling a running workflow
  • cancelRun via CLI - cancelling a running workflow
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router

Details by Category

✅ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 48 0 4
✅ example 48 0 4
✅ express 48 0 4
✅ fastify 48 0 4
✅ hono 48 0 4
✅ nextjs-turbopack 51 0 1
✅ nextjs-webpack 51 0 1
✅ nitro 48 0 4
✅ nuxt 48 0 4
✅ sveltekit 48 0 4
✅ vite 48 0 4
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 45 0 7
✅ express-stable 45 0 7
✅ fastify-stable 45 0 7
✅ hono-stable 45 0 7
✅ nextjs-turbopack-canary 49 0 3
✅ nextjs-turbopack-stable 49 0 3
✅ nextjs-webpack-canary 49 0 3
✅ nextjs-webpack-stable 49 0 3
✅ nitro-stable 45 0 7
✅ nuxt-stable 45 0 7
✅ sveltekit-stable 45 0 7
✅ vite-stable 45 0 7
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 45 0 7
✅ express-stable 45 0 7
✅ fastify-stable 45 0 7
✅ hono-stable 45 0 7
✅ nextjs-turbopack-canary 49 0 3
✅ nextjs-turbopack-stable 49 0 3
✅ nextjs-webpack-canary 49 0 3
✅ nextjs-webpack-stable 49 0 3
✅ nitro-stable 45 0 7
✅ nuxt-stable 45 0 7
✅ sveltekit-stable 45 0 7
✅ vite-stable 45 0 7
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 45 0 7
✅ express-stable 45 0 7
✅ fastify-stable 45 0 7
✅ hono-stable 45 0 7
✅ nextjs-turbopack-canary 49 0 3
✅ nextjs-turbopack-stable 49 0 3
✅ nextjs-webpack-canary 49 0 3
✅ nextjs-webpack-stable 49 0 3
✅ nitro-stable 45 0 7
✅ nuxt-stable 45 0 7
✅ sveltekit-stable 45 0 7
✅ vite-stable 45 0 7
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 49 0 3
❌ 🌍 Community Worlds
App Passed Failed Skipped
✅ mongodb-dev 3 0 0
✅ mongodb 49 0 3
✅ redis-dev 3 0 0
✅ redis 49 0 3
✅ turso-dev 3 0 0
❌ turso 4 45 3
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 45 0 7
✅ e2e-local-postgres-nest-stable 45 0 7
✅ e2e-local-prod-nest-stable 45 0 7

📋 View full workflow run

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Feb 8, 2026

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Feb 8, 2026

🦋 Changeset detected

Latest commit: f28311e

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

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

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

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

Comment thread packages/core/src/workflow.test.ts Outdated
Copy link
Copy Markdown
Contributor

@pranaygp pranaygp left a comment

Choose a reason for hiding this comment

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

Review: PR #979 - Add Encryptor interface and thread through serialization layer

Summary: Adds the Encryptor, EncryptionContext, and KeyMaterial interfaces to @workflow/world, makes World extend Encryptor, and threads the encryptor parameter through all serialization functions. This is a no-op refactor -- the encryptor parameter is unused (_encryptor) throughout.

Strengths:

  • Clean interface design: Encryptor has all-optional methods, so existing World implementations don't break
  • EncryptionContext is minimal (just runId) -- good for forward compatibility
  • KeyMaterial interface for o11y tooling is a thoughtful addition
  • The getEncryptorForRun() method on World is a well-designed escape hatch for cross-deployment encryption (e.g., resumeHook() from newer deployment)
  • getHookByTokenWithEncryptor() resolves the encryptor once and reuses it -- avoids redundant key resolution

Concerns:

  1. resolveEncryptorForRun type safety: In resume-hook.ts line 29-31, getEncryptorForRun is accessed via (world as any).getEncryptorForRun. Since World already extends Encryptor and getEncryptorForRun is defined on World, you should be able to use optional chaining directly: world.getEncryptorForRun?.(runId). The 'getEncryptorForRun' in world + as any pattern bypasses type checking unnecessarily.

  2. Serialization parameter ordering: The PR reorders parameters in the dehydrate/hydrate functions. For example, dehydrateWorkflowArguments goes from (value, ops, runId, ...) to (value, runId, encryptor, ops, ...). This is a breaking change to the internal API. While these aren't public, any external code calling these directly would break. The reorder makes sense semantically (runId + encryptor are conceptually paired), but consider documenting this in the changeset.

  3. _encryptor unused parameter pattern: All 8 functions have _encryptor: Encryptor that is unused. This is expected since the actual wiring happens in #957. However, this means if #979 lands but #957 doesn't (or is delayed), there's dead parameter threading throughout the codebase. A minor code smell but acceptable for a PR stack.

  4. hydrateResourceIO now requires encryptor: In observability.ts, hydrateResourceIO now takes an Encryptor parameter, and all callers pass world. This means the observability layer now has a dependency on the World instance. Previously it was a pure data transformation. This is a reasonable tradeoff for encryption support, but worth noting the coupling increase.

Overall, well-structured interface design. The cross-deployment encryption support via getEncryptorForRun shows good foresight for production scenarios.

Comment thread packages/core/src/runtime/resume-hook.ts
@TooTallNate TooTallNate marked this pull request as ready for review February 9, 2026 23:12
Copilot AI review requested due to automatic review settings February 9, 2026 23:12
@TooTallNate TooTallNate force-pushed the nate/encryptor-interface branch from f2a9138 to 78aa5f9 Compare February 10, 2026 17:42
Copy link
Copy Markdown
Member

@VaguelySerious VaguelySerious left a comment

Choose a reason for hiding this comment

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

One important non-blocking note on the world interface choice

// Resolve encryption key for the new run. Since start() always runs on the
// current deployment, we can use a placeholder runId — the implementation
// will resolve to the local deployment's key regardless.
const encryptionKey = await world.getEncryptionKeyForRun?.(runId);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I feel like this isn't adequately documented in the world interface . If a placeholder "works", then getEncryptionKeyForRun on the world should allow null or something similar and describe how the fallback works.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Updated the comment. The runId is a client-generated ULID that has already been created at that point — it is the actual runId that will be used for the run_created event and key derivation. Clarified the comment to explain this.

Comment thread packages/core/src/runtime/resume-hook.ts
Copy link
Copy Markdown
Contributor

@pranaygp pranaygp left a comment

Choose a reason for hiding this comment

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

Overall this is a clean, well-structured PR that threads the encryption key through the serialization layer in preparation for at-rest encryption. The interface design on World is good — optional method means no breaking changes for existing implementations. A few comments below, mostly nits and one potential performance concern.

const startTime = Date.now();
const stepEncryptionKey =
await world.getEncryptionKeyForRun?.(workflowRunId);
const dehydrated = await dehydrateStepReturnValue(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The encryption key is resolved twice for the same workflowRunId — once at line 301 for hydration and again here for dehydration. This means two calls to world.getEncryptionKeyForRun?.() per step execution.

Since workflowRunId doesn't change between hydration and dehydration, you could resolve it once before both trace blocks and reuse it:

const encryptionKey = await world.getEncryptionKeyForRun?.(workflowRunId);

Not a correctness issue, but for production deployments where getEncryptionKeyForRun might involve a network call (e.g., KMS), this avoids an unnecessary round-trip.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch. Hoisted the getEncryptionKeyForRun call above both trace blocks so it resolves once and is reused for both hydration and dehydration.

export async function resumeHook<T = any>(
tokenOrHook: string | Hook,
payload: T
payload: T,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: _encryptionKey uses the underscore-prefix convention that typically signals an unused parameter, but it is used on lines 106 and 110. Consider renaming to encryptionKeyOverride or just encryptionKey to better communicate intent.

(I see @VaguelySerious flagged this too — just confirming it's still present in the latest revision.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed — renamed to encryptionKeyOverride to properly communicate intent.

runId: string,
_key: Uint8Array | undefined,
ops: Promise<void>[] = [],
global: Record<string, any> = globalThis,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: The JSDoc @param list above (lines 1370-1377) is stale — it still references global before runId and doesn't mention the new _key parameter. Since this is an internal API it's not urgent, but updating the params would help future readers. Same applies to the other 7 function JSDocs.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Updated all 8 function JSDoc blocks with accurate @param descriptions matching the current signatures.

* @returns The per-run AES-256 key, or undefined if encryption is not configured
*/
getEncryptionKeyForRun?(
run: WorkflowRun | string
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The signature accepts WorkflowRun | string but every callsite I see in the diff passes a string (runId). Is there an existing or planned callsite that passes a full WorkflowRun object? If not, simplifying to just string would reduce the interface surface area. If there is a planned use, a brief note in the JSDoc about when to prefer which form would be helpful.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The WorkflowRun form is used by the o11y/CLI path (in the later PRs in the stack) where the run entity is already fetched and provides the deploymentId for cross-deployment key resolution. Updated the JSDoc to document both use cases.

resource: T,
_encryptorResolver?: unknown
): T {
return hydrateResourceIOGeneric(resource as any, getRevivers()) as T;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: The type of _encryptorResolver is unknown here, but EncryptionKeyResolver is defined in the sibling output.ts. For consistency and forward-compat, consider importing and using that type so when encryption is wired into the CLI hydration, the type is already correct.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added a proper EncryptionKeyResolver type definition in hydration.ts matching the one in output.ts, and typed the parameter accordingly.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 18, 2026

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 0.033s (+30.3% 🔺) 1.005s (~) 0.972s 10 1.00x
💻 Local Nitro 0.034s (+5.3% 🔺) 1.005s (~) 0.972s 10 1.03x
💻 Local Next.js (Turbopack) 0.044s (+8.1% 🔺) 1.005s (~) 0.961s 10 1.35x
🌐 Redis Next.js (Turbopack) 0.049s (+4.5%) 1.005s (~) 0.956s 10 1.50x
🐘 Postgres Nitro 0.093s (-18.2% 🟢) 1.010s (~) 0.917s 10 2.85x
🌐 MongoDB Next.js (Turbopack) 0.119s (+33.2% 🔺) 1.007s (~) 0.888s 10 3.65x
🐘 Postgres Express 0.135s (-69.3% 🟢) 1.010s (~) 0.874s 10 4.14x
🐘 Postgres Next.js (Turbopack) 0.373s 1.010s 0.636s 10 11.42x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 0.592s (-7.9% 🟢) 2.119s (-2.4%) 1.527s 10 1.00x
▲ Vercel Next.js (Turbopack) 0.679s (+23.0% 🔺) 2.233s (+20.1% 🔺) 1.553s 10 1.15x
▲ Vercel Express 0.786s (+19.6% 🔺) 2.208s (+1.1%) 1.422s 10 1.33x

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

workflow with 1 step

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Next.js (Turbopack) 1.101s (~) 2.006s (~) 0.904s 10 1.00x
💻 Local Express 1.107s (+3.3%) 2.006s (~) 0.899s 10 1.00x
🌐 Redis Next.js (Turbopack) 1.109s (~) 2.006s (~) 0.897s 10 1.01x
💻 Local Nitro 1.111s (~) 2.006s (~) 0.895s 10 1.01x
🌐 MongoDB Next.js (Turbopack) 1.308s (~) 2.008s (~) 0.699s 10 1.19x
🐘 Postgres Next.js (Turbopack) 1.752s 2.012s 0.260s 10 1.59x
🐘 Postgres Express 2.376s (+2.5%) 3.014s (~) 0.638s 10 2.16x
🐘 Postgres Nitro 2.469s (-1.1%) 3.015s (~) 0.545s 10 2.24x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 2.192s (-8.7% 🟢) 3.412s (-7.5% 🟢) 1.220s 10 1.00x
▲ Vercel Nitro 2.247s (-4.3%) 3.556s (+3.0%) 1.309s 10 1.02x
▲ Vercel Next.js (Turbopack) 2.424s (+7.5% 🔺) 3.632s (+21.1% 🔺) 1.208s 10 1.11x

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

workflow with 10 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Next.js (Turbopack) 10.718s (-0.5%) 11.023s (~) 0.305s 3 1.00x
🌐 Redis Next.js (Turbopack) 10.735s (~) 11.023s (~) 0.288s 3 1.00x
💻 Local Express 10.834s (+2.7%) 11.022s (~) 0.188s 3 1.01x
💻 Local Nitro 10.848s (~) 11.023s (~) 0.174s 3 1.01x
🌐 MongoDB Next.js (Turbopack) 12.271s (-0.7%) 13.024s (~) 0.752s 3 1.14x
🐘 Postgres Next.js (Turbopack) 15.133s 16.048s 0.915s 2 1.41x
🐘 Postgres Nitro 20.346s (~) 21.061s (~) 0.715s 2 1.90x
🐘 Postgres Express 20.425s (+1.3%) 21.057s (~) 0.632s 2 1.91x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 17.888s (+2.8%) 19.304s (+1.2%) 1.416s 2 1.00x
▲ Vercel Nitro 17.972s (+3.1%) 19.266s (+3.1%) 1.294s 2 1.00x
▲ Vercel Next.js (Turbopack) 18.291s (+1.0%) 19.433s (+3.3%) 1.142s 2 1.02x

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

workflow with 25 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🌐 Redis 🥇 Next.js (Turbopack) 26.892s (~) 27.051s (~) 0.159s 3 1.00x
💻 Local Next.js (Turbopack) 27.224s (~) 28.051s (~) 0.827s 3 1.01x
💻 Local Express 27.483s (+2.8%) 28.051s (+3.7%) 0.569s 3 1.02x
💻 Local Nitro 27.595s (~) 28.054s (~) 0.459s 3 1.03x
🌐 MongoDB Next.js (Turbopack) 30.552s (~) 31.042s (~) 0.490s 2 1.14x
🐘 Postgres Next.js (Turbopack) 37.717s 38.090s 0.373s 2 1.40x
🐘 Postgres Express 50.242s (+0.6%) 51.137s (+2.0%) 0.894s 2 1.87x
🐘 Postgres Nitro 50.412s (~) 51.134s (~) 0.722s 2 1.87x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 267.686s (+541.3% 🔺) 269.508s (+524.6% 🔺) 1.822s 1 1.00x
▲ Vercel Next.js (Turbopack) 308.006s (+609.6% 🔺) 308.595s (+594.7% 🔺) 0.589s 1 1.15x
▲ Vercel Express 340.656s (+694.2% 🔺) 342.158s (+664.6% 🔺) 1.502s 1 1.27x

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

workflow with 50 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🌐 Redis 🥇 Next.js (Turbopack) 54.297s (~) 55.098s (~) 0.801s 2 1.00x
💻 Local Next.js (Turbopack) 56.769s (~) 57.103s (~) 0.335s 2 1.05x
💻 Local Express 57.603s (+3.6%) 58.104s (+3.6%) 0.501s 2 1.06x
💻 Local Nitro 57.664s (+0.7%) 58.106s (~) 0.442s 2 1.06x
🌐 MongoDB Next.js (Turbopack) 60.971s (~) 61.564s (~) 0.593s 2 1.12x
🐘 Postgres Next.js (Turbopack) 75.129s 75.668s 0.539s 2 1.38x
🐘 Postgres Nitro 100.396s (~) 101.253s (~) 0.857s 1 1.85x
🐘 Postgres Express 100.513s (+33.4% 🔺) 101.253s (+32.9% 🔺) 0.740s 1 1.85x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 109.978s (+26.1% 🔺) 110.539s (+24.8% 🔺) 0.561s 1 1.00x
▲ Vercel Express 153.347s (+61.6% 🔺) 155.409s (+62.0% 🔺) 2.062s 1 1.39x
▲ Vercel Next.js (Turbopack) 180.557s (+99.9% 🔺) 182.256s (+98.6% 🔺) 1.699s 1 1.64x

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

Promise.all with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🌐 Redis 🥇 Next.js (Turbopack) 1.264s (+1.0%) 2.006s (~) 0.742s 15 1.00x
💻 Local Next.js (Turbopack) 1.403s (-1.7%) 2.005s (~) 0.602s 15 1.11x
💻 Local Nitro 1.428s (+0.9%) 2.005s (~) 0.577s 15 1.13x
💻 Local Express 1.436s (+3.6%) 2.006s (~) 0.570s 15 1.14x
🐘 Postgres Next.js (Turbopack) 1.839s 2.011s 0.171s 15 1.46x
🌐 MongoDB Next.js (Turbopack) 2.165s (+1.0%) 3.009s (~) 0.844s 10 1.71x
🐘 Postgres Nitro 2.449s (+7.0% 🔺) 3.015s (~) 0.566s 10 1.94x
🐘 Postgres Express 2.545s (+22.0% 🔺) 3.015s (~) 0.470s 10 2.01x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 2.399s (+2.1%) 3.671s (-2.1%) 1.272s 9 1.00x
▲ Vercel Nitro 2.529s (-8.0% 🟢) 3.975s (-1.5%) 1.446s 8 1.05x
▲ Vercel Next.js (Turbopack) 2.982s (+36.3% 🔺) 4.135s (+40.1% 🔺) 1.153s 9 1.24x

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

Promise.all with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🌐 Redis 🥇 Next.js (Turbopack) 2.513s (+0.7%) 3.008s (~) 0.495s 10 1.00x
💻 Local Next.js (Turbopack) 2.519s (-2.9%) 3.007s (~) 0.488s 10 1.00x
💻 Local Express 2.652s (+15.4% 🔺) 3.007s (~) 0.356s 10 1.06x
💻 Local Nitro 2.694s (+3.3%) 3.007s (~) 0.313s 10 1.07x
🌐 MongoDB Next.js (Turbopack) 4.756s (~) 5.178s (~) 0.421s 6 1.89x
🐘 Postgres Nitro 7.753s (-8.4% 🟢) 8.277s (-5.7% 🟢) 0.524s 4 3.08x
🐘 Postgres Express 8.854s (-3.3%) 9.282s (-7.4% 🟢) 0.429s 4 3.52x
🐘 Postgres Next.js (Turbopack) 11.562s 12.036s 0.474s 3 4.60x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.607s (+1.1%) 3.592s (-2.7%) 0.985s 9 1.00x
▲ Vercel Express 2.855s (+13.2% 🔺) 4.071s (+11.1% 🔺) 1.216s 8 1.10x
▲ Vercel Next.js (Turbopack) 4.044s (+50.6% 🔺) 5.073s (+44.3% 🔺) 1.028s 6 1.55x

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

Promise.all with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🌐 Redis 🥇 Next.js (Turbopack) 4.087s (+1.4%) 4.583s (-5.8% 🟢) 0.495s 7 1.00x
💻 Local Express 7.710s (+23.6% 🔺) 8.269s (+17.9% 🔺) 0.559s 4 1.89x
💻 Local Next.js (Turbopack) 7.799s (+3.0%) 8.268s (+3.2%) 0.469s 4 1.91x
💻 Local Nitro 7.939s (+5.1% 🔺) 8.269s (+3.1%) 0.330s 4 1.94x
🌐 MongoDB Next.js (Turbopack) 9.844s (-1.1%) 10.354s (-3.1%) 0.511s 3 2.41x
🐘 Postgres Nitro 44.604s (-3.7%) 45.117s (-4.2%) 0.513s 1 10.91x
🐘 Postgres Express 46.785s (+1.2%) 47.123s (~) 0.338s 1 11.45x
🐘 Postgres Next.js (Turbopack) 56.922s 57.128s 0.206s 1 13.93x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 6.126s (+68.3% 🔺) 7.719s (+60.2% 🔺) 1.593s 4 1.00x
▲ Vercel Express 6.926s (+136.3% 🔺) 8.296s (+112.1% 🔺) 1.370s 4 1.13x
▲ Vercel Nitro 7.461s (+17.6% 🔺) 8.938s (+13.0% 🔺) 1.477s 4 1.22x

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

Promise.race with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🌐 Redis 🥇 Next.js (Turbopack) 1.235s (~) 2.006s (~) 0.771s 15 1.00x
💻 Local Express 1.437s (+5.7% 🔺) 2.005s (~) 0.567s 15 1.16x
💻 Local Next.js (Turbopack) 1.449s (-0.9%) 2.006s (~) 0.557s 15 1.17x
💻 Local Nitro 1.452s (+3.6%) 2.005s (~) 0.552s 15 1.18x
🐘 Postgres Express 1.865s (-14.4% 🟢) 2.155s (-19.6% 🟢) 0.290s 14 1.51x
🐘 Postgres Nitro 1.900s (-7.3% 🟢) 2.514s (-11.2% 🟢) 0.613s 12 1.54x
🐘 Postgres Next.js (Turbopack) 2.029s 2.512s 0.483s 12 1.64x
🌐 MongoDB Next.js (Turbopack) 2.162s (+0.9%) 3.008s (~) 0.846s 10 1.75x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 2.274s (~) 3.438s (-3.1%) 1.165s 9 1.00x
▲ Vercel Nitro 2.326s (-63.3% 🟢) 3.387s (-56.4% 🟢) 1.061s 9 1.02x
▲ Vercel Next.js (Turbopack) 3.186s (+47.4% 🔺) 4.475s (+49.4% 🔺) 1.288s 7 1.40x

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

Promise.race with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🌐 Redis 🥇 Next.js (Turbopack) 2.486s (~) 3.008s (~) 0.522s 10 1.00x
💻 Local Next.js (Turbopack) 2.735s (-0.7%) 3.108s (~) 0.373s 10 1.10x
💻 Local Nitro 2.847s (+4.5%) 3.008s (~) 0.161s 10 1.15x
💻 Local Express 2.873s (+21.6% 🔺) 3.108s (+3.3%) 0.234s 10 1.16x
🌐 MongoDB Next.js (Turbopack) 4.799s (+2.2%) 5.346s (+3.3%) 0.547s 6 1.93x
🐘 Postgres Express 9.113s (-25.8% 🟢) 9.783s (-23.0% 🟢) 0.670s 4 3.67x
🐘 Postgres Nitro 10.072s (+0.7%) 10.701s (~) 0.629s 3 4.05x
🐘 Postgres Next.js (Turbopack) 12.955s 13.367s 0.411s 3 5.21x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 4.322s (+32.7% 🔺) 5.582s (+41.7% 🔺) 1.261s 6 1.00x
▲ Vercel Nitro 4.923s (+93.0% 🔺) 6.711s (+74.7% 🔺) 1.788s 5 1.14x
▲ Vercel Express 5.057s (+36.1% 🔺) 6.144s (+31.4% 🔺) 1.087s 5 1.17x

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

Promise.race with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🌐 Redis 🥇 Next.js (Turbopack) 4.026s (-1.7%) 4.439s (-8.8% 🟢) 0.413s 7 1.00x
💻 Local Next.js (Turbopack) 7.090s (-16.7% 🟢) 7.518s (-16.7% 🟢) 0.428s 4 1.76x
💻 Local Nitro 8.444s (+3.0%) 9.025s (+2.9%) 0.580s 4 2.10x
💻 Local Express 8.531s (+26.3% 🔺) 9.017s (+28.6% 🔺) 0.486s 4 2.12x
🌐 MongoDB Next.js (Turbopack) 10.310s (+4.7%) 10.688s (+3.3%) 0.378s 3 2.56x
🐘 Postgres Express 47.451s (-10.1% 🟢) 48.137s (-9.4% 🟢) 0.686s 1 11.79x
🐘 Postgres Nitro 50.241s (-0.8%) 51.136s (~) 0.895s 1 12.48x
🐘 Postgres Next.js (Turbopack) 58.909s 59.121s 0.212s 1 14.63x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 3.881s (+18.6% 🔺) 5.079s (+15.2% 🔺) 1.197s 6 1.00x
▲ Vercel Next.js (Turbopack) 4.436s (-38.1% 🟢) 5.664s (-28.0% 🟢) 1.228s 6 1.14x
▲ Vercel Express 5.385s (-30.6% 🟢) 7.031s (-25.8% 🟢) 1.646s 5 1.39x

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

Stream Benchmarks (includes TTFB metrics)
workflow with stream

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Next.js (Turbopack) 0.143s (-5.8% 🟢) 1.001s (~) 0.011s (-6.6% 🟢) 1.016s (~) 0.873s 10 1.00x
🌐 Redis Next.js (Turbopack) 0.151s (+4.6%) 1.000s (~) 0.001s (~) 1.007s (~) 0.856s 10 1.06x
💻 Local Express 0.176s (+57.0% 🔺) 1.002s (~) 0.012s (+29.7% 🔺) 1.017s (~) 0.842s 10 1.23x
💻 Local Nitro 0.181s (+5.3% 🔺) 1.003s (~) 0.012s (+10.9% 🔺) 1.018s (~) 0.837s 10 1.27x
🌐 MongoDB Next.js (Turbopack) 0.496s (+3.0%) 0.951s (-1.6%) 0.002s (+7.1% 🔺) 1.009s (~) 0.513s 10 3.47x
🐘 Postgres Next.js (Turbopack) 0.747s 0.793s 0.001s 1.011s 0.263s 10 5.23x
🐘 Postgres Express 2.148s (+88.0% 🔺) 2.897s (+52.5% 🔺) 0.001s (+8.3% 🔺) 3.015s (+49.7% 🔺) 0.867s 10 15.02x
🐘 Postgres Nitro 2.432s (+2.4%) 2.611s (-2.2%) 0.002s (+33.3% 🔺) 3.015s (~) 0.584s 10 17.00x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 1.916s (-15.6% 🟢) 2.837s (-4.0%) 0.844s (+378.3% 🔺) 4.269s (+14.5% 🔺) 2.353s 10 1.00x
▲ Vercel Express 2.351s (+19.3% 🔺) 3.372s (+30.0% 🔺) 0.577s (+6.3% 🔺) 4.713s (+25.6% 🔺) 2.362s 10 1.23x
▲ Vercel Next.js (Turbopack) 2.548s (+36.5% 🔺) 2.760s (+5.4% 🔺) 0.612s (-1.5%) 4.391s (+18.2% 🔺) 1.843s 10 1.33x

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

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World 🥇 Fastest Framework Wins
💻 Local Next.js (Turbopack) 9/12
🐘 Postgres Next.js (Turbopack) 6/12
▲ Vercel Nitro 6/12
Fastest World by Framework

Winner determined by most benchmark wins

Framework 🥇 Fastest World Wins
Express 💻 Local 10/12
Next.js (Turbopack) 🌐 Redis 8/12
Nitro 💻 Local 9/12
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants