Skip to main content
The Workflow Development Kit 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.

Workflows and Steps Primer

The Workflow DevKit 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:
async function fetchUserData(userId: string) {
  "use step";

  // Full Node.js access: database calls, API requests, file I/O
  const user = await db.query("SELECT * FROM users WHERE id = ?", [userId]);
  return user;
}
Workflow functions are deterministic orchestrators that coordinate steps. They must be pure functions - during replay, the same step results always produce the same output:
export async function onboardUser(userId: string) {
  "use workflow";

  const user = await fetchUserData(userId); // Calls step

  // Non-deterministic code would break replay behavior
  if (Math.random() > 0.5) {
    await sendWelcomeEmail(user);
  }

  return `Onboarded ${user.name}!`;
}
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, enqueues the step for background execution, and resumes later with the result

The Core Challenge

This execution model enables powerful durability features - 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:
  1. Enables compile-time transformations and validation
  2. Prevents accidental use of non-deterministic APIs
  3. Allows static analysis of workflow structure
  4. Feels natural to JavaScript developers

Why Directives?

JavaScript directives have precedent for changing execution semantics within a defined scope:
  • "use strict" (ECMAScript 5, TC39-standardized) changes language rules to make the runtime faster, safer, and more predictable
  • "use client" and "use server" (React Server Components) define an explicit boundary of “where” code gets executed
  • "use workflow" (Workflow DevKit) defines both “where” code runs (in a deterministic sandbox) and “how” it runs (deterministic, resumable, sandboxed execution)
Directives provide a build-time contract. When the Workflow DevKit 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 static analysis to generate UML diagrams/visualizations
  • Signals to the developer that you are entering a different execution mode

How Directives Work

The "use workflow" and "use step" directives trigger different transformations depending on the compilation mode:

Step Mode Transformation

Input:
export async function createUser(email: string) {
  "use step";
  return { id: crypto.randomUUID(), email };
}
Output (step.js bundle):
import { registerStepFunction } from "workflow/internal/private";

export async function createUser(email: string) {
  return { id: crypto.randomUUID(), email };
}

registerStepFunction("step//workflows/user.js//createUser", createUser);
What happens:
  • The "use step" directive is removed
  • The function body remains intact (no transformation)
  • The function is registered with the runtime using a stable ID
  • Step functions run with full Node.js access

Workflow Mode Transformation

Input:
export async function createUser(email: string) {
  "use step";
  return { id: crypto.randomUUID(), email };
}

export async function handleUserSignup(email: string) {
  "use workflow";
  const user = await createUser(email);
  return { userId: user.id };
}
Output (flow.js bundle):
export async function createUser(email: string) {
  return globalThis[Symbol.for("WORKFLOW_USE_STEP")](
    "step//workflows/user.js//createUser"
  )(email);
}

export async function handleUserSignup(email: string) {
  const user = await createUser(email);
  return { userId: user.id };
}
handleUserSignup.workflowId = "workflow//workflows/user.js//handleUserSignup";
What happens:
  • Step function bodies are replaced with calls to globalThis[Symbol.for("WORKFLOW_USE_STEP")]
  • Workflow function bodies remain intact—they execute deterministically during replay
  • The WORKFLOW_USE_STEP symbol is a special runtime hook that:
    1. Checks if the step has already been executed (in the event log)
    2. If yes: Returns the cached result
    3. If no: Triggers a suspension and enqueues the step for background execution

Client Mode Transformation

Input:
export async function handleUserSignup(email: string) {
  "use workflow";
  const user = await createUser(email);
  return { userId: user.id };
}
Output (your application code):
export async function handleUserSignup(email: string) {
  throw new Error("You attempted to execute a workflow directly...");
}
handleUserSignup.workflowId = "workflow//workflows/user.js//handleUserSignup";
What happens:
  • Workflow function bodies are replaced with an error throw
  • The workflowId property is added (same as workflow mode)
  • This prevents accidental direct execution while allowing start() to identify which workflow to launch

The useStep Implementation

The createUseStep function in step.ts shows how step calls work during workflow replay:
export function createUseStep(ctx: WorkflowOrchestratorContext) {
  return function useStep<Args extends Serializable[], Result>(
    stepName: string,
    closureVarsFn?: () => Record<string, Serializable>
  ) {
    const stepFunction = function (...args: Args): Promise<Result> {
      const { promise, resolve, reject } = withResolvers<Result>();
      const correlationId = `step_${ctx.generateUlid()}`;

      // Add to invocation queue
      ctx.invocationsQueue.set(correlationId, {
        type: 'step',
        correlationId,
        stepName,
        args,
      });

      // Subscribe to events for this step
      ctx.eventsConsumer.subscribe((event) => {
        if (event?.correlationId !== correlationId) {
          return EventConsumerResult.NotConsumed;
        }

        if (event.eventType === 'step_completed') {
          // Remove from queue and resolve with cached result
          ctx.invocationsQueue.delete(correlationId);
          resolve(hydrateStepReturnValue(event.eventData.result));
          return EventConsumerResult.Finished;
        }

        if (event.eventType === 'step_failed') {
          // Remove from queue and reject with error
          ctx.invocationsQueue.delete(correlationId);
          reject(new FatalError(event.eventData.error.message));
          return EventConsumerResult.Finished;
        }

        return EventConsumerResult.Consumed;
      });

      return promise;
    };

    return stepFunction;
  };
}
This implementation shows the key mechanism:
  1. Queue the step invocation with a unique correlation ID
  2. Subscribe to the event log looking for matching step events
  3. If step_completed found: Resolve immediately with cached result
  4. If no matching event: The promise never resolves, causing a WorkflowSuspension to be thrown
  5. The suspension handler enqueues the step for actual execution

Determinism and the VM Sandbox

Workflow functions execute in a Node.js VM context with restricted access to non-deterministic APIs. From workflow.ts:
const { context, globalThis: vmGlobalThis, updateTimestamp } = createContext({
  seed: workflowRun.runId,
  fixedTimestamp: +startedAt,
});

// Override non-deterministic APIs
vmGlobalThis.fetch = () => {
  throw new vmGlobalThis.Error(
    'Global "fetch" is unavailable in workflow functions. Use the "fetch" step function from "workflow".'
  );
};

vmGlobalThis.setTimeout = () => {
  throw new WorkflowRuntimeError(
    'Timeout functions are not supported in workflow functions. Use the "sleep" function from "workflow".'
  );
};
The VM provides:
  • Seeded random: Math.random() is deterministic based on the run ID
  • Fixed timestamp: Date.now() returns a consistent value during replay
  • Blocked APIs: Direct access to fetch, setTimeout, file I/O, etc. is prevented
This ensures that replaying a workflow with the same step results always produces the same execution path.

Why Not Other Approaches?

We explored several alternatives before settling on directives:

Generator-Based API

export const myWorkflow = workflow(function*() {
  const message = yield* run(() => step());
  return `${message}!`;
});
Problems:
  • Unfamiliar syntax for most JavaScript developers
  • Can’t use Promise.all directly
  • Still no compile-time sandboxing

File System Conventions

workflows/
  onboarding.ts
steps/
  send-email.ts
Problems:
  • Too opinionated for diverse ecosystems
  • Doesn’t support publishable npm packages
  • Migration requires restructuring projects

Decorators

class MyWorkflow {
  @workflow()
  static async processOrder(orderId: string) {
    // ...
  }
}
Problems:
  • Not yet TC39 standard
  • Requires class boilerplate
  • Makes workflows look like regular runtime code when they’re compile-time declarations

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
  • Static Analysis: Generate UML/DAG diagrams without executing code
  • Durable Execution: Workflows can safely suspend and resume
  • Future Optimizations: Smaller serialized state, smarter scheduling based on workflow structure

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 Development Kit to provide durable execution with familiar JavaScript patterns, while maintaining the compile-time guarantees necessary for reliable workflow orchestration.

Build docs developers (and LLMs) love