Vercel Workflows tutorial: build a durable background job
By Flavio Copes
Build a durable Vercel Workflow with use workflow, use step, sleep, automatic retries, local inspection, and deployment-safe execution.
Vercel Workflows lets an asynchronous function keep running across failures, waits, and deployments.
The code still looks like normal TypeScript. The important difference is that completed work is recorded, and failed steps can resume without restarting everything.
In this tutorial we’ll build a small customer onboarding workflow.
Why a normal background function is not enough
Suppose a new customer should receive a welcome message now and a follow-up three days later.
This does not work:
await sendWelcomeMessage(customerId)
setTimeout(async () => {
await sendFollowUpMessage(customerId)
}, 3 * 24 * 60 * 60 * 1000)
A serverless Function does not stay alive for three days. A deployment or runtime restart also removes the timer.
A workflow stores its progress. It can sleep without holding compute, then continue later.
Create the Next.js app
Create a new Next.js application:
npx create-next-app@latest workflow-demo
cd workflow-demo
Install the Workflow SDK and its Next.js integration:
npm install workflow @workflow/next
Wrap the Next.js configuration with the Workflow integration.
Update next.config.ts:
import type { NextConfig } from 'next'
import { withWorkflow } from 'workflow/next'
const nextConfig: NextConfig = {}
export default withWorkflow(nextConfig)
This lets the build find functions marked with the workflow directives.
Create the workflow
Create workflows/onboard-customer.ts:
import { sleep } from 'workflow'
export async function onboardCustomer(customerId: string) {
'use workflow'
const customer = await loadCustomer(customerId)
await sendWelcomeMessage(customer)
await sleep('10s')
await sendFollowUpMessage(customer)
return {
customerId,
status: 'completed',
}
}
async function loadCustomer(customerId: string) {
'use step'
return {
id: customerId,
name: 'Ada',
email: '[email protected]',
}
}
async function sendWelcomeMessage(customer: {
name: string
email: string
}) {
'use step'
console.log(`Welcome sent to ${customer.name} at ${customer.email}`)
}
async function sendFollowUpMessage(customer: {
name: string
email: string
}) {
'use step'
console.log(`Follow-up sent to ${customer.name} at ${customer.email}`)
}
Two strings change how this code runs:
'use workflow'marks the durable coordinator'use step'marks work that should run and retry independently
The ten-second sleep makes local testing quick. Change it to 3d or 3 days when the real feature is ready.
Workflow code and step code have different jobs
The workflow function coordinates the process.
Keep side effects inside steps. Database queries, API calls, email delivery, and file writes belong in functions marked with 'use step'.
The workflow stores step inputs and outputs. When a completed workflow replays, it uses the recorded result instead of repeating that side effect.
This is why step boundaries matter.
My advice is to make every step do one clear thing. Small steps are easier to retry and inspect.
Start the workflow from an API route
Create app/api/onboard/route.ts:
import { start } from 'workflow/api'
import { onboardCustomer } from '@/workflows/onboard-customer'
export async function POST(request: Request) {
const { customerId } = await request.json()
if (typeof customerId !== 'string' || !customerId) {
return Response.json(
{ error: 'customerId is required' },
{ status: 400 },
)
}
const run = await start(onboardCustomer, [customerId])
return Response.json({
runId: run.runId,
status: 'started',
})
}
start() queues the workflow and returns a handle.
The route does not wait for the welcome message, sleep, and follow-up. It returns the run ID immediately.
Start the development server:
npm run dev
Trigger the workflow:
curl -X POST http://localhost:3000/api/onboard \
-H 'content-type: application/json' \
-d '{"customerId":"customer_42"}'
The response looks like this:
{
"runId": "wrun_...",
"status": "started"
}
Watch the development terminal. You should see the welcome message, a ten-second wait, and the follow-up.
Inspect the run locally
Open the local Workflow inspector:
npx workflow web
The inspector shows the workflow and each step.
You can see:
- inputs
- outputs
- current status
- step duration
- failures and retries
This is much easier than reconstructing a three-day job from unrelated Function logs.
See automatic retries
Let’s make the follow-up fail once.
Replace sendFollowUpMessage() with a temporary failure:
async function sendFollowUpMessage(customer: {
name: string
email: string
}) {
'use step'
if (Math.random() < 0.7) {
throw new Error('Temporary email provider error')
}
console.log(`Follow-up sent to ${customer.email}`)
}
Remove this random failure after testing. A real step throws when its database or external API request fails.
When a step throws, Workflow retries it. The completed loadCustomer() and sendWelcomeMessage() steps do not run again.
Make side effects idempotent
Retries mean a step might execute more than once.
An email step should use a stable idempotency key:
const idempotencyKey = `onboarding-welcome-${customer.id}`
Pass that value to an email provider that supports idempotency. For a database write, use a unique constraint or an upsert.
Never assume “the Function returned an error” means the side effect did not happen. The provider might accept the request just before the connection fails.
Stop retrying permanent failures
Some failures will not improve with another attempt.
For example, an invalid email address should not retry forever. Throw FatalError:
import { FatalError } from 'workflow'
async function sendWelcomeMessage(customer: {
name: string
email: string
}) {
'use step'
if (!customer.email.includes('@')) {
throw new FatalError('Customer email is invalid')
}
console.log(`Welcome sent to ${customer.email}`)
}
Use normal errors for temporary failures. Use FatalError when retrying cannot fix the input.
Deploy to Vercel
Deploy the application:
npx vercel
Vercel automatically selects its managed Workflow backend. It provides storage, queues, authentication, and the Workflows dashboard.
Enable Fluid compute for the project. Workflows use it when suspending and resuming steps.
Open the project in Vercel and choose Workflows. Trigger the preview URL and inspect the run.
The dashboard view is only available for deployed runs. Local runs stay in the local inspector.
Prove it survives a deployment
Change the sleep to two minutes:
await sleep('2m')
Deploy, then start a workflow.
While it sleeps, change the follow-up log message and deploy again.
The running workflow remains attached to the deployment that started it. It finishes with the old code. New runs use the new deployment.
This protects long-running work from code changes made halfway through the process.
When to use a Workflow
Use a Workflow when the job has state and several ordered steps.
Good examples include:
- customer onboarding
- order fulfillment
- document processing
- AI agent runs
- approval flows
- delayed follow-ups
Use Vercel Queues when you need lower-level message delivery, fan-out consumers, and direct control over acknowledgment. The next tutorial explains that distinction.
For an agent example, Vercel eve combines Workflows with Sandbox and AI Gateway.
The main idea is small: write the process as normal async code, put side effects in steps, and let the runtime remember where it got to.
The general Vercel tutorial covers Preview deployments, production promotion, logs, and rollbacks.
Related posts about services: