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 (with special handling):
-
undefined -
bigint -
ArrayBuffer -
BigInt64Array,BigUint64Array -
Date -
Float32Array,Float64Array -
Headers -
Int8Array,Int16Array,Int32Array -
Map<Serializable, Serializable> -
RegExp -
Response -
Set<Serializable> -
URL -
URLSearchParams -
Uint8Array,Uint8ClampedArray,Uint16Array,Uint32Array -
ReadableStream<Serializable> -
WritableStream<Serializable>
Streaming
As noted in the list above, ReadableStream and WritableStream are supported in
Workflow DevKit's serialization system. These streams are automatically managed by
the Workflow runtime and can be passed between workflow and step functions, while
maintaining their streaming capabilities.
However, there is an important consideration to keep in mind which is that streams cannot be interacted with within the workflow function context. They should be considered opaque handles that may be passed around between steps.
Why this limitation?
Workflow functions must be deterministic and replay-safe. Streams represent asynchronous, non-deterministic data flow. When you pass a stream through a workflow, only the stream reference is serialized—not the actual streaming data. The stream data flows directly between steps without being persisted, which maintains efficiency while preserving the workflow's ability to resume.
This pattern is particularly useful for handling large datasets, streaming LLM responses, or processing file uploads/downloads where you want to pass data efficiently between steps without loading everything into memory.
For example, one step function may produce a ReadableStream while a different
step consumes the stream. The workflow function does not interact directly with
the stream but is able to pass it on to the next step:
async function generateStream() {
"use step";
return new ReadableStream({
async start(controller) {
controller.enqueue(1);
controller.enqueue(2);
controller.enqueue(3);
controller.close();
}
});
}
async function consumeStream(readable: ReadableStream<number>) {
"use step";
const values: number[] = [];
for await (const value of readable) {
values.push(value);
}
console.log(values);
// Logs: [1, 2, 3]
}
export async function passingStreamWorkflow() {
"use workflow";
// ✅ Stream is received as a return value and passed
// into a step, but NOT directly used in the workflow
const readable = await generateStream();
await consumeStream(readable);
}What NOT to do: Do not attempt to read from or write to a stream directly within the workflow function context, as this will not work as expected and an error will be thrown at runtime:
export async function incorrectStreamUsage() {
"use workflow";
const readable = await generateStream();
// ❌ This will fail - cannot read stream in workflow context
const reader = readable.getReader();
}Always delegate stream operations to step functions.
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:
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();
// …
}Pass-by-Value Semantics
An important consequence of serialization is that all parameters are passed by value, not by reference. When you pass an object or array to a step function, the step receives a deserialized copy of that data. Any mutations to the arguments inside the step function will not be reflected in the workflow's context.
Common Misuse
// ❌ INCORRECT - mutations to arguments won't persist
export async function updateUserWorkflow(userId: string) {
"use workflow";
const user = { id: userId, name: "John", email: "john@example.com" };
// This passes a copy of the user object to the step
await updateUserStep(user);
// The user object in the workflow is UNCHANGED - user.email is still "john@example.com"
console.log(user.email); // Still "john@example.com", not updated!
}
async function updateUserStep(user: { id: string; name: string; email: string }) {
"use step";
// This modifies the LOCAL COPY, not the original in the workflow
user.email = "newemail@example.com";
}// ❌ INCORRECT - pushing to an array argument won't persist
export async function collectItemsWorkflow() {
"use workflow";
const items: string[] = ["apple"];
// This passes a copy of the items array to the step
await addItemStep(items);
// The items array in the workflow is UNCHANGED
console.log(items); // Still ["apple"], not ["apple", "banana"]!
}
async function addItemStep(items: string[]) {
"use step";
// This modifies the LOCAL COPY of the array
items.push("banana");
}Correct Pattern - Return Values
To persist changes, return the modified data from the step function and reassign it in the workflow:
// ✅ CORRECT - return the modified data
export async function updateUserWorkflow(userId: string) {
"use workflow";
let user = { id: userId, name: "John", email: "john@example.com" };
// Capture the return value
user = await updateUserStep(user);
// Now the user object reflects the changes
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;
}// ✅ CORRECT - return the modified array
export async function collectItemsWorkflow() {
"use workflow";
let items: string[] = ["apple"];
// Capture the return value
items = await addItemStep(items);
// Now the items array reflects the changes
console.log(items); // ["apple", "banana"] ✓
}
async function addItemStep(items: string[]) {
"use step";
items.push("banana");
return items;
}Remember: Only return values are persisted to the event log and visible in the workflow context. Mutations to parameters are lost because each step receives a fresh deserialized copy of the data.