The Workflow component allows you to write durable functions: code that orchestrates potentially-long-lived operations reliably, even in the face of server restarts.
It can pause indefinitely while it waits for an asynchronous event or sleep for an arbitrary amount of time, without consuming any resources in the interim.
Step execution can be determined dynamically with branching, loops, try/catch and more, all via regular code. Local variables, for-loops, console logging, etc. work as you'd expect, even though under the hood the function will be suspended and re-hydrated between steps.
Steps are regular Convex queries, mutations, or actions, and can run
sequentially or in parallel (e.g. Promise.all), using output from previous
steps or local variables. The overall workflow and each step has type-safe and
runtime-validated arguments and return value.
Workflows can be canceled and restarted from an arbitrary step, allowing you to recover failed workflows after third party outages or fixing your code.
The status can be observed by many users simultaneously via regular reactive-by-default Convex queries, and the history of each step's execution is likewise live-updating.
Retry behavior for each action step is configurable, mutations have
exactly-once execution (ignoring rollbacks due to database conflicts, which
are automatically retried), and the overall workflow is guaranteed to run to
completion, with exactly-once execution of an onComplete handler.
Uses a Workpool under the hood to enable parallelism limits for steps, to avoid spikes of asynchronous work consuming too many resources.
import { WorkflowManager } from "@convex-dev/workflow";
import { components } from "./_generated/api";
export const workflow = new WorkflowManager(components.workflow);
export const userOnboarding = workflow.define({
args: {
userId: v.id("users"),
},
handler: async (step, args): Promise<void> => {
let result = await step.runAction(
internal.llm.generateCustomContent,
{ userId: args.userId },
// Retry this on transient errors with the default retry policy.
{ retry: true },
);
while (result.requiresRefinement) {
// Run a whole workflow as a single step.
result = await step.runWorkflow(internal.llm.refineContentWorkflow, {
userId: args.userId,
currentResult: result,
});
}
const email = await step.runMutation(
internal.emails.sendWelcomeEmail,
{ userId: args.userId, content: result.content, },
// Optimization: run the mutation synchronously from this transaction.
{ inline: true },
);
if (email.status === "needsVerification") {
// Waits until verification is completed asynchronously.
await step.awaitEvent({ name: "emailHasBeenVerified" });
}
// Wait 3 days before starting follow-ups.
const DAY = 24 * 60 * 60 * 1000;
await step.sleep(3 * DAY);
for (let i = 0; i < 3; i++) {
const sendTime = await getNextBestEmailTime(step, args.userId);
const status = await step.runMutation(
internal.emails.sendFollowUpEmailMaybe,
{ userId: args.userId },
// Waits until this time to run this step.
{ runAt: sendTime },
);
if (!status.ok) break;
}
},
});The workflow tracks each step as it goes, executing steps asynchronously, and
resuming the workflow's handler where it left off when the step completes. If a
step fails, it will either retry based on the configured policy, or throw a
catch-able exception in the workflow handler, allowing graceful recovery.
While steps are executing, the workflow handler is not running. When it is time
to run the next step, it re-executes the code, deterministically replaying the
history until it finds the next step. The workflow itself is also run via the
Workpool, so exceptions thrown within the workflow will get delivered to the
onComplete handler.
First, add @convex-dev/workflow to your Convex project:
npm install @convex-dev/workflowThen, install the component within your convex/convex.config.ts file:
// convex/convex.config.ts
import workflow from "@convex-dev/workflow/convex.config.js";
import { defineApp } from "convex/server";
const app = defineApp();
app.use(workflow);
export default app;Finally, create a workflow manager within your convex/ folder, and point it to
the installed component:
// convex/index.ts
import { WorkflowManager } from "@convex-dev/workflow";
import { components } from "./_generated/api";
export const workflow = new WorkflowManager(components.workflow);The first step is to define a workflow using workflow.define(). This function
is designed to feel like a Convex action but with a few restrictions:
- The workflow runs in the background, so the result can't be directly returned to whatever code starts it.
- The workflow must be deterministic, so it should implement most of its
logic by calling out to other Convex functions. We restrict access to some
non-deterministic functions like
fetch, env vars andcrypto. Others we patch, such asconsolefor logging,Math.random()(seeded PRNG) andDatefor time.
Note: To help avoid type cycles, always annotate the return type of the
handler with the return type of the workflow.
export const exampleWorkflow = workflow.define({
args: { exampleArg: v.string() },
returns: v.string(),
handler: async (step, args): Promise<string> => {
// ^ Specify the return type of the handler
const queryResult = await step.runQuery(
internal.example.exampleQuery,
args,
);
const actionResult = await step.runAction(
internal.example.exampleAction,
{ queryResult }, // pass in results from previous steps!
);
return actionResult;
},
});
export const exampleQuery = internalQuery({
args: { exampleArg: v.string() },
handler: async (ctx, args) => {
return `The query says... Hi ${args.exampleArg}!`;
},
});
export const exampleAction = internalAction({
args: { queryResult: v.string() },
handler: async (ctx, args) => {
return args.queryResult + " The action says... Hi back!";
},
});Once you've defined a workflow, you can start it from a mutation or action using
workflow.start().
export const kickoffWorkflow = mutation({
handler: async (ctx) => {
const workflowId = await workflow.start(
ctx,
internal.example.exampleWorkflow,
{ exampleArg: "James" },
);
},
});You can handle the workflow's result with onComplete. This is useful for
cleaning up any resources used by the workflow.
Note: when you return things from a workflow, you'll need to specify the return
type of your handler to break type cycles due to using internal.* functions
in the body, which then inform the type of the workflow, which is included in
the internal.* type.
You can also specify a returns validator to do runtime validation on the
return value. If it fails, your onComplete handler will be called with an
error instead of success. You can also do validation in the onComplete handler
to have more control over handling that situation.
import { vWorkflowId } from "@convex-dev/workflow";
import { vResultValidator } from "@convex-dev/workpool";
export const foo = mutation({
handler: async (ctx) => {
const name = "James";
const workflowId = await workflow.start(
ctx,
internal.example.exampleWorkflow,
{ name },
{
onComplete: internal.example.handleOnComplete,
context: name, // can be anything
},
);
},
});
export const handleOnComplete = mutation({
args: {
workflowId: vWorkflowId,
result: vResultValidator,
context: v.any(), // used to pass through data from the start site.
},
handler: async (ctx, args) => {
const name = (args.context as { name: string }).name;
if (args.result.kind === "success") {
const text = args.result.returnValue;
console.log(`${name} result: ${text}`);
} else if (args.result.kind === "error") {
console.error("Workflow failed", args.result.error);
} else if (args.result.kind === "canceled") {
console.log("Workflow canceled", args.context);
}
},
});You can run steps in parallel by calling step.runAction() multiple times in a
Promise.all() call.
export const exampleWorkflow = workflow.define({
args: { name: v.string() },
handler: async (step, args): Promise<void> => {
const [result1, result2] = await Promise.all([
step.runAction(internal.example.myAction, args),
step.runAction(internal.example.myAction, args),
]);
},
});Note: The workflow will not proceed until all steps fired off at once have completed. Note: if you are starting many tasks at once, it will only start the first 10 (or maxParallelism) at once, to prevent one workflow from starving others. It will start the next batch when all 10 have finished.
Use step.sleep to pause a workflow for a given duration in milliseconds. The
workflow consumes no resources while sleeping.
// Wait one day before continuing
await step.sleep(24 * 60 * 60 * 1000);Tip: You can name the sleep step for clarity with a second { name } argument.
If you want to defer a specific step, you can use
runAfter or runAt as scheduling options on any step. This delays that
particular step's execution:
// Run this action 10 seconds from now.
await step.runAction(internal.example.myAction, args, { runAfter: 10_000 });This is roughly equivalent to doing a sleep first, with the difference being that the "myAction" step is considered "in progress" while it is waiting, and it only enqueues one item into the Workpool (myAction@delay), instead of two (sleep@delay, myAction@now).
Sometimes actions fail due to transient errors, whether it was an unreliable third-party API or a server restart. You can have the workflow automatically retry actions using best practices (exponential backoff & jitter). By default there are no retries on actions, and if the exception isn't caught in the workflow, it will call the onComplete as a failure. Note: all queries and mutations (including the workflow handler) are retried automatically by Convex on system errors, and no transaction will either partially commit or commit twice (regular Convex guarantees).
You can specify default retry behavior for all workflows on the WorkflowManager, or override it on a per-workflow basis.
You can also specify a custom retry behavior per-step, to opt-out of retries for actions that may want at-most-once semantics.
Workpool options:
If you specify any of these, it will override the
DEFAULT_RETRY_BEHAVIOR.
defaultRetryBehavior: The default retry behavior for all workflows.maxAttempts: The maximum number of attempts to retry an action.initialBackoffMs: The initial backoff time in milliseconds.base: The base multiplier for the backoff. Default is 2.
retryActionsByDefault: Whether to retry actions, by default is false.- If you specify a retry behavior at the step level, it will always retry.
At the step level, you can also specify true or false to use the default
policy or disable retries.
const workflow = new WorkflowManager(components.workflow, {
workpoolOptions: {
defaultRetryBehavior: {
maxAttempts: 3,
initialBackoffMs: 100,
base: 2,
},
retryActionsByDefault: true, // default is false
}
});
export const exampleWorkflow = workflow.define({
args: { name: v.string() },
handler: async (step, args): Promise<void> => {
// Uses default retry behavior & retryActionsByDefault
await step.runAction(internal.example.myAction, args);
// Retries will be attempted with the default behavior
await step.runAction(internal.example.myAction, args, { retry: true });
// No retries will be attempted
await step.runAction(internal.example.myAction, args, { retry: false });
// Custom retry behavior will be used
await step.runAction(internal.example.myAction, args, {
retry: { maxAttempts: 2, initialBackoffMs: 100, base: 2 },
});
},
// If specified, this will override the workflow manager's default
workpoolOptions: { ... },
});You can specify how many steps can run in parallel by setting the
maxParallelism workpool option. Note: this is not a limit of how many
workflows can be in progress. Any number of workflows can be in-flight. This
limit is how many queries/mutations/actions can be executing at the same time.
If you see that your scheduled functions are getting backlogged, you should decrease this number. You can look for data on the Dashboard's Health and Schedules pages, as well as in log stream data (tip: define alerts on this!).
If you don't specify a limit in code, you can set this dynamically without a code push while your app is running. Run this from the CLI (or dashboard function runner):
npx convex run --component workflow utils:updateConfig '{ maxParallelism: 50 }'On a Pro account, avoid exceeding a total of 100 across all your workflows and workpools. If you want to do a lot of work in parallel, you should employ batching, where each workflow operates on a batch of work, e.g. scraping a list of links instead of one link per workflow.
const workflow = new WorkflowManager(components.workflow, {
workpoolOptions: {
// Note: You must only set this to one value per `components.<name>`!
// You can set different values if you "use" multiple instances
// with unique names in convex.config.ts.
// Tip: use an environment variable for dynamic control
maxParallelism: 10,
},
});By default, every step.runQuery() and step.runMutation() call is dispatched
through the workpool, which runs the function in an independent transaction. You
can opt in to running a query or mutation inline, sharing the workflow's
transaction by passing { inline: true }:
export const myWorkflow = workflow.define({
args: { userId: v.id("users") },
handler: async (step, args): Promise<string> => {
const user = await step.runQuery(
internal.example.getUser,
{ userId: args.userId },
{ inline: true },
);
const updated = await step.runMutation(
internal.example.updateUser,
{ userId: args.userId, name: user.name + "!" },
{ inline: true },
);
return updated;
},
});Because inline functions share the workflow's transaction, their reads and writes count toward the same transaction limits. If a step reads or writes a large amount of data, it's better to leave it running through the work pool (the default) so it gets its own transaction budget.
The workflow.start() method returns a WorkflowId, which can then be used for
querying a workflow's status.
export const runWorkflowAndPoll = action({
args: {...},
handler: async (ctx, args) => {
const workflowId = await workflow.start(
ctx,
internal.example.exampleWorkflow,
{ name: "James" },
);
await new Promise((resolve) => setTimeout(resolve, 1000));
const status = await workflow.status(ctx, workflowId);
console.log("Workflow status after 1s", status);
},
});You can cancel a workflow with workflow.cancel(), halting the workflow's
execution immmediately. In-progress calls to step.runAction(), however, will
finish executing.
export const kickoffWorkflow = action({
handler: async (ctx) => {
const workflowId = await workflow.start(
ctx,
internal.example.exampleWorkflow,
{ name: "James" },
);
await new Promise((resolve) => setTimeout(resolve, 1000));
// Cancel the workflow after 1 second.
await workflow.cancel(ctx, workflowId);
},
});If you want to re-run a workflow from a specific point, you can do so with
workflow.restart(...). By default it will retry the handler using the existing
history of steps.
// Re-executes the handler with the existing history of steps.
// This is useful if you had a bug in the handler code itself.
await workflow.restart(ctx, args.workflowId);If a certain step failed, you can restart from that step onwards by providing
the step number, name, or function reference (internal.foo.bar e.g.). Events
are named after the event name or event ID if no name is given. You can get the
step number by listing the steps and using the stepNumber.
// Restart from a step number (0-indexed)
await workflow.restart(ctx, workflowId, { from: 2 });
// Restart from a step by name
await workflow.restart(ctx, workflowId, { from: "eventName" });
// Restart from a step by function reference
await workflow.restart(ctx, workflowId, {
from: internal.example.myAction,
});It will find the step by number, or the last step with the given name or function reference and delete all subsequent steps, so the workflow will start from that step when re-executing.
By default it will execute the handler in the same transaction so any errors will be immediately visible. However, this means that on a handler error, the restart itself will also be rolled back and the workflow will be unchanged.
If you want to retry the workflow in a separate transaction, you can do so by
passing startAsync: true. This will enqueue the handler via the workpool to
run asynchronously.
await workflow.restart(ctx, workflowId, { startAsync: true });After a workflow has completed, you can clean up its storage with
workflow.cleanup(). Completed workflows are not automatically cleaned up by
the system.
export const kickoffWorkflow = action({
handler: async (ctx) => {
const workflowId = await workflow.start(
ctx,
internal.example.exampleWorkflow,
{ name: "James" },
);
try {
while (true) {
const status = await workflow.status(ctx, workflowId);
if (status.type === "inProgress") {
await new Promise((resolve) => setTimeout(resolve, 1000));
continue;
}
console.log("Workflow completed with status:", status);
break;
}
} finally {
await workflow.cleanup(ctx, workflowId);
}
},
});You could alternatively use the workflow.list API to paginate through and
clean up old workflows from an hourly cron.
You can specify a custom name for a step by passing a name option to the step.
This allows the events emitted to your logs to be more descriptive, as well as
restarting from a given step to be more clear. By default it uses the
file/folder:function name.
Note: The workflow will fail if the name of the function changes between when
the workflow is started and when it is resumed after some step (potentially much
later if it waited on an event or a sleep).
export const exampleWorkflow = workflow.define({
args: { name: v.string() },
handler: async (step, args): Promise<void> => {
await step.runAction(internal.example.foo, args, { name: "some action" });
},
});Tip: If you want to rename or move a function that is for a given step, you can use the name of the old function.
Use step.awaitEvent inside a workflow handler to pause until an external event
is triggered. This is useful for human-in-the-loop flows or coordinating with
other asynchronous flows. Wait for an indefinite amount of time and continue
when the event is triggered.
At its simplest, you can wait for an event by name:
await step.awaitEvent({ name: "eventName" });This will wait for the first un-consumed event with the name "eventName", and
will continue immediately if one was already sent. Events are sent by calling
workflow.sendEvent from a mutation or action:
await workflow.sendEvent(ctx, {
name: "eventName",
workflowId,
});Note: You must send the event on the same workflow component that is waiting for it, and the workflowId must match the ID of the workflow that is waiting for it.
You can send a value with the event using the value property. For type safety
and runtime validation, provide a validator on the sending and receiving sides.
const sharedValidator = v.number();
// In the workflow:
const event = await step.awaitEvent({ name, validator: sharedValidator });
// From elsewhere:
await workflow.sendEvent(ctx, { name, workflowId, value: 42 });To send an error, use the error property. This will cause step.awaitEvent to
throw an error.
await workflow.sendEvent(ctx, { name, workflowId, error: "An error occurred" });Use defineEvent to define an event's name and validator in one place, then
share it between the workflow and the sender:
const approvalEvent = defineEvent({
name: "approval",
validator: v.object({ approved: v.boolean() }),
});
// In the workflow:
const approval = await step.awaitEvent(approvalEvent);
// From a mutation:
const value = { approved: true };
await workflow.sendEvent(ctx, { ...approvalEvent, workflowId, value });See example/convex/userConfirmation.ts
for a full approval flow built this way.
Note: this is just a convenience to create a typed { event, validator } pair.
You can also dynamically create an event with createEvent:
const eventId = await workflow.createEvent(ctx, {
name: "userResponse",
workflowId,
});Then wait for it by ID in the workflow:
await step.awaitEvent({ id: eventId });This works well when there are dynamically defined events, for instance a tool
that is waiting for a response from a user. You would save the eventId somewhere
to be able to send the event later with workflow.sendEvent:
await workflow.sendEvent(ctx, { id: eventId });Similar to named events, you can also send a value or error with the event.
See example/convex/passingSignals.ts for
a complete example of creating events, passing their IDs around, and sending
signals.
Use step.runWorkflow to run another workflow as a single step in the current
one. The parent workflow waits for the nested workflow to finish and receives
its return value:
const result = await step.runWorkflow(internal.example.childWorkflow, { args });
You can also specify scheduling options like { runAfter: 5000 } to delay the
nested workflow. See
example/convex/nestedWorkflow.ts for a
complete parent/child workflow example.
To associate the child workflow with the parent in your own tables, you can pass
the step.workflowId to the child workflow as an argument, and/or return the
child's workflowId to the parent.
The status of the parent workflow will include any active child workflowIds.
Use list to get a paginated list of all workflows.
await workflow.list(ctx, { order: "asc" });Use listByName to get a paginated list of workflows matching a specific name.
await workflow.listByName(ctx, "file/folder:function", { order: "desc" });Both accept paginationOpts, such as { numItems: 50, cursor: null } to get the
first 50 items, or with a continue cursor from a previous call to paginate
through them all.
Use listSteps with a workflow's ID to get a paginated list of the steps in
that workflow run.
await workflow.listSteps(ctx, workflowId);Having the return value of workflows depend on other Convex functions can lead
to circular dependencies due to the internal.foo.bar way of specifying
functions. The way to fix this is to explicitly type the return value of the
workflow. When in doubt, add return types to more handler functions, like
this:
export const supportAgentWorkflow = workflow.define({
args: { prompt: v.string(), userId: v.string(), threadId: v.string() },
+ handler: async (step, { prompt, userId, threadId }): Promise<string> => {
// ...
},
});
// And regular functions too:
export const myFunction = action({
args: { prompt: v.string() },
+ handler: async (ctx, { prompt }): Promise<string> => {
// ...
},
});To avoid the noise of internal.foo.* syntax, you can use a variable. For
instance, if you define all your steps in convex/steps.ts, you can do this:
const s = internal.steps;
export const myWorkflow = workflow.define({
args: { prompt: v.string() },
handler: async (step, args): Promise<string> => {
+ const result = await step.runAction(s.myAction, args);
return result;
},
});Here are a few limitations to keep in mind:
- Steps can only take in and return a total of 1 MB of data within a single workflow execution. If you run into journal size limits, you can work around this by storing results in the database or file storage from your step functions and passing IDs around within the the workflow.
- The workflow body is internally a mutation, with each step's return value read from the database on each subsequent step. As a result, the limits for a mutation apply and limit the number and size of steps you can perform (including the workflow state overhead). There is currently an 8MiB limit imposed on the journal size, to stay well within the mutation bounds. See more about mutation limits here: https://docs.convex.dev/production/state/limits#transactions
- If you need to use side effects like
fetchor use crypto.subtle, you'll need to do that in a step, not in the workflow definition. Math.randomin the handler itself is seeded per workflow for determinism and not suitable for cryptographic use. It is, however, useful for sharding, jitter, and other pseudo-random applications.- If the implementation of the workflow meaningfully changes (steps added, removed, or reordered) then it will fail with a determinism violation. The implementation should stay stable for the lifetime of active workflows. See this issue for ideas on how to make this better.
Open a GitHub issue with any feedback or bugs you find.