Understanding Directives
This guide explores how JavaScript directives enable the Workflow SDK's execution model. For getting started with workflows, see the getting started guides for your framework.
The Workflow SDK uses JavaScript directives ("use workflow" and "use step") as the foundation for its durable execution model. Directives provide the compile-time semantic boundary necessary for workflows to suspend, resume, and maintain deterministic behavior across replays.
This page explores how directives enable this execution model and the design principles that led us here.
To understand how directives work, let's first understand what workflows and steps are in the Workflow SDK.
Workflows and steps primer
The Workflow SDK has two types of functions:
Step functions are side-effecting operations with full Node.js runtime access. Think of them like named RPC calls - they run once, their result is persisted, and they can be retried on failure:
Workflow functions are deterministic orchestrators that coordinate steps. They must be pure functions - during replay, the same step results always produce the same output. This is necessary because workflows resume by replaying their code from the beginning using cached step results; non-deterministic logic would break resumption. They run in a sandboxed environment without direct Node.js access:
The key insight: Workflows resume from suspension by replaying their code using cached step results from the event log. When a step like await fetchUserData(userId) is called:
- If already executed: Returns the cached result immediately from the event log
- If not yet executed: Suspends the workflow, executes the step (usually inline in the same invocation, or through the queue when the invocation runs out of inline budget or nears its timeout), then resumes with the result
This replay mechanism requires deterministic code. If Math.random() weren't seeded, the first execution might return 0.7 (sending the email) but replay might return 0.3 (skipping it), thus breaking resumption. The Workflow SDK sandbox provides seeded Math.random() and Date to ensure consistent behavior across replays.
For a deeper dive into workflows and steps, see Workflows and Steps.
The core challenge
This execution model provides durability: workflows can suspend for days, survive restarts, and resume from any point. However, it also requires a semantic boundary in the code that tells the compiler, runtime, and developer that execution semantics have changed.
The challenge: how do we mark this boundary in a way that:
- Enables compile-time transformations and validation
- Prevents accidental use of non-deterministic APIs
- Allows static analysis of workflow structure
- Feels natural to JavaScript developers
Directives have prior uses, and we considered several alternatives:
Prior art on directives
JavaScript directives have precedent for changing execution semantics within a defined scope:
"use strict"(introduced in ECMAScript 5 in 2009, TC39-standardized) changes language rules to make the runtime faster, safer, and more predictable."use client"and"use server"(introduced by React Server Components) define an explicit boundary of "where" code gets executed - client-side browser JavaScript vs server-side Node.js."use workflow"(introduced by the Workflow SDK) defines both "where" code runs (in a deterministic sandbox environment) and "how" it runs (deterministic, resumable, sandboxed execution semantics).
Directives provide a build-time contract.
When the Workflow SDK sees "use workflow", it:
- Bundles the workflow and its dependencies into code that can be run in a sandbox
- Restricts access to Node.js APIs in that sandbox
- Enables future functionality and optimizations only possible with a build tool
- For instance, the bundled workflow code can be statically analyzed to generate UML diagrams/visualizations of the workflow
In addition to being important to the compiler, "use workflow" explicitly signals to the developer that you are entering a different execution mode.
The "use workflow" directive is also used by the Language Server Plugin shipped with Workflow SDK to provide IntelliSense to your IDE. Check the getting started instructions for your framework for details on setting up the Language Server Plugin.
But we didn't get here immediately. This took some discovery to arrive at:
Alternatives we explored
Before settling on directives, we prototyped several other approaches. Each had limitations that made them unsuitable for production use.
Runtime-only "Suspense" API
Our first proof of concept used a wrapper-based API without a build step:
This implementation used "throwing promises" (similar to early React Suspense) to suspend execution. When a step needed to run, we'd throw a promise, catch it at the workflow boundary, execute the step, and replay the workflow with the result.
The problems:
1. Every side effect needed wrapping
Any operation that could produce non-deterministic results had to be wrapped in run():
This was verbose and developers could forget it. If a developer forgot to wrap something like Date.now(), it led to unstable runtime behavior.
For example:
2. Closures and mutation became unpredictable
Variables captured in closures would behave unexpectedly when steps mutated them:
The workflow function would replay multiple times, but mutations inside run() callbacks wouldn't persist across replays. This made reasoning about state nearly impossible.
3. Error handling broke down
Since we used thrown promises for control flow, try/catch blocks became unreliable:
Generator-based API
We explored using generators for explicit suspension points, inspired by libraries like Effect.ts:
We're big fans of Effect.ts and the power of generator-based APIs for effect management. However, for workflow orchestration specifically, we found the syntax too heavy for developers unfamiliar with generators.
The problems:
1. Syntax felt more like a DSL than JavaScript
Generators require a custom mental model that differs from familiar async/await patterns. The yield* syntax and generator delegation were unfamiliar to many developers:
Complex workflows became particularly verbose and difficult to read:
2. Still no compile-time sandboxing
Like the runtime-only approach, generators couldn't prevent non-deterministic code:
The generator syntax addressed suspension but didn't solve the fundamental sandboxing problem.
File system-based conventions
We explored using file system conventions to identify workflows and steps, similar to how modern frameworks handle routing (Next.js, Hono, Nitro, SvelteKit):
With this approach, any function in the workflows/ directory would be transformed as a workflow, and any function in steps/ would be a step. File locations would replace directives.
Why this could work:
- Clear separation of concerns
- Enables compiler transformations based on file path
- Familiar pattern for developers used to file-based routing, for example Next.js
Why we moved away:
1. Too opinionated for diverse ecosystems
Different frameworks and developers have strong opinions about project structure. Forcing a specific directory layout often caused conflicts across various conventions, especially in existing codebases.
2. No support for publishable, reusable functions
We want developers to be able to publish libraries to npm that include step and workflow directives. Ideally, logic that is isomorphic so it could be used with and without Workflow SDK. File system conventions made this impossible.
3. Migration and code reuse became difficult
Migrating existing code required moving files and restructuring projects rather than adding a single line.
The directive approach solved all these issues: it works in any project structure, supports code reuse and migration, enables npm packages, and allows functions to adapt to their execution context.
Decorators
We considered decorators, but they presented technical and ergonomic challenges.
Decorators are not-yet-standard and class-focused
Decorators are not yet a standard syntax (TC39 proposal) and they currently only work with classes. A class decorator approach could look like this:
This approach requires:
- Writing class boilerplate with static methods
- Storing/mutating class properties was not obvious (similar closure/mutation issues as the runtime-only approach)
- Class-based syntax that doesn't feel "JavaScript native" to developers used to functional patterns
As the JavaScript ecosystem has moved toward function-forward programming (exemplified by React's shift from class components to functions and hooks), requiring developers to use classes felt like a step backward and also didn't match our own personal taste as authors of the SDK.
The core problem: Presents workflows as regular runtime code
While decorators can be handled at compile-time with build tool support, they present workflow functions as if they were regular, composable JavaScript code, when they're actually compile-time declarations that need special handling.
See the Macro Wrapper section below for a deeper dive into why this approach breaks down with concrete examples.
Macro wrapper approach
We also explored compile-time macro approaches - using a compiler to transform wrapper functions or decorators into directive-based code:
The compiler could transform both to be equivalent to Workflow SDK's directive approach:
The benefit is that macros could enforce types and provide "Go To Definition" or other LSP features without additional configuration.
However, the core problem remains: Workflows aren't runtime values
The fundamental issue is that both wrappers and decorators make workflows appear to be first-class, runtime values when they're actually compile-time declarations. This mismatch between syntax and semantics creates numerous failure modes.
Concrete examples of how this breaks:
The developer writing processOrder has no visible signal that they're in a deterministic, sandboxed environment. It's also ambiguous whether the retry logic executes inside the workflow or outside, and the actual behavior likely doesn't match developer intuition.
Why the compiler can't catch this:
To detect that processOrder is actually a workflow, the compiler would need whole-program analysis to track that:
withRetryreturns the result ofuseWorkflow- Therefore
processOrder = withRetry(...)is a workflow - The function passed to
withRetrywill execute in a sandboxed context
This level of cross-function analysis is impractical for build tools - it would require analyzing every function call chain in your entire codebase and all dependencies. The compiler can only reliably detect direct useWorkflow calls, not calls hidden behind abstractions.
How directives solve these problems
Directives address all the issues we encountered with previous approaches:
1. Compile-time semantic boundary
The "use workflow" directive tells the compiler to treat this code differently:
2. Build-time validation
The compiler can enforce restrictions before deployment:
In fact, Workflow SDK will throw an error that links to this error page: Node.js module in workflow
3. No closure ambiguity
Steps are transformed into function calls that communicate with the runtime:
Callbacks, however, run inside the workflow sandbox and work as expected:
The callback runs in the workflow sandbox, so closure reads and mutations behave consistently across replays.
4. Natural syntax
Looks and feels like regular JavaScript:
5. Consistent syntax for steps
The "use step" directive maintains consistency. While steps run in the full Node.js runtime and could work without a directive, they need some way to signal to the workflow runtime that they're steps.
We could have used a function wrapper for steps:
Mixing syntaxes felt inconsistent.
An alternative approach we considered was to treat all async function calls as steps by default:
This breaks down because many valid async operations inside workflows aren't steps:
By requiring explicit "use step" directives, developers have fine-grained control over what becomes a durable, retryable step versus what runs inline in the workflow sandbox.
To understand how directives are transformed at compile time, see How the Code Transform Works.
What directives enable
Because "use workflow" defines a compile-time semantic boundary, we can provide:
Build-Time Validation
The compiler catches invalid patterns before deployment: detects disallowed imports, prevents direct side effects, and validates workflow structure.
Static Analysis
Analyze workflow code without executing it: generate UML or DAG diagrams automatically, provide observability and visualization, and optimize execution paths.
Durable Execution
Workflows can safely suspend and resume: persist execution state between steps, resume from checkpoints after failures or deploys, and scale to zero without losing progress.
Future Optimizations
The semantic boundary enables planned improvements: smaller serialized state for faster checkpoints, smarter scheduling based on workflow structure, and more efficient suspension and resumption.
Directives as a JavaScript pattern
Directives in JavaScript have always been contracts between the developer and the execution environment. "use strict" made this pattern familiar - it's a string literal that changes how code is interpreted.
While JavaScript doesn't yet have first-class support for custom directives (like Rust's #[attribute] or C++'s #pragma), string literal directives are the most pragmatic tool available today.
As TC39 members, we at Vercel are actively working with the standards body and broader ecosystem to explore formal specifications for pragma-like syntax or macro annotations that can express execution semantics.
Closing thoughts
Directives aren't about syntax preference, they're about expressing semantic boundaries. "use workflow" tells the compiler, developer, and runtime that this code is deterministic, resumable, and sandboxed.
This clarity enables the Workflow SDK to provide durable execution with familiar JavaScript patterns, while maintaining the compile-time guarantees necessary for reliable workflow orchestration.