# Vercel Queues tutorial: process background jobs reliably

> Publish and consume Vercel Queue messages with retries, delayed delivery, idempotency keys, private consumers, and safe duplicate handling.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-08-01 | Topics: [Services](https://flaviocopes.com/tags/services/) | Canonical: https://flaviocopes.com/vercel-queues/

[Vercel Queues](https://vercel.com/docs/queues) moves work out of the request that created it.

A route publishes a message to a topic. A separate Function receives that message and performs the slow work.

In this tutorial we'll queue order receipts so the checkout request can return immediately.

Vercel Queues is currently in beta. Check the documentation before relying on current limits or pricing.

## What a queue changes

Without a queue, checkout might do this:

```text
save order -> charge payment -> generate receipt -> send email -> respond
```

The customer waits for every step.

With a queue, the request can stop after publishing the background job:

```text
save order -> charge payment -> publish receipt job -> respond
                                      |
                                      -> consumer sends email
```

If the email provider fails, the queue can deliver the message again.

## Create the Next.js app

Create a Next.js application:

```bash
npx create-next-app@latest queue-demo
cd queue-demo
```

Install the Queues SDK:

```bash
npm install @vercel/queue
```

The SDK uses Vercel OIDC authentication.

Link the local directory and pull its environment:

```bash
npx vercel link
npx vercel env pull
```

On Vercel, the project receives its OIDC credentials automatically.

## Publish a message

Create `app/api/orders/route.ts`:

```ts
import { send } from '@vercel/queue'

type ReceiptJob = {
  orderId: string
  email: string
}

export async function POST(request: Request) {
  const job = (await request.json()) as ReceiptJob

  if (!job.orderId || !job.email) {
    return Response.json(
      { error: 'orderId and email are required' },
      { status: 400 },
    )
  }

  const { messageId } = await send('order-receipts', job, {
    idempotencyKey: `receipt-${job.orderId}`,
  })

  return Response.json(
    {
      orderId: job.orderId,
      receiptJob: messageId,
    },
    { status: 202 },
  )
}
```

`send()` publishes the object to the `order-receipts` topic.

The route returns `202 Accepted` because the receipt has been queued, not completed.

The idempotency key protects us when the producer sends the same order twice. Vercel deduplicates matching publishes while the original message remains within its retention window.

## Create the consumer

Now create `app/api/queues/send-receipt/route.ts`:

```ts
import { handleCallback } from '@vercel/queue'

type ReceiptJob = {
  orderId: string
  email: string
}

export const POST = handleCallback<ReceiptJob>(
  async (job, metadata) => {
    console.log(
      `Sending receipt for ${job.orderId} to ${job.email}`,
      metadata.messageId,
    )

    await sendReceiptEmail(job)
  },
)

async function sendReceiptEmail(job: ReceiptJob) {
  console.log(`Receipt sent for ${job.orderId}`)
}
```

`handleCallback()` connects the Function to the queue delivery protocol.

When the callback completes, the message is acknowledged. When it throws, Vercel makes the message available for retry.

Our `sendReceiptEmail()` function only logs for now. Replace it with your email provider after the queue flow works.

## Make the consumer private

Vercel needs to know which Function consumes the topic.

Create `vercel.json`:

```json
{
  "functions": {
    "app/api/queues/send-receipt/route.ts": {
      "experimentalTriggers": [
        {
          "type": "queue/v2beta",
          "topic": "order-receipts",
          "retryAfterSeconds": 60
        }
      ]
    }
  }
}
```

This trigger does two things.

It subscribes the Function to `order-receipts`. It also removes the route from the public internet.

Only Vercel's queue infrastructure can invoke this consumer. You do not need to add HTTP authentication to it.

The producer route remains public, so protect that route with your application's normal authentication and authorization.

## Test the queue

Start the app:

```bash
npm run dev
```

Publish a message:

```bash
curl -X POST http://localhost:3000/api/orders \
  -H 'content-type: application/json' \
  -d '{"orderId":"order_2048","email":"ada@example.net"}'
```

The response contains the order and queue message IDs:

```json
{
  "orderId": "order_2048",
  "receiptJob": "msg_..."
}
```

Recent versions of the SDK can connect local development to the Vercel Queue service and invoke the registered callback in the application process.

If authentication fails, refresh the local OIDC token:

```bash
npx vercel env pull
```

## See a message retry

Temporarily change the consumer:

```ts
async function sendReceiptEmail(job: ReceiptJob) {
  if (Math.random() < 0.7) {
    throw new Error('Email provider is unavailable')
  }

  console.log(`Receipt sent for ${job.orderId}`)
}
```

The callback throws on most attempts. The queue retries until it succeeds or the message expires.

Remove the random failure after testing.

For more control, add a retry function:

```ts
export const POST = handleCallback<ReceiptJob>(
  async job => {
    await sendReceiptEmail(job)
  },
  {
    retry: (error, metadata) => {
      console.error(error)

      if (metadata.deliveryCount > 10) {
        return { acknowledge: true }
      }

      return {
        afterSeconds: Math.min(
          300,
          2 ** metadata.deliveryCount * 5,
        ),
      }
    },
  },
)
```

This increases the delay between attempts.

After ten deliveries it acknowledges the message and stops retrying. Store the failed job somewhere before acknowledging it, or you will lose the chance to inspect and replay it.

## At-least-once means duplicates are possible

Vercel Queues provides **at-least-once delivery**.

The same message can reach a consumer more than once. This can happen when the email sends successfully but the consumer fails before acknowledgment.

The producer idempotency key does not remove this possibility. It deduplicates publishes, not every consumer execution.

The consumer must also be idempotent.

One approach is to store a unique job key:

```sql
CREATE TABLE processed_jobs (
  job_key TEXT PRIMARY KEY,
  processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```

Before sending the email, try to insert:

```sql
INSERT INTO processed_jobs (job_key)
VALUES ('receipt-order_2048')
ON CONFLICT DO NOTHING;
```

Only the consumer that inserted the row should continue.

For external APIs, pass a stable idempotency key when the provider supports one.

## Delay a message

You can make a message visible later.

For example, send a follow-up five days after an order:

```ts
await send(
  'order-follow-ups',
  {
    orderId: 'order_2048',
    email: 'ada@example.net',
  },
  {
    delaySeconds: 5 * 24 * 60 * 60,
    retentionSeconds: 7 * 24 * 60 * 60,
    idempotencyKey: 'follow-up-order_2048',
  },
)
```

At the time of writing, Vercel supports retention and delivery delays up to seven days. Keep the retention longer than the delay.

Use a Workflow when you need a process to wait longer or continue through several stateful steps.

## Add another consumer

A topic can feed several independent consumer groups.

Suppose an order should also update analytics. Create another route with its own trigger for `order-receipts`.

Both consumers receive the message:

```text
order-receipts
  ├── send-receipt consumer
  └── update-analytics consumer
```

A failure in analytics does not block the email consumer.

This fan-out model is one of the main reasons to use a queue instead of calling several services inside one Function.

## Queues or Workflows?

Use Queues when messages and consumers are the main abstraction.

Choose Queues for:

- fan-out event delivery
- traffic buffering
- direct retry control
- independent consumers
- producer and consumer separation

Use [Vercel Workflows](https://flaviocopes.com/vercel-workflows/) for:

- ordered multi-step processes
- state shared between steps
- long sleeps
- external approval events
- one run you want to inspect end-to-end

Workflows use Queues underneath, but give you a higher-level TypeScript model.

My advice is to start with Workflows for a business process. Reach for Queues when you specifically need message routing and consumer control.

## Deploy and inspect

Deploy a preview:

```bash
npx vercel
```

Publish an order to the preview URL.

In the Vercel dashboard, inspect queue activity and the consumer's runtime logs. Test a failure before sending real traffic.

Also verify your limits, retention, region, and retry behavior against the current beta documentation.

The finished flow is small:

1. The request validates the order
2. `send()` publishes the receipt job
3. The private consumer receives it
4. Success acknowledges the message
5. Failure schedules a retry

That separation keeps checkout fast without treating the receipt as disposable work.

The [general Vercel tutorial](https://flaviocopes.com/vercel/) explains how Preview and Production deployments fit around this queue setup.
