This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Dapr Workflow JavaScript SDK

Author, run, and manage durable workflows with the Dapr JavaScript SDK

Use the Dapr JavaScript SDK to coordinate activities, durable timers, external events, and other workflows. WorkflowRuntime registers and executes your application code; DaprWorkflowClient schedules instances and manages their lifecycle. Both use the Dapr sidecar’s gRPC endpoint, independently of the protocol you select for a DaprClient.

These guides target @dapr/dapr 3.19.0 or later. The examples use Dapr runtime 1.19 or later so that all documented capabilities are available, including detached workflows, management operations, and the alpha binary-store building block. Use Node.js 18 or later.

npm install @dapr/dapr@3.19.0

Choose a guide

GuideWhat you learn
Author and run workflowsRegister a worker, chain and parallelize activities, use timers and events, retry failures, and continue as new.
Manage workflow instancesSchedule, inspect, wait, suspend, resume, terminate, purge, list, retrieve history, and rerun from an event.
Multi-app workflows and historyCall remote activities and child workflows, schedule detached workflows, and inspect propagated ancestor history.
Workflow versioningUse replay-safe patch decisions to evolve running workflows.
Workflow serializationShare a serializer between clients and workers and preserve compatibility with persisted history.
Workflow data references (alpha)Offload large payloads to a binary store, forward explicit references, and stream data only where needed.

Runtime configuration

WorkflowRuntime and DaprWorkflowClient accept an options object:

OptionBehavior
daprHost, daprPortSidecar host and gRPC port. Defaults come from SDK settings, normally 127.0.0.1:50001. Without explicit host or port options, DAPR_GRPC_ENDPOINT is honored.
daprApiTokenDapr API token; otherwise uses DAPR_API_TOKEN.
loggerSDK logging options. Use ctx.logger inside workflow code to suppress replayed messages.
serializerPayload serializer. Defaults to JSON; if omitted with payloads configured, uses the resolver’s serializer.
payloadsOptional BinaryStoreWorkflowPayloadResolver. Register on every client and worker that reads or writes references.
grpcOptionsSupports grpc.max_receive_message_length and grpc.max_send_message_length, each defaulting to 128 MiB, and Connect-compatible interceptors. Other legacy grpc-js channel settings, such as keepalive, credentials, or compression, are not applied.
maxConcurrentActivitiesWorker-only positive integer limiting simultaneous activity executions. Additional activities queue locally; workflow work items are not limited. Omit to leave concurrency to the Dapr runtime’s workflow.maxConcurrentActivityInvocations.

Await runtime.start() before scheduling work. It resolves when the worker connects and is ready, and rejects if the initial connection fails. runtime.isReady reports current connection readiness; the worker reconnects after a lost connection. Call runtime.stop() and client.stop() during application shutdown.

The SDK propagates W3C trace context when scheduling workflows and creates WorkflowActivity <name> spans under the Dapr.Workflow tracer when an activity has a parent trace context. Configure an OpenTelemetry provider and exporter in your application to collect these spans.

Upgrade considerations

This SDK uses its own workflow engine rather than the shared DurableTask client library. Re-test existing workflows when upgrading, including replay, status comparisons, serializers, and transport options. Compare statuses with WorkflowRuntimeStatus members instead of hard-coded strings or numeric values. The engine does not support the stateful workflow client; propagated ancestor history and getWorkflowHistory are separate capabilities.

Next steps

1 - How to: Author and run workflows with the JavaScript SDK

Register workflows and activities, coordinate durable tasks, and handle retries and events

Workflows are generator functions that yield SDK tasks. The runtime records task results in history and replays the generator to reconstruct its state. Activities are ordinary functions, optionally asynchronous, that perform I/O or other work outside the deterministic workflow.

Prerequisites

  • Node.js 18 or later and @dapr/dapr 3.19.0 or later.
  • An initialized self-hosted Dapr environment with runtime 1.19 or later.
  • Workflow infrastructure: an actor state store, placement, and scheduler. The default dapr init environment provides these for local development.

Register and run a workflow

Install the package in your application directory:

npm init -y
npm install @dapr/dapr@3.19.0

Save this complete example as app.mjs. It uses JavaScript ES modules; the same APIs are available in TypeScript.

import {
  DaprWorkflowClient,
  WorkflowRuntime,
  WorkflowRuntimeStatus,
} from "@dapr/dapr";

async function hello(_ctx, name) {
  return `Hello ${name}!`;
}

function* sequence(ctx) {
  const cities = [];
  for (const city of ["Tokyo", "Seattle", "London"]) {
    cities.push(yield ctx.callActivity(hello, city));
  }
  ctx.logger.info("Greeting sequence completed");
  return cities;
}

const options = { daprHost: "127.0.0.1", daprPort: "50001" };
const runtime = new WorkflowRuntime(options);
const client = new DaprWorkflowClient(options);
runtime.registerWorkflow(sequence).registerActivity(hello);

try {
  await runtime.start();
  const id = await client.scheduleNewWorkflow(sequence);
  const state = await client.waitForWorkflowCompletion(id, true, 30);
  if (!state || state.runtimeStatus !== WorkflowRuntimeStatus.COMPLETED) {
    throw new Error(
      state?.workflowFailureDetails?.getErrorMessage() ?? "Workflow did not complete successfully",
    );
  }
  console.log(state.getOutput());
} finally {
  await runtime.stop();
  await client.stop();
}

Run it with a sidecar:

dapr run --app-id sequence-workflow --dapr-grpc-port 50001 -- node app.mjs

The output includes ["Hello Tokyo!", "Hello Seattle!", "Hello London!"].

Registration names

registerWorkflow(fn) and registerActivity(fn) use the function name as the durable registration name. For explicit, stable names, use:

runtime.registerWorkflowByName("greeting-v1", sequence);
runtime.registerActivityWithName("hello-v1", hello);

Call these registrations by string, for example ctx.callActivity("hello-v1", city) or client.scheduleNewWorkflow("greeting-v1"). Keep names stable across deployments and minification. registerWorkflowWithName is a deprecated alias of registerWorkflowByName.

Keep workflow execution deterministic

Put network calls, database operations, filesystem access, and nondeterministic computation in activities. Use yield with ctx.callActivity, timers, event waits, and task-composition helpers; do not substitute await or Promise.all for durable tasks. Both synchronous and async generators are supported, but an async generator does not make arbitrary I/O replay-safe.

NeedWorkflow API
Current timectx.getCurrentUtcDateTime(), not new Date() or Date.now()
Generated identifierctx.newGuid(), not a random UUID
Instance or registration namectx.getWorkflowInstanceId(), ctx.getWorkflowName()
Logging without duplicate replay messagesctx.logger.info(...), .warn(...), .error(...), or .debug(...)
Conditional replay handlingctx.isReplaying()
Externally visible progressctx.setCustomStatus({ step: "processing" })

Activities can be delivered more than once. Make external side effects idempotent. An activity receives WorkflowActivityContext with getWorkflowInstanceId(), getWorkflowActivityId(), and getTaskExecutionId(). The execution ID is a persisted activity key, also exposed as task.executionId on the workflow’s activity task, and is reused across SDK retry attempts. It can be undefined for legacy history or runtimes that do not supply it; do not assume it is always present or that it guarantees exactly-once delivery.

Run activities in parallel

Create all tasks before yielding whenAll:

function* parallelGreetings(ctx, cities) {
  const tasks = cities.map((city) => ctx.callActivity("hello", city));
  return yield ctx.whenAll(tasks);
}

whenAll returns results in input order and fails if a task fails. whenAny returns the first completed task, not its result, and does not cancel the other tasks. Yield the winning task or call its getResult() to observe its result or error.

Retry activities and child workflows

Pass retryPolicy in activity or child-workflow options:

function* resilientGreeting(ctx, name) {
  return yield ctx.callActivity("hello", name, {
    retryPolicy: {
      firstRetryInterval: 1,
      maxNumberOfAttempts: 4,
      backoffCoefficient: 2,
      maxRetryInterval: 30,
      retryTimeout: 120,
    },
  });
}

All intervals are in seconds. maxNumberOfAttempts includes the initial call; backoffCoefficient defaults to 1. The first interval and optional limits must be positive and finite; attempts must be a positive integer and the coefficient must be at least 1. A non-retriable failure stops retries. retryTimeout limits scheduling further attempts; it is not a timeout that interrupts a running activity. Failures are thrown into the generator at yield, where you can catch them and schedule compensating activities.

Wait for external events

An external event lets another process signal an instance. The SDK matches event names case-insensitively and buffers events that arrive before their wait is created.

import { TaskTimeoutError } from "@dapr/dapr";

function* approval(ctx) {
  ctx.setCustomStatus({ step: "awaiting-approval" });
  try {
    const decision = yield ctx.waitForExternalEvent("approval", {
      timeoutInSeconds: 3600,
    });
    return decision;
  } catch (error) {
    if (!(error instanceof TaskTimeoutError)) throw error;
    return { approved: false, reason: "Approval deadline expired" };
  }
}

From a client, call await client.raiseEvent(instanceId, "approval", { approved: true }). From another workflow in the same app, use ctx.sendEvent(instanceId, "approval", { approved: true }); sendEvent does not return a task and is not yielded. Do not expose the client or make client RPCs inside workflow code.

Use durable timers and cancellation

ctx.createTimer(60) waits 60 seconds of workflow time. You can also supply an absolute Date computed from ctx.getCurrentUtcDateTime(). Do not use setTimeout for a durable delay.

For a race between an event and a timer:

function* approvalRace(ctx) {
  const event = ctx.waitForExternalEvent("approval");
  const timer = ctx.createTimer(3600);
  const winner = yield ctx.whenAny([event, timer]);
  if (winner === event) {
    timer.cancel();
    return event.getResult();
  }
  event.cancel();
  timer.getResult();
  return { approved: false, reason: "Approval deadline expired" };
}

Timers and event waits implement CancellableTask, exposing cancel() and isCanceled. Canceling stops the SDK’s local wait and rejects it with TaskCanceledError; it does not cancel runtime-side work, an activity, or an entire workflow. Cancel unused waits explicitly after a race.

Continue as new

For long-running loops, start a fresh history generation under the same instance ID:

function* batchLoop(ctx, input) {
  const next = yield ctx.callActivity("processBatch", input);
  ctx.continueAsNew(next, true);
  return;
}

Return immediately after continueAsNew. The second argument, saveEvents, defaults to true and retains unconsumed external events; set it to false to discard them. Do not schedule more work after requesting continuation. Register the activity used by this example before starting the worker.

Next steps

2 - Manage workflow instances with the JavaScript SDK

Schedule, inspect, control, list, and rerun workflow instances with DaprWorkflowClient

Use DaprWorkflowClient from application or administrative code, not inside a workflow generator. This guide targets @dapr/dapr 3.19.0 or later and runtime 1.19 or later.

import { DaprWorkflowClient, WorkflowRuntimeStatus } from "@dapr/dapr";

const client = new DaprWorkflowClient({
  daprHost: "127.0.0.1",
  daprPort: "50001",
});

The following snippets reuse client. The workflow name must be registered on the destination worker. Call await client.stop() when the application shuts down.

Choose the management client

DaprWorkflowClient is separate from DaprClient.workflow, the basic workflow API. The basic API supports HTTP or gRPC with scheduleNewWorkflow, getWorkflowState, raiseEvent, pause, resume, terminate, and purge. Its old start, get, and raise names are deprecated aliases. It returns a plain WorkflowGetResponseType with properties, rather than the WorkflowState described here.

Use DaprWorkflowClient for durable start/completion waits, listing, history retrieval, reruns, and integrated payload-reference handling. Its methods and options are not interchangeable with the basic API: for example, it uses suspendWorkflow rather than pause, and getWorkflowState requires the getInputsAndOutputs argument.

Schedule and wait

const instanceId = await client.scheduleNewWorkflow(
  "orderWorkflow",
  { orderId: "order-123" },
  "order-123",
  new Date(Date.now() + 60_000),
);

await client.waitForWorkflowStart(instanceId, false, 120);
const state = await client.waitForWorkflowCompletion(instanceId, true, 300);
if (!state) throw new Error(`Workflow ${instanceId} was not found`);

if (state.runtimeStatus === WorkflowRuntimeStatus.COMPLETED) {
  console.log(state.getOutput());
} else {
  console.error(state.runtimeStatus, state.workflowFailureDetails);
}

The positional scheduling arguments are (workflow, input?, instanceId?, startAt?, signal?). workflow can be a registered name or a function; omitted instance IDs are generated. startAt is an optional absolute Date. Using wall-clock time here is safe because this is client code, not replayed workflow code.

Both wait methods accept (instanceId, fetchPayloads = true, timeoutInSeconds = 60, signal?). A wait deadline or RPC failure rejects the promise. A workflow that reaches a failed or terminated state can return a state rather than throw: check runtimeStatus, not just whether the wait resolved.

Inspect state and failure details

await client.getWorkflowState(instanceId, true) returns a WorkflowState snapshot, or undefined for a missing instance. Pass false to omit inputs and outputs when only metadata is needed.

MemberMeaning
name, instanceId, existsRegistration name, instance ID, and existence (true for a returned state).
runtimeStatusCompare with WorkflowRuntimeStatus.RUNNING, COMPLETED, CONTINUED_AS_NEW, FAILED, CANCELED, TERMINATED, PENDING, SUSPENDED, or STALLED.
createdAt, lastUpdatedAtJavaScript Date values.
getInput(), getOutput(), getCustomStatus()Deserialize with the client’s configured serializer; TypeScript callers can provide a generic result type.
serializedInput, serializedOutput, customStatusSerialized strings, not already-parsed objects. With payload support, resolved strings are prepared where possible.
workflowFailureDetailsgetErrorType(), getErrorMessage(), getStackTrace(), isNonRetriable, and getInnerFailure() for nested details. innerFailure exposes the raw nested protobuf value.
getInputReference(), getOutputReference(), getCustomStatusReference()Original top-level binary-store reference, if present. See data references.

Typed getters do not validate application schemas or reconstruct class instances automatically. Missing payload fields return undefined. Missing binary-store objects or integrity failures are errors, not “workflow not found.”

Send events and control execution

MethodBehavior
raiseEvent(id, eventName, payload?, signal?)Deliver an event to a workflow’s durable event wait.
suspendWorkflow(id, reason?, signal?)Suspend instance execution.
resumeWorkflow(id, reason?, signal?)Resume a suspended instance.
terminateWorkflow(id, output?, signal?)Request termination with optional output; does not execute application compensation logic.
purgeWorkflow(id, signal?)Purge retained instance state/history; returns whether an instance was deleted. Subject to runtime eligibility checks.

For example:

await client.suspendWorkflow(instanceId, "Investigating an order");
await client.resumeWorkflow(instanceId, "Investigation complete");
await client.raiseEvent(instanceId, "approval", { approved: true });

Do not use suspension or termination as a way to undo activity side effects. A canceled client request also does not terminate the instance. Purging history does not delete binary-store payloads.

Cancel client requests

Scheduling, state reads, waits, event delivery, suspend/resume, termination, and purge accept an optional AbortSignal in their final argument:

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
try {
  await client.waitForWorkflowCompletion(instanceId, true, 60, controller.signal);
} finally {
  clearTimeout(timeout);
}

Aborting stops the request and associated payload I/O where supported, not the durable workflow. A request may already have been accepted by the runtime before cancellation. Listing, history retrieval, and rerun do not expose an AbortSignal parameter in this release.

List instances

listWorkflowInstanceIds(continuationToken?, pageSize?) returns { instanceIds, continuationToken }. Continue until no token remains:

let token;
do {
  const page = await client.listWorkflowInstanceIds(token, 100);
  for (const id of page.instanceIds) {
    console.log(id);
  }
  token = page.continuationToken;
} while (token);

The SDK validates pageSize as an integer from 1 to 4294967295; the runtime may impose additional limits. The response contains IDs, not state snapshots or filtered query results.

Retrieve execution history

const history = await client.getWorkflowHistory(instanceId);
for (const event of history) {
  console.log(event.eventId, event.eventType.case);
}

This returns the complete HistoryEvent[] using the SDK’s protobuf types. Payload fields remain serialized wire data, including any reference envelopes; retrieving history is not automatic payload hydration. Missing-instance and other runtime errors are propagated.

Rerun from an event

Inspect the source history to choose the event ID accepted by the runtime, then create a new instance:

const rerunId = await client.rerunWorkflowFromEvent(instanceId, eventId, {
  newInstanceId: "order-123-rerun",
  input: { correctedAmount: 125 },
});

eventId is a history event ID, not the array index, and must be an unsigned 32-bit integer. This operation creates a new instance; it does not modify the source instance. Replacement input applies to the activity targeted by the event, not the original workflow input.

newInstanceId is optional but must not be empty. overwriteInput defaults to true when input is supplied; set it to false to preserve the original activity input. The runtime decides which histories and events are eligible and propagates errors for missing instances or invalid events. Reruns can repeat side effects: review idempotency and retain any binary objects still referenced by the copied history.

Next steps

3 - Multi-app workflows and history propagation in the JavaScript SDK

Coordinate remote activities, child workflows, and detached workflows and inspect ancestor history

Use targetAppId to route activity and workflow calls to another Dapr application. This guide uses @dapr/dapr 3.19.0 or later and Dapr runtime 1.19 or later. See Multi Application Workflows for runtime architecture and constraints.

Requirements

Participating apps must run in the same namespace and use the same workflow actor state store. Each destination must register the name being called and run a workflow worker. Omit targetAppId for a local call. For cross-language calls, agree on names, input/output schemas, and serialization.

Call an activity or child workflow

import { HistoryPropagationScope } from "@dapr/dapr";

function* orderWorkflow(ctx, order) {
  const payment = yield ctx.callActivity("chargePayment", order, {
    targetAppId: "payments",
    retryPolicy: { firstRetryInterval: 2, maxNumberOfAttempts: 3 },
    propagationScope: HistoryPropagationScope.OWN_HISTORY,
  });

  return yield ctx.callChildWorkflow(
    "fulfillmentWorkflow",
    { order, payment },
    undefined,
    {
      targetAppId: "fulfillment",
      propagationScope: HistoryPropagationScope.LINEAGE,
    },
  );
}

An activity call returns the activity result; a child call waits for the child and returns its output. Failures are thrown at the corresponding yield. callSubWorkflow is an alias of callChildWorkflow. The positional child arguments are (workflow, input?, instanceId?, options?); omit the child ID to let the SDK derive it. A retry policy can retry a child workflow as well as an activity. A custom child ID applies to the first attempt; later retry attempts receive generated child IDs.

Schedule an independent, detached workflow

Detached workflows require Dapr runtime 1.19 or later:

function* dispatchWorkflow(ctx, input) {
  const startAt = new Date(ctx.getCurrentUtcDateTime().getTime() + 60_000);
  const detachedId = yield ctx.scheduleNewDetachedWorkflow(
    "notificationWorkflow",
    input,
    undefined,
    startAt,
    { targetAppId: "notifications" },
  );
  return { detachedId };
}

The task returns the detached workflow’s instance ID, not its output. The workflow runs independently: its completion or failure does not flow back to the caller. Use a client to inspect the returned ID through the destination app’s sidecar. The arguments are (workflow, input?, instanceId?, startAt?, options?). Detached options support targetAppId and allowPayloadReferences, but not retries or history propagation. Always yield the scheduling task so the workflow can record the scheduling action.

Inspect propagated history

Activity and child-workflow options accept:

HistoryPropagationScopeHistory shared with the callee
NONENo history propagation; the default.
OWN_HISTORYThe calling workflow’s own history.
LINEAGEThe caller’s history together with its inherited ancestor history.

From a workflow or activity, call ctx.getPropagatedHistory(). It returns undefined when no history was supplied, otherwise a read-only PropagatedHistoryView:

async function auditActivity(ctx, input) {
  const history = ctx.getPropagatedHistory();
  if (history) {
    console.log(history.appIDs);
    for (const ancestor of history.workflows) {
      console.log(ancestor.workflowName, ancestor.instanceId, ancestor.eventCount);
    }
  }
  return input;
}
MemberMeaning
scope, chunksRequested scope and ancestor chunks, oldest ancestor first and immediate parent last.
events, appIDsFlattened events and distinct app IDs across chunks.
workflowsChunks with startEventIndex and eventCount in the flattened view.
getWorkflowByName(name)Oldest matching ancestor, or null.
getEventsByAppID(id), getEventsByInstanceID(id)Events matching an app or instance.
getEventsByWorkflowName(name)Events from every matching workflow instance, not just the first match.

Each chunk contains appId, instanceId, workflowName, decoded events, and original rawEvents, rawSignatures, and signingCertChains. The SDK preserves these bytes but does not verify signatures. Do not treat this view as SDK-verified evidence. Payloads in history remain serialized; reference envelopes are not automatically resolved in this view.

History propagation increases data transferred to callees and can expose ancestor inputs, outputs, and events. Select the narrowest scope needed. This is distinct from the unsupported stateful workflow client.

Cross-app data references

With binary-store payload support, cross-app activity, child, and detached workflow inputs are inline by default. Set allowPayloadReferences: true only when the destination understands the reference contract and can access the same stored objects.

The JavaScript SDK shares the version-1 reference envelope with the .NET SDK. Interoperation requires compatible serializers, contracts, and storage access; JavaScript does not implement .NET payload-stage reversal. Inlining only applies to the top-level call input: results, events, nested references, and propagated history are not made compatible by that default. See cross-app reference handling before enabling offloading in a multi-app topology.

4 - Workflow versioning in the JavaScript SDK

Evolve workflow code with replay-safe named patches

Workflow code must reproduce the task sequence recorded in history when replaying an existing instance. Changing activity order, adding durable tasks, or changing branches without a migration strategy can cause nondeterministic replay failures.

With @dapr/dapr 3.19.0 or later, use ctx.isPatched(patchName) for named code changes. Patch evaluation is validated by the Dapr runtime; the examples in this documentation use runtime 1.19 or later. See Workflow versioning for the runtime-level concepts.

Introduce a patch

Suppose an existing workflow calls chargePayment directly, and a new deployment must validate the order first:

function* orderWorkflow(ctx, order) {
  if (ctx.isPatched("validate-order-before-charge")) {
    yield ctx.callActivity("validateOrder", order);
  }
  return yield ctx.callActivity("chargePayment", order);
}

When an unrecorded patch is first encountered during replay, isPatched returns false so the old execution path remains intact. Outside replay, or when the patch is already recorded, it returns true. The SDK records evaluated patches for runtime version validation.

Names are case-sensitive and must not be empty or whitespace. Use a distinct name for each logical change; never reuse a name for an unrelated change or compute it from nondeterministic values.

Keep old execution paths available

Keep the original branch and patch check until every instance that needs that history has finished and is no longer eligible for replay or rerun. Do not reorder patch checks or remove them merely because a new deployment has started. Account for retained histories, suspended instances, child workflows, and administrative reruns when planning retirement.

For a larger contract change, register a new workflow name and send new instances to it while keeping the old implementation available for existing instances. Patching does not convert persisted serialized data or turn an incompatible payload schema into a compatible one.

Next steps

5 - Workflow serialization in the JavaScript SDK

Configure payload serialization consistently across JavaScript workflow clients and workers

The JavaScript SDK uses JSON by default. With @dapr/dapr 3.19.0 or later, clients and workers can share a custom IDaprSerializer through their serializer option. Serializer selection is client-side; it does not by itself require binary-store support.

Default JSON behavior

Workflow payloads use JSON.stringify and JSON.parse. Use JSON-compatible application contracts: dates deserialize as strings, prototypes are not reconstructed, BigInt requires explicit handling, and cyclic values cannot be serialized. TypeScript generics describe the expected result but do not validate or instantiate that type at runtime.

WorkflowState.getInput<T>(), getOutput<T>(), and getCustomStatus<T>() use the client’s serializer. Their serialized counterparts are strings, not parsed application values.

Configure a serializer

The serializer contract converts values to and from strings:

import {
  DaprWorkflowClient,
  JsonSerializer,
  WorkflowRuntime,
  type IDaprSerializer,
} from "@dapr/dapr";

const serializer: IDaprSerializer = new JsonSerializer();
const options = { daprHost: "127.0.0.1", daprPort: "50001", serializer };
const client = new DaprWorkflowClient(options);
const runtime = new WorkflowRuntime(options);

For a custom format, implement serialize<T>(value: T): string and deserialize<T = unknown>(data: string): T. An optional contentType identifies the media type for SDK APIs that use it; it is not a workflow wire-format negotiation mechanism. Binary formats can use Base64 to satisfy the string contract, at the cost of added size. Serialize deterministically and throw on invalid input rather than silently returning defaults.

For cross-language workflows, agree on the wire format and property names. The default JavaScript JSON behavior is not a replacement for the .NET SDK’s case-insensitive deserialization or other language-specific conversion rules.

Combine serialization with data references

Workflow data references offload the serialized UTF-8 string to a binary store. Offloading is separate from choosing a serializer and is evaluated after serialization. The reference envelope itself is versioned JSON, independent of the application serializer.

Supply the same serializer to the BinaryStoreWorkflowPayloadResolver and all clients and workers. When a workflow client or runtime has payloads but no explicit serializer, it uses payloads.serializer. An explicit serializer overrides that choice; do not configure incompatible serializers in those two places. Only top-level PayloadReference objects receive special envelope handling, not references nested within an application object.

6 - Workflow data references in the JavaScript SDK

Offload large workflow payloads to a binary store and defer materialization with explicit references

Workflow data references keep large payloads out of inline workflow history. The JavaScript SDK stores the serialized value in a Dapr binary store and records a small, versioned reference envelope instead. You can keep ordinary workflow inputs and let the SDK retrieve data automatically, or forward explicit PayloadReference values without downloading the document in the workflow.

Choose how to handle payloads

ApproachBehaviorBest for
Inline (default)Serialized values travel in workflow history. No binary-store access is required.Small payloads.
Automatic offloadingPreferReference stores eligible large values. Ordinary input/result contracts trigger automatic download and deserialization.Reducing history size while preserving existing application contracts.
Explicit referencesRegister reference inputs and request reference results; forward envelopes without downloading the data. Resolve or stream only where needed.Workflows that coordinate large documents but do not inspect their contents.

Performance and memory tradeoffs

Offloading replaces repeated large history values with small references, reducing history storage and workflow protocol traffic. It does not make storage I/O free:

  • Automatic offloading still materializes values. An ordinary workflow input, activity result, or event must be downloaded and deserialized when consumed. Replays repeat those reads; the cache is scoped to a single orchestrator execution, not shared across replays.
  • Explicit references avoid unnecessary reads. A workflow can pass the same reference through several activities without loading the document itself. Only consumers that need the content resolve or stream it.
  • Streaming avoids full buffering. It trades the convenience of a deserialized object for incremental access to the raw serialized bytes.

For a document of size S forwarded through multiple steps, inline history repeatedly carries data on the order of S; reference-based history repeatedly carries the much smaller envelope. This is a traffic model, not a JavaScript latency or allocation benchmark. Actual performance depends on document size, replay frequency, store latency, and how many consumers materialize the data. Small values may be faster inline. Do not apply the .NET benchmark results to JavaScript; measure your own workload.

Set up payload support

Configure a binary-store component

For a disposable local experiment, save this as components\workflow-payloads.yaml:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: workflow-payloads
spec:
  type: binarystore.in-memory
  version: v1
  metadata:
    - name: maxSize
      value: "4Mi"
    - name: maxTotalSize
      value: "512Mi"

See the binary-store guide for component and transport details. Adding a component alone does not enable workflow offloading.

Register a resolver on clients and workers

import {
  BinaryStoreWorkflowPayloadResolver,
  CommunicationProtocolEnum,
  DaprClient,
  DaprWorkflowClient,
  PayloadWritePreference,
  WorkflowRuntime,
} from "@dapr/dapr";

const connection = { daprHost: "127.0.0.1", daprPort: "50001" };
const dapr = new DaprClient({
  ...connection,
  communicationProtocol: CommunicationProtocolEnum.GRPC,
});
const payloads = new BinaryStoreWorkflowPayloadResolver(dapr.binaryStore, {
  storeName: "workflow-payloads",
  writePreference: PayloadWritePreference.PreferReference,
  offloadThreshold: 64 * 1024,
});
const client = new DaprWorkflowClient({ ...connection, payloads });
const runtime = new WorkflowRuntime({ ...connection, payloads });

The resolver uses DaprClient.binaryStore, which supports HTTP or gRPC; the workflow transport itself still uses gRPC. Configure endpoints and API tokens consistently if the clients connect to different endpoints. storeName must match the component name. All readers need access to the referenced store/key and compatible serialization. Merely constructing a resolver uses Preserve, not automatic offloading; the example explicitly selects PreferReference.

Use automatic offloading

With the registration above, keep ordinary inputs:

function* documentWorkflow(ctx, document) {
  return yield ctx.callActivity("measureDocument", document);
}

async function measureDocument(_ctx, document) {
  return document.content.length;
}

runtime.registerWorkflow(documentWorkflow).registerActivity(measureDocument);

try {
  await runtime.start();
  const id = await client.scheduleNewWorkflow(documentWorkflow, {
    content: "a".repeat(128 * 1024),
  });
  const state = await client.waitForWorkflowCompletion(id, true, 60);
  console.log(state?.getOutput());
} finally {
  await runtime.stop();
  await client.stop();
  await dapr.stop();
}

Combine this snippet with the imports and resolver setup above in app.mjs, then run:

dapr run --app-id document-workflow --dapr-grpc-port 50001 --resources-path .\components -- node app.mjs

The document exceeds the threshold and is offloaded at eligible boundaries. The workflow still receives an ordinary object and downloads it on replay; the small numeric result stays inline.

Pass explicit references

JavaScript types are erased at runtime. A TypeScript annotation such as PayloadReference<DocumentPayload> alone does not change how inputs are read. Register each workflow and activity that accepts an envelope with { inputAsReference: true }.

Use this alternative to the automatic example, with the same resolver setup:

function* referenceWorkflow(ctx, documentReference) {
  return yield ctx.callActivity("measureReference", documentReference);
}

async function measureReference(ctx, documentReference) {
  const document = await ctx.payloads.resolve(documentReference);
  return document.content.length;
}

runtime
  .registerWorkflow(referenceWorkflow, { inputAsReference: true })
  .registerActivity(measureReference, { inputAsReference: true });

try {
  await runtime.start();
  const instanceId = "document-reference-1";
  const reference = await payloads.store(
    { content: "a".repeat(128 * 1024) },
    instanceId,
  );
  await client.scheduleNewWorkflow(referenceWorkflow, reference, instanceId);
  const state = await client.waitForWorkflowCompletion(instanceId, true, 60);
  console.log(state?.getOutput());
} finally {
  await runtime.stop();
  await client.stop();
  await dapr.stop();
}

store(value, instanceId, signal?) explicitly stores the serialized value, independently of the automatic threshold or write preference. The workflow only forwards its reference; the activity downloads the document. A reference-input contract rejects an inline value instead of silently storing or converting it.

Keep activity and child results as references

Activities can return a reference created by ctx.payloads.store(value, ctx.getWorkflowInstanceId()). Request resultAsReference: true on ctx.callActivity or ctx.callChildWorkflow to receive that reference without automatically resolving it:

function* documentPipeline(ctx, input) {
  const reference = yield ctx.callActivity("createDocument", input, {
    resultAsReference: true,
  });
  return yield ctx.callActivity("measureReference", reference);
}

The producing activity must actually return a reference, or its result must be offloaded by policy. An inline result fails a reference-result contract, including a small result below the offload threshold. resultAsReference does not force a write. Forward PayloadReference objects directly, not a plain object containing one: nested references are not recursively converted or resolved.

Resolve inside a workflow only when necessary

ctx.hasPayloadResolver() reports whether payload support is configured. yield ctx.resolvePayload(reference) downloads and deserializes the data through an SDK task. yield ctx.openPayload(reference) obtains a raw Node.js readable stream; consuming it asynchronously requires an async generator. These read-only APIs are supported, but repeat during replay and sacrifice the principal benefit of keeping the document out of the orchestrator. Prefer resolving or streaming in activities. Do not perform arbitrary storage writes from workflow code.

Stream payloads in activities

ctx.payloads.open(reference, signal?) returns a Node.js Readable over the raw stored UTF-8 serialized bytes. It does not deserialize the document:

async function countStoredBytes(ctx, reference) {
  const stream = ctx.payloads.open(reference);
  let bytes = 0;
  try {
    for await (const chunk of stream) {
      bytes += chunk.length;
    }
  } finally {
    stream.destroy();
  }
  return bytes;
}

runtime.registerActivity(countStoredBytes, { inputAsReference: true });

Streaming bypasses maxInMemoryRehydrationSize, but also bypasses buffered SHA-256 verification and payload-stage reversal. If needed, compute and verify a hash while consuming the stream. Destroy streams on early exit and handle stream errors. Creating the stream alone does not download all data. The byte count includes JSON syntax and encoding, not just the length of an application string.

Configuration and policy reference

Offloading uses the serialized value’s UTF-8 byte count, not JavaScript string length. Keys have the form <keyPrefix>/<instanceId>/<sha256>. References contain the store, key, size, SHA-256 hash, format version, and optional stage metadata. The version-1 wire envelope uses the reserved $daprRef property.

Resolver optionDefault and behavior
storeNameRequired binary-store component name. Reads use the store recorded in each reference.
keyPrefix"workflow-payloads"; prefix for new content-addressed keys.
writePreferencePayloadWritePreference.Preserve. See the policy table below.
offloadSitesPayloadKind.All; bitwise combination of eligible boundary kinds.
offloadThreshold64 * 1024 bytes, inclusive. Positive integer; null offloads every nonempty serialized snapshot eligible under PreferReference.
skipWriteIfExiststrue; tolerates an already-existing content-addressed object rather than overwriting it. Does not mean the SDK skips every upload attempt.
maxInMemoryRehydrationSize16 * 1024 * 1024 bytes. Enforced against both the envelope size and actual buffered bytes.
verifyIntegrityOnReadtrue; buffered reads verify the SHA-256 hash.
resolutionCacheSize128 entries per orchestrator execution. 0 disables caching.
resolutionCacheBytes64 * 1024 * 1024 summed envelope bytes per execution. 0 disables caching; values exceeding the budget are read without being cached.
manifestOptional durable, idempotent IWorkflowPayloadManifest implementation for explicit cleanup.

The cache retains serialized reads, not a durable cross-replay object cache. Both the entry and byte bounds apply.

Write preferenceOrdinary valuesExisting top-level references
PreserveStay inline.Pass through, except where a cross-app input requires inlining.
PreferReferenceOffload if the site and threshold allow it.Pass through, except where a cross-app input requires inlining.
PreferInlineStay inline.Resolve before writing the outgoing value.

offloadSites combines PayloadKind.WorkflowInput, WorkflowOutput, ActivityInput, ActivityOutput, ExternalEvent, ContinueAsNewInput, CustomStatus, ChildWorkflowInput, and ChildWorkflowOutput. For example, PayloadKind.ActivityInput | PayloadKind.ActivityOutput limits automatic offloading to activity boundaries. PayloadKind.None disables new automatic offloading without disabling reads or forwarding. Detached inputs use WorkflowInput, termination output uses WorkflowOutput, and rerun replacement input uses WorkflowInput.

Read limits and errors

Buffered resolution rejects staged references because JavaScript does not implement stage reversal. It also throws PayloadRehydrationLimitError above the configured limit, PayloadNotFoundError for a missing object, and PayloadIntegrityError for a hash mismatch when verification is enabled. PayloadNotResolvableError indicates missing resolver support. Invalid or unsupported reserved reference envelopes throw rather than being treated as ordinary application data.

Changing the write threshold affects new writes only. Lowering the rehydration limit can break existing ordinary-value consumers and PreferInline conversions. Plan that change against all retained payloads; use explicit references and streaming for values that must not be fully buffered.

Read client results

getWorkflowState and the wait methods prepare referenced input, output, and custom status before returning. With a compatible resolver, the ordinary getters use the resolved serialized data. This means fetching payloads can trigger downloads even when you intend to inspect a reference.

state.getInputReference(), getOutputReference(), and getCustomStatusReference() expose the original reference. If buffered resolution was refused because the resolver is absent or the size exceeds its limit, the reference remains accessible, while the corresponding typed getter throws the preparation error:

const state = await client.getWorkflowState(instanceId, true);
const reference = state?.getOutputReference();
if (reference) {
  const stream = payloads.open(reference);
  try {
    for await (const chunk of stream) {
      process.stdout.write(chunk);
    }
  } finally {
    stream.destroy();
  }
}

Use an appropriate rehydration limit on clients intended to stream large results. Missing objects and integrity failures reject the state request instead of being deferred. Serialized getters can still contain the raw envelope when preparation could not resolve it; do not treat that envelope as an application object. Metadata-only state reads omit input/output fields. History retrieval and propagated-history views preserve their wire payloads and do not hydrate them.

Cross-app and cross-language calls

Cross-app activity, child, and detached workflow inputs are inline by default, even under PreferReference. The sender resolves an existing top-level reference before submission; a failed read prevents submission. The destination should accept an ordinary value for that input.

To forward references, explicitly allow them on the call:

const result = yield ctx.callActivity("remoteDocumentConsumer", reference, {
  targetAppId: "document-service",
  allowPayloadReferences: true,
});

This permits the resolver’s write policy; it does not force offloading or override PreferInline. Every reader must resolve the recorded store and key to the same binary object through its own sidecar. Separate in-memory components do not meet that requirement.

The JavaScript and .NET SDKs share a version-1 envelope, but cross-language use also requires compatible serialization and contracts. JavaScript buffered reads do not reverse .NET payload stages. Use unstaged, compatible payloads for such calls; other SDKs must not be assumed to support this scheme.

The inline default protects only top-level call inputs. It does not inline nested references, return values, external events, or references in propagated ancestor history. Coordinate payload support across the entire topology before enabling it; do not mix payload-aware and payload-unaware workers for one app ID.

Events and continuation

client.raiseEvent and workflow ctx.sendEvent apply the ExternalEvent write policy. ctx.waitForExternalEvent(name) resolves an offloaded event as an ordinary value; use ctx.waitForExternalEvent(name, { asReference: true }) to receive an envelope without downloading. The sender must actually supply a reference or produce an offloaded event for that contract.

continueAsNew applies the ContinueAsNewInput policy. Unconsumed reference events preserved with saveEvents: true are carried forward without being materialized. Ensure the next generation’s input contract is compatible with the new input.

Adopt references in existing applications

First deploy resolver support with Preserve everywhere that may read the histories. This keeps ordinary new values inline while allowing existing references to be read. Next, enable PreferReference at selected sites after assessing storage durability, read limits, and every downstream consumer.

Moving from ordinary input to inputAsReference: true is a contract change: old inline histories cannot satisfy it. Introduce a new registration name/version and route new instances to that contract, keeping the old implementation for existing instances. Likewise, resultAsReference and asReference must not be introduced into histories that still contain inline results or events.

To stop creating new automatic references, use Preserve; to inline outgoing references as well, use PreferInline. Keep the resolver registered while any history, result, event, or consumer can still contain a reference. Switching write policy does not rewrite retained history.

Retention and cleanup

Neither workflow completion nor purgeWorkflow deletes binary-store objects. A failed scheduling request can also leave an uploaded object behind. Retain objects for suspended workflows, replay, reruns, descendants, and forwarded references; deleting a source instance’s objects can break another instance that still uses them.

For explicit cleanup, supply a durable IWorkflowPayloadManifest with asynchronous record(instanceId, reference, signal?), list(instanceId, signal?), and clear(instanceId, signal?) methods, then call await payloads.deletePayloads(instanceId) only when no consumer needs those objects. The resolver records writes in the manifest; it does not discover every forwarded-reference consumer. Cleanup without a manifest throws. Missing objects are tolerated during deletion; other errors propagate, and the manifest is cleared only after deletions succeed.

Use application-level retention and access control appropriate to the data. A reference is a locator, not authorization or a substitute for protecting the binary-store component.