---
title: Timers
description: Schedule a typed session event for later, through the same scheduler that handles recovery.
---

## Schedule from a handler

```ts server/orders.ts
import { createServer } from 'experimental-a2/server'
import { vercelQueues } from 'experimental-a2/scheduler-vercel'
import { orders } from '@/contracts'

export const scheduler = vercelQueues()

export const ordersServer = createServer({
  contract: orders,
  scheduler,
  handlers: {
    created: async ({ session }) => {
      await session.schedule(
        'expire-order',
        { delay: '5d' },
        { type: 'expired', payload: {} },
      )
    },
    expired: async ({ session }) => {
      const events = await session.history()
      if (events.some((event) => event.type === 'shop.started')) return
      // your expiry side effect goes here
    },
  },
})
```

Configure a scheduler, then call `session.schedule(name, timing, ...events)`.
The scheduler route that handles recovery delivers timers too;
[mount it once](/schedulers/overview).

`timing` is `{ delay }` (units `ms`, `s`, `m`, `h`, `d`, checked by the type)
or `{ at: Date }`. A delay must be a finite, unsigned base-10 decimal above
zero with no leading zeros; A2 rejects `0s`, signs, exponent notation,
malformed strings, and invalid dates.

The call resolves once the provider accepts the task; nothing enters the log
until delivery. The target is the earliest delivery time, not a deadline:
adapters round to whole seconds, and an already-due target sends immediately.

## Schedule from ordinary server code

```ts app/api/trials/route.ts
import { z } from 'zod'
import { ordersServer } from '@/server/orders'

const scheduleTrial = z.object({
  orderId: z.string(),
  expiresAt: z.iso.datetime().transform((value) => new Date(value)),
})

export async function POST(request: Request) {
  const body = scheduleTrial.parse(await request.json())
  await ordersServer.session(body.orderId).schedule(
    'expire-order',
    { at: body.expiresAt },
    { type: 'expired', payload: {} },
  )
  return Response.json({ scheduled: true }, { status: 202 })
}
```

Root sessions have the same method. A root `{ delay: '30s' }` counts from that
call's clock, so a retry 10 seconds later delivers 10 seconds later too. Use
`{ at }` when a caller may retry. Inside a handler, a delay counts from the
triggering event's durable `createdAt`, so retries keep the same target.

## Identity and retries

`name` identifies one schedule: per contract and session at the root, plus the
triggering event id in a handler. Different events can share a name like
`expire-order`, while retries of one event converge.

Keep the same name, timing, and events on every retry. Omitted event ids are
derived and stable, so duplicate deliveries append once per session lifetime,
even after the provider's deduplication window. A
[clear](/contract/sessions#clearing-a-session) frees those ids, so a root task
redelivered after one appends again. Changed input under the same name
conflicts at delivery when it produces a different validated event.

## What arrives later

Payloads travel in the provider message as plain JSON: encode a `Date` as an
ISO string and a class as plain data. A2 rejects `undefined`, functions,
symbols, bigints, cycles, accessors, and non-plain prototypes, and shared
references arrive as copies. At delivery, an ordinary top-level append with
fixed ids validates the original input, so a schema transform runs on that
input, not on an earlier output.

Scheduled events enter the log only when delivered, with `cause: null`. From
there they behave like any server append: handlers, lanes, reducers, streams,
and live clients see them normally, and their stable ids absorb at-least-once
delivery.

If the receiving deployment changed or removed the event's schema, delivery
can fail validation and then keeps retrying as a scheduler error. The Vercel
adapter pins timers to their publishing deployment by default; with
`deploymentId: null` or a stable URL like QStash's, keep schemas compatible.
See [Deploys and rollbacks](/schedulers/overview#deploys-and-rollbacks).

## Stale timers

The order shipped on day two; its expiry still arrives on day five. Use the
guard from the first example: let the event land, read the session, and return
when it no longer applies. The log still records that the timer fired.

A2 has no cancellation, reschedule, cron, or timer-listing API. `name` is an
idempotency key, not a handle. For repeating work, have each occurrence's
handler schedule the next one.

## Provider limits

| Adapter | Maximum one-shot delay |
| --- | --- |
| Vercel Queues | 7 days |
| QStash free | 7 days |
| QStash usage-based | 1 year |
| QStash fixed | no fixed maximum |

A2 does not split a longer delay into several tasks. Vercel Queues keeps
messages for seven days, and A2 reserves the final day for delivery and
retries. At a QStash maximum, leave at least one second of margin: its
`notBefore` uses whole Unix seconds.
