Overview
A clock for handler retries and timers.
import { createServer } from 'experimental-a2/server'
import { redis } from 'experimental-a2/store-redis'
import { vercelQueues } from 'experimental-a2/scheduler-vercel'
import { orders } from '@/contracts'
export const scheduler = vercelQueues()
export const ordersServer = createServer({
contract: orders,
store: redis({ url: process.env.REDIS_URL }),
scheduler,
handlers: {
/* ... */
},
})import { billingServer, ordersServer, scheduler } from '@/server'
export const POST = scheduler.handler(ordersServer, billingServer)A scheduler wakes sessions on a clock. It retries a handler that failed or
whose process died, and it delivers timers, whose delays must
fit the provider's limit. Share one
scheduler across your servers and mount its route once, with every server it
delivers to. scheduler.handler(...) checks those servers when the module
loads, so a bad route fails at startup.
| Scheduler | Use it for |
|---|---|
| Vercel Queues | Apps on Vercel (recommended) |
| QStash | A stable public URL, called from separate infrastructure |
Without a scheduler
If no event type has a handler and nothing calls session.schedule, skip it.
Events settle as they're appended, with no queue messages. That's a complete
production setup for history, reducers, and live sync.
With handlers but no scheduler, a session only wakes on a top-level append or
an explicit server.drain(). Reads never wake it, so a session nobody writes
to stays stuck. That's fine for internal tools that can heal on the next
write. For production, add a scheduler.
More than one scheduler
Servers with different scheduler instances need separate routes, even from the
same adapter. Give each QStash instance its own url, and each Vercel Queues
instance its own topic and trigger.
Dispatch in a fresh invocation
append starts handlers in the same request and uses the scheduler as a
watchdog. append.dispatch stores the event and hands its handlers to the
queue instead:
import { ordersServer } from '@/server'
export async function POST(request: Request) {
const { orderId, shopId, items } = await request.json()
await ordersServer.session(orderId).append.dispatch({
id: `created:${orderId}`,
type: 'created',
payload: { shopId, items },
})
return Response.json({ accepted: true }, { status: 202 })
}It needs a scheduler and waits for the queue to accept the message. If that
send fails, the event may already be stored; its explicit id makes a retry
safe.
Deploys and rollbacks
Queued work can reach a different deployment than the one that sent it. Keep event schemas compatible across deployments. Each adapter's page explains where its tasks go: Vercel Queues and QStash.
Send errors
When a provider rejects a send, both adapters keep the provider's original error.
- Terminal: a definite 4xx status or error, except 408, 409, 425, and 429.
The Vercel adapter also treats the SDK's
BadRequestError,UnauthorizedError, andForbiddenErroras terminal. - Retryable: 408, 409, 425, 429, 5xx, network failures, and errors with no recognized status.
- Vercel Queues' duplicate-message error counts as success.
A2's durable AI tools use this classification. Direct session.schedule()
calls still reject with the original error.