---
title: Serialization
description: Understand how workflow data is serialized and persisted across suspensions and resumptions.
type: conceptual
summary: Learn which types can be passed between workflow and step functions.
prerequisites:
  - /docs/foundations/workflows-and-steps
related:
  - /docs/errors/serialization-failed
---

All function arguments and return values passed between workflow and step functions must be serializable. Workflow SDK uses a custom serialization system built on top of [devalue](https://github.com/sveltejs/devalue). This system supports standard JSON types, as well as a few additional popular Web API types.

<Callout type="info">
The serialization system ensures that all data persists correctly across workflow suspensions and resumptions, enabling durable execution.
</Callout>

## 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`
- `DataView`
- `Date`
- `Float16Array`, `Float32Array`, `Float64Array`
- `Int8Array`, `Int16Array`, `Int32Array`
- `Map<Serializable, Serializable>`
- `RegExp`
- `Set<Serializable>`
- `URL`
- `URLSearchParams`
- `Uint8Array`, `Uint8ClampedArray`, `Uint16Array`, `Uint32Array`

**Notable:**

<Callout type="info">
These types have special handling and are explained in detail in the sections below.
</Callout>

- `Headers`
- `Request`
- `Response`
- `ReadableStream<Serializable>`
- `WritableStream<Serializable>`
- `AbortController`
- `AbortSignal`

### Typing serializable values

Import the `Serializable` type from `workflow` to type values that cross a workflow or step boundary:

```typescript title="workflows/record-event.ts" lineNumbers
import type { Serializable } from "workflow";

async function recordEvent(payload: Serializable) {
  "use step";
  // ...
}
```

`Serializable` is a union of the JSON and built-in types listed above. It doesn't include `Request`, `Float16Array`, or classes with [custom serialization](#custom-class-serialization), so type those values directly.

## 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:**

```typescript title="workflows/incorrect-mutation.ts" lineNumbers
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" // [!code highlight]
  console.log(user.email); // [!code highlight]
}

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

**Correct, return the modified data:**

```typescript title="workflows/correct-mutation.ts" lineNumbers
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 // [!code highlight]

  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; // [!code highlight]
}
```

<Callout>
Deeply frozen values are the exception: they come back frozen, and are stored by reference. See [Frozen values](#frozen-values).
</Callout>

**Custom classes:**

- Class instances that implement [`WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE`](#custom-class-serialization)

## Frozen values

State that grows across steps, such as a conversation history passed into every step, is stored again by every step that receives it, so total storage grows with the square of the number of steps. If you pass that state as a **deeply frozen** value, Workflow stores each step's change instead:

- A step input that holds a value Workflow already stored is written as a reference to it, about 80 bytes.
- A step output is written as its change from the stored value it was made from, for example "the previous history plus two messages".

Workflow finds what changed by identity. A frozen object can't change, so an object that appears in both versions is unchanged and needs no comparison.

The easiest way to make deeply frozen values is [immer](https://immerjs.github.io/immer/)'s `produce`, which freezes its results and shares every unchanged object with the previous value:

```typescript lineNumbers
import { produce } from "immer";

type Message = { role: "user" | "assistant"; content: string };
declare function reply(history: readonly Message[], text: string): Promise<string>; // @setup
declare const messages: AsyncIterable<{ text: string }>; // @setup

async function turn(history: readonly Message[], text: string) {
  "use step";
  const answer = await reply(history, text);
  return produce(history, (draft) => {
    draft.push({ role: "user", content: text }, { role: "assistant", content: answer });
  });
}

export async function chat() {
  "use workflow";
  let history: readonly Message[] = Object.freeze([]);
  for await (const { text } of messages) {
    history = await turn(history, text); // each input is a reference, each output a change
  }
}
```

Without immer, freeze every object you create, and reuse the frozen objects you didn't change:

```typescript lineNumbers
type Message = { role: "user" | "assistant"; content: string };
declare const history: readonly Message[]; // @setup
declare const text: string; // @setup

const next = Object.freeze([
  ...history, // unchanged messages, already frozen, reused as they are
  Object.freeze({ role: "user" as const, content: text }),
]);
```

### Rules

- **Frozen values come back frozen.** Changing one in place throws a `TypeError`. Make the next version with `produce` (or a new frozen copy) instead.
- **`Date`, `Map`, `Set` and byte arrays are trusted.** Inside a frozen value they are treated as unchanging, even though JavaScript lets you change a `Date` or a `Map` after freezing. Change them only inside `produce` (see the note below).
- **Only deeply frozen plain data counts.** A value that isn't frozen, is only frozen at the top, or holds a class instance or another custom type, including inside a `Map` or `Set`, is stored in full.
- **Keep immer's auto-freeze on.** immer freezes what `produce` returns by default. With `setAutoFreeze(false)` its results aren't frozen, so they're stored in full every time: still correct, but the savings are gone. Turning it off is also how to opt a codebase out of storing values by reference.
- **A new value is linked by the objects it shares.** A value whose objects are all new, for example one made only of numbers and strings, is stored in full each time. That's fine for small values.
- **Some payloads are always stored in full:** run inputs, including values passed to another run through `start()`, workflow return values (so `run.returnValue` reads them as usual), and hook payloads.
- **Parallel steps.** Passing a frozen value to several parallel steps writes, in each step's input, a small reference if a step returned it unchanged, or only its change if you made the next version in workflow code with `produce` or `Object.freeze`. A frozen value built entirely in workflow code, sharing no objects with a step's output, is written in full to each input, because one step's input can't refer to another's.
- **A value is written whole again from time to time:** once its changes since the last whole copy pass half its size (for growing state, each time it doubles), or after 1,000 changes. That bounds how much a replay has to apply to about the value's own size.
- With `WORKFLOW_VM=quickjs`, values are stored in full, because that VM can't read references yet.

<Callout type="warn">
`Object.freeze` doesn't stop a `Map`, `Set` or `Date` from changing through its own methods (`map.set()`, `set.add()`, `date.setTime()`), and byte arrays can't be frozen at all. immer's `produce` makes the Maps and Sets it returns throw on those methods, but `Object.freeze` doesn't. Changing a `Date`, `Map`, `Set` or byte array in place inside a frozen value, for example with `date.setTime()`, `map.set()` or `bytes[0] = 1`, isn't recorded. A step that runs in the same invocation may still see the change, but a retried or queued step, and every replay, rebuilds the value from the stored records and sees the original. Make the change inside `produce` instead. Immer drafts a `Map` or `Set` once you call its `enableMapSet()`, and copies it when you change it. It doesn't draft a `Date` or a byte array, so replace those with new ones:

```typescript
import { enableMapSet, produce } from "immer";
declare const state: { updatedAt: Date; tags: ReadonlySet<string> }; // @setup

enableMapSet(); // once, before producing state that holds a Map or Set

const next = produce(state, (draft) => {
  draft.updatedAt = new Date(); // not draft.updatedAt.setTime(Date.now())
  draft.tags.add("urgent"); // immer copies the Set
});
```
</Callout>

## 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](/docs/foundations/streaming).

## Request & response

The Web API [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) and [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/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:

```typescript title="workflows/webhook.ts" lineNumbers
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 // [!code highlight]
  const body = await request.json(); // [!code highlight]

  // …
}
```

### Using `fetch` in workflows

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

```typescript title="workflows/api-call.ts" lineNumbers
import { fetch } from "workflow"; // [!code highlight]

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

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

  return data;
}
```

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

```typescript title="Implementation" lineNumbers
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.

## AbortController & AbortSignal

`AbortController` and `AbortSignal` are serializable types that enable cooperative cancellation across workflow and step boundaries. Inside a workflow function, `new AbortController()` creates a durable controller that works across suspensions and step boundaries:

```typescript lineNumbers
import { sleep } from "workflow";

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

  const controller = new AbortController(); // [!code highlight]

  const result = await Promise.race([
    fetchData(controller.signal), // [!code highlight]
    sleep("10s").then(() => null),
  ]);

  if (result === null) {
    controller.abort(); // [!code highlight]
  }

  return result;
}

async function fetchData(signal: AbortSignal) {
  "use step";
  const response = await fetch("https://api.example.com/data", { signal });
  return response.json();
}
```

For usage patterns including timeouts, parallel cancellation, user-triggered cancellation, and run cancellation, see the [Cancellation Guide](/docs/foundations/cancellation). For details on the hook and stream backing that makes this work, see [How Cancellation Works](/docs/how-it-works/cancellation).

## 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

{/* @expect-error:2351 */}

```typescript title="workflows/custom-class.ts" lineNumbers
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde"; // [!code highlight]

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

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

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

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

{/* @expect-error:2351 */}

```typescript title="workflows/geometry.ts" lineNumbers
import { Point } from "./custom-class";

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

  const point = new Point(10, 20);
  // Point is serialized automatically
  const doubled = await doublePoint(point); // [!code highlight]

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

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

### 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. Each class receives a deterministic `classId` derived from its file path and class name, and is registered into the global `Symbol.for("workflow-class-registry")` registry at build time. No manual registration step is required

### Requirements

<Callout type="warn">
`WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE` must be implemented as **static** methods on the class. Defining them as instance methods is not supported.
</Callout>

- The data returned by `WORKFLOW_SERIALIZE` must itself be serializable (see [Supported Serializable Types](#supported-serializable-types))
- Both symbols must be implemented together - a class with only one will not be serializable

<Callout type="warn">
The `WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE` methods run inside the workflow context and are subject to the same constraints as `"use workflow"` functions. This means:
- No Node.js-specific APIs (like `fs`, `path`, `crypto`, etc.)
- No non-deterministic operations (like `Math.random()` or `Date.now()`)
- No external network calls

Keep these methods focused on data transformation only.
</Callout>

### Instance methods as steps

In practice, many classes have methods that need Node.js APIs, perform network calls, or interact with databases, operations that are not allowed in the `"use workflow"` execution context. You can make these methods workflow-compatible by adding `"use step"` to them. The SWC compiler will strip the method bodies from the workflow bundle and replace them with proxy functions that invoke the method as a step, with full Node.js runtime access. The `this` context (the class instance) is automatically serialized and deserialized across the workflow/step boundary.

This requires the class to implement `WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE`, so that the instance can be passed to the step execution context.

{/* @expect-error:2351 */}

```typescript title="workflows/order.ts" lineNumbers
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde"; // [!code highlight]
import { db } from "../lib/db";

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

  // Custom serialization: data must be serializable types
  static [WORKFLOW_SERIALIZE](instance: Order) { // [!code highlight]
    return { // [!code highlight]
      id: instance.id, // [!code highlight]
      items: instance.items, // Map is serializable // [!code highlight]
      createdAt: instance.createdAt, // Date is serializable // [!code highlight]
    }; // [!code highlight]
  } // [!code highlight]

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

  // Methods without "use step" run in the workflow context
  // and must follow the same constraints as workflow functions
  total(): number {
    let sum = 0;
    for (const quantity of this.items.values()) {
      sum += quantity;
    }
    return sum;
  }

  // Instance methods with "use step" run as step functions
  // with full Node.js access; `this` is automatically serialized
  async save(): Promise<void> {
    "use step"; // [!code highlight]
    await db.orders.insert({ // [!code highlight]
      id: this.id, // [!code highlight]
      items: Object.fromEntries(this.items), // [!code highlight]
      createdAt: this.createdAt, // [!code highlight]
    }); // [!code highlight]
  }

  async sendConfirmation(email: string): Promise<string> {
    "use step"; // [!code highlight]
    const res = await fetch("https://api.example.com/email", { // [!code highlight]
      method: "POST", // [!code highlight]
      body: JSON.stringify({ // [!code highlight]
        to: email, // [!code highlight]
        orderId: this.id, // [!code highlight]
        itemCount: this.items.size, // [!code highlight]
      }), // [!code highlight]
    }); // [!code highlight]
    const { messageId } = await res.json();
    return messageId;
  }
}
```

The class can then be used naturally inside a workflow function. Instance methods marked with `"use step"` are each executed as a step, with automatic caching, retry semantics, and full Node.js runtime access. Methods _without_ `"use step"` run directly in the workflow context, so they must follow the same constraints as workflow functions:

{/* @expect-error:2693 */}

```typescript title="workflows/process-order.ts" lineNumbers
export async function processOrderWorkflow(
  orderId: string,
  items: Map<string, number>,
  email: string
) {
  "use workflow";

  const order = new Order(orderId, items, new Date()); // [!code highlight]

  // Runs in the workflow context; no "use step" needed
  const itemCount = order.total(); // [!code highlight]

  // Each "use step" instance method call runs as a separate step
  await order.save(); // [!code highlight]
  const messageId = await order.sendConfirmation(email); // [!code highlight]

  return { orderId, itemCount, messageId };
}
```

[Pass-by-value semantics](#pass-by-value-semantics) also apply to the `this` context of `"use step"` instance methods. Modifying instance properties inside a step method will not affect the original instance in the workflow. If you need to update instance state, return `this` from the step method and re-assign the variable in the workflow:

{/* @expect-error:2351 */}

```typescript title="workflows/order.ts" lineNumbers
export class Order {
  // ...

  async addItem(name: string, quantity: number): Promise<Order> {
    "use step";
    this.items.set(name, quantity);
    return this; // [!code highlight]
  }
}
```

{/* @expect-error:2693,2552,2304 */}

```typescript title="workflows/process-order.ts" lineNumbers
export async function processOrderWorkflow() {
  "use workflow";

  let order = new Order(orderId, items, new Date());

  // Re-assign to capture the updated instance
  order = await order.addItem("Widget", 3); // [!code highlight]
}
```
