Overview

A clock for handler retries and timers.

server/orders.ts
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: {
    /* ... */
  },
})
app/api/a2/scheduler/route.ts
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.

SchedulerUse it for
Vercel QueuesApps on Vercel (recommended)
QStashA 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:

app/api/imports/route.ts
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, and ForbiddenError as 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.