Serialization

All function arguments and return values passed between workflow and step functions must be serializable. Workflow DevKit uses a custom serialization system built on top of devalue. This system supports standard JSON types, as well as a few additional popular Web API types.

The serialization system ensures that all data persists correctly across workflow suspensions and resumptions, enabling durable execution.

Supported Serializable Types

The following types can be serialized and passed through workflow functions:

Standard JSON Types:

  • string
  • number
  • boolean
  • null
  • Arrays of serializable values
  • Objects with string keys and serializable values

Extended Types:

  • undefined
  • bigint
  • ArrayBuffer
  • BigInt64Array, BigUint64Array
  • Date
  • Float32Array, Float64Array
  • Int8Array, Int16Array, Int32Array
  • Map<Serializable, Serializable>
  • RegExp
  • Set<Serializable>
  • URL
  • URLSearchParams
  • Uint8Array, Uint8ClampedArray, Uint16Array, Uint32Array

Notable:

These types have special handling and are explained in detail in the sections below.

  • Headers
  • Request
  • Response
  • ReadableStream<Serializable>
  • WritableStream<Serializable>

Streaming

ReadableStream and WritableStream are supported as serializable types with special handling. These streams can be passed between workflow and step functions while maintaining their streaming capabilities.

For complete information about using streams in workflows, including patterns for AI streaming, file processing, and progress updates, see the Streaming Guide.

Request & Response

The Web API Request and Response APIs are supported by the serialization system, and can be passed around between workflow and step functions similarly to other data types.

As a convenience, these two APIs are treated slightly differently when used within a workflow function: calling the text() / json() / arrayBuffer() instance methods is automatically treated as a step function invocation. This allows you to consume the body directly in the workflow context while maintaining proper serialization and caching.

For example, consider how receiving a webhook request provides the entire Request instance into the workflow context. You may consume the body of that request directly in the workflow, which will be cached as a step result for future resumptions of the workflow:

workflows/webhook.ts
import { createWebhook } from "workflow";

export async function handleWebhookWorkflow() {
  "use workflow";

  const webhook = createWebhook();
  const request = await webhook;

  // The body of the request will only be consumed once
  const body = await request.json(); 

  // …
}

Using fetch in Workflows

Because Request and Response are serializable, Workflow DevKit provides a fetch function that can be used directly in workflow functions:

workflows/api-call.ts
import { fetch } from "workflow"; 

export async function apiWorkflow() {
  "use workflow";

  // fetch can be called directly in workflows
  const response = await fetch("https://api.example.com/data"); 
  const data = await response.json();

  return data;
}

The implementation is straightforward - fetch from workflow is a step function that wraps the standard fetch:

Implementation
export async function fetch(...args: Parameters<typeof globalThis.fetch>) {
  "use step";
  return globalThis.fetch(...args);
}

This allows you to make HTTP requests directly in workflow functions while maintaining deterministic replay behavior through automatic caching.

Custom Class Serialization

By default, custom class instances cannot be serialized because the serialization system doesn't know how to reconstruct them. You can make your classes serializable by implementing two static methods using special symbols from the @workflow/serde package.

Basic Example

workflows/custom-class.ts
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde";

class Point {
  constructor(
    public x: number,
    public y: number
  ) {}

  // Define how to serialize an instance to plain data
  static [WORKFLOW_SERIALIZE](instance: Point) {
    return { x: instance.x, y: instance.y };
  }

  // Define how to reconstruct an instance from plain data
  static [WORKFLOW_DESERIALIZE](data: { x: number; y: number }) {
    return new Point(data.x, data.y);
  }
}

Once you've implemented these methods, instances of your class can be passed between workflow and step functions:

workflows/geometry.ts
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde";

class Point {
  constructor(public x: number, public y: number) {}
  static [WORKFLOW_SERIALIZE](instance: Point) {
    return { x: instance.x, y: instance.y };
  }
  static [WORKFLOW_DESERIALIZE](data: { x: number; y: number }) {
    return new Point(data.x, data.y);
  }
}

export async function geometryWorkflow() {
  "use workflow";

  const point = new Point(10, 20);
  const doubled = await doublePoint(point); // Point is serialized automatically

  console.log(doubled.x, doubled.y); // 20, 40
  return doubled;
}

async function doublePoint(point: Point) {
  "use step";
  return new Point(point.x * 2, point.y * 2); // Returns a new Point instance
}

How It Works

  1. WORKFLOW_SERIALIZE: A static method that receives a class instance and returns serializable data (primitives, plain objects, arrays, etc.)

  2. WORKFLOW_DESERIALIZE: A static method that receives the serialized data and returns a new class instance

  3. Automatic Registration: The SWC compiler plugin automatically detects classes that implement these symbols and registers them for serialization

Requirements

Both methods must be implemented as static methods on the class. Instance methods are not supported.

  • The data returned by WORKFLOW_SERIALIZE must itself be serializable (primitives, plain objects, arrays, or other serializable types including other custom classes)
  • Both symbols must be implemented together - a class with only one will not be serializable

Complex Example

Custom serialization works with nested objects and other serializable types:

workflows/complex-class.ts
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde";

class Order {
  constructor(
    public id: string,
    public items: Map<string, number>,
    public createdAt: Date
  ) {}

  static [WORKFLOW_SERIALIZE](instance: Order) {
    return {
      id: instance.id,
      items: instance.items, // Map is serializable
      createdAt: instance.createdAt, // Date is serializable
    };
  }

  static [WORKFLOW_DESERIALIZE](data: {
    id: string;
    items: Map<string, number>;
    createdAt: Date;
  }) {
    return new Order(data.id, data.items, data.createdAt);
  }
}

Pass-by-Value Semantics

Parameters are passed by value, not by reference. Steps receive deserialized copies of data. Mutations inside a step won't affect the original in the workflow.

Incorrect:

workflows/incorrect-mutation.ts
export async function updateUserWorkflow(userId: string) {
  "use workflow";

  let user = { id: userId, name: "John", email: "john@example.com" };
  await updateUserStep(user);

  // user.email is still "john@example.com"
  console.log(user.email); 
}

async function updateUserStep(user: { id: string; name: string; email: string }) {
  "use step";
  user.email = "newemail@example.com"; // Changes are lost
}

Correct - return the modified data:

workflows/correct-mutation.ts
export async function updateUserWorkflow(userId: string) {
  "use workflow";

  let user = { id: userId, name: "John", email: "john@example.com" };
  user = await updateUserStep(user); // Reassign the return value

  console.log(user.email); // "newemail@example.com"
}

async function updateUserStep(user: { id: string; name: string; email: string }) {
  "use step";
  user.email = "newemail@example.com";
  return user; 
}