Vercel Queues
A TypeScript client library for interacting with the Vercel Queue Service API, designed for seamless integration with Vercel deployments.
Features
- Simple API:
send and receive are all you need
- Automatic Triggering on Vercel: Vercel invokes your route handlers when messages are ready
- Works Anywhere:
send and receive work in any Node.js environment
- Type Safety: Full TypeScript generics support
- Customizable Serialization: Built-in JSON, Buffer, and Stream transports
- Local Dev Mode: Messages sent locally trigger your handlers automatically
Installation
npm install @vercel/queue
Quick Start
Set up your region via environment variables. If your framework supports .env files (Next.js, Vite, Nuxt, etc.):
QUEUE_REGION=${VERCEL_REGION}
QUEUE_REGION=iad1
Otherwise, set QUEUE_REGION in your environment directly (e.g. via your hosting provider's dashboard or a dotenv setup).
Create a shared queue client:
import { QueueClient } from "@vercel/queue";
const queue = new QueueClient({ region: process.env.QUEUE_REGION! });
export const { send, receive, handleCallback, handleNodeCallback } = queue;
Send a message anywhere in your app:
import { send } from "@/lib/queue";
await send("my-topic", { message: "Hello world" });
Handle incoming messages with a route handler:
import { handleCallback } from "@/lib/queue";
export const POST = handleCallback(async (message, metadata) => {
console.log("Processing:", message);
});
Configure your vercel.json:
{
"functions": {
"app/api/queue/my-topic/route.ts": {
"experimentalTriggers": [{ "type": "queue/v2beta", "topic": "my-topic" }]
}
}
}
Project Setup
For local development, link your Vercel project:
npm i -g vercel
vc link
vc env pull
Local Development
Queues just work locally. When you send() messages in development mode, the library sends them to the real Vercel Queue Service, reads your vercel.json configuration, discovers your queue handlers, and triggers them automatically via local HTTP requests. This means your local dev environment behaves identically to production — no surprising behavior differences.
Note: Local dev mode is enabled when NODE_ENV=development. Most frameworks (Next.js, etc.) set this automatically during npm run dev.
Publishing Messages
import { QueueClient } from "@vercel/queue";
const { send } = new QueueClient({ region: process.env.QUEUE_REGION! });
await send("my-topic", { message: "Hello world" });
await send(
"my-topic",
{ message: "Hello world" },
{
idempotencyKey: "unique-key",
retentionSeconds: 3600,
delaySeconds: 60,
},
);
Example usage in an API route:
import { send } from "@/lib/queue";
export async function POST(request: Request) {
const body = await request.json();
const { messageId } = await send("my-topic", { message: body.message });
return Response.json({ messageId });
}
Note: messageId is null when the server accepts the message for deferred processing (e.g. during a server-side outage). The message will still be delivered.
Consuming Messages
On Vercel
On Vercel, messages are consumed using API route handlers that Vercel automatically invokes when messages are available. Use handleCallback or handleNodeCallback to create these route handlers.
Web API — handleCallback
Returns (Request) => Promise<Response>. For frameworks that export Web API route handlers (Next.js App Router, Hono, etc.).
Next.js App Router:
import { handleCallback } from "@/lib/queue";
export const POST = handleCallback(async (message, metadata) => {
await processMessage(message);
});
Hono:
import { Hono } from "hono";
import { handleCallback } from "@/lib/queue";
const app = new Hono();
app.post(
"/api/queue",
handleCallback(async (message, metadata) => {
await processMessage(message);
}),
);
export default app;
Connect-style — handleNodeCallback
Returns (req, res) => Promise<void>. For frameworks that export Connect-style handlers (Express, Next.js Pages Router, etc.).
Next.js Pages Router:
import { handleNodeCallback } from "@/lib/queue";
export default handleNodeCallback(async (message, metadata) => {
await processMessage(message);
});
Express:
import express from "express";
import { handleNodeCallback } from "@/lib/queue";
const app = express();
app.use(express.json());
app.post(
"/api/queue/my-topic",
handleNodeCallback(async (message, metadata) => {
await processMessage(message);
}),
);
export default app;
2. Configure vercel.json
Tell Vercel which routes handle which topics:
{
"functions": {
"app/api/queue/my-topic/route.ts": {
"experimentalTriggers": [
{
"type": "queue/v2beta",
"topic": "my-topic",
"retryAfterSeconds": 60,
"initialDelaySeconds": 0
}
]
},
"app/api/queue/orders/fulfillment/route.ts": {
"experimentalTriggers": [
{ "type": "queue/v2beta", "topic": "order-events" }
]
},
"app/api/queue/orders/analytics/route.ts": {
"experimentalTriggers": [
{
"type": "queue/v2beta",
"topic": "order-events",
"retryAfterSeconds": 300
}
]
}
}
}
Multiple route files for the same topic create separate consumer groups — each receives a copy of every message.
3. Retry and Backoff
When a handler throws, the message is not acknowledged and becomes available for redelivery after the retryAfterSeconds interval configured in vercel.json. Retries continue until the handler succeeds or the message expires (default: 24 hours).
For finer control over retry timing, pass a retry option:
export const POST = handleCallback(
async (message, metadata) => {
await processMessage(message);
},
{
retry: (error, metadata) => {
if (error instanceof RateLimitError) return { afterSeconds: 60 };
},
},
);
When retry returns { afterSeconds: N }, the message is rescheduled for redelivery after N seconds. Return { acknowledge: true } to acknowledge the message so it is never retried. When it returns undefined, the error propagates normally and the message is retried at the default interval.
Exponential backoff uses metadata.deliveryCount (starts at 1, increments each delivery):
export const POST = handleCallback(
async (message, metadata) => {
await processMessage(message);
},
{
retry: (error, metadata) => {
const delay = Math.min(300, 2 ** metadata.deliveryCount * 5);
return { afterSeconds: delay };
},
},
);
Conditional retry — only retry transient errors:
export const POST = handleCallback(
async (message, metadata) => {
await processMessage(message);
},
{
retry: (error, metadata) => {
if (error instanceof RateLimitError) return { afterSeconds: 60 };
if (error instanceof TemporaryError) return { afterSeconds: 30 };
},
},
);
Acknowledging poison messages — stop retrying messages that can never succeed:
export const POST = handleCallback(
async (message, metadata) => {
await processMessage(message);
},
{
retry: (error, metadata) => {
if (error instanceof ValidationError) return { acknowledge: true };
if (metadata.deliveryCount > 5) return { acknowledge: true };
return { afterSeconds: Math.min(300, 2 ** metadata.deliveryCount * 5) };
},
},
);
The retry option is available on handleCallback, handleNodeCallback, and receive.
Custom Client Configuration
All configuration lives on the QueueClient:
import { QueueClient, BufferTransport } from "@vercel/queue";
const queue = new QueueClient({
region: process.env.QUEUE_REGION!,
token: "my-token",
transport: new BufferTransport(),
headers: { "X-Custom": "header" },
deploymentId: null,
});
await queue.send("my-topic", myBuffer);
export const { send, receive, handleCallback, handleNodeCallback } = queue;
The client sends requests to https://${region}.vercel-queue.com. When handleCallback receives a message, it reads the ce-vqsregion header and routes follow-up API calls to the correct regional endpoint.
To customize the URL scheme, provide a resolveBaseUrl:
const queue = new QueueClient({
region: process.env.QUEUE_REGION!,
resolveBaseUrl: (region) => `https://${region}.my-proxy.example`,
});
Transports
The transport controls how message payloads are serialized and deserialized.
| Structured data | JsonTransport | Low | Default, JSON encoding |
| Binary data | BufferTransport | Medium | Raw bytes |
| Large payloads | StreamTransport | Very Low | No buffering, streaming |
import {
QueueClient,
JsonTransport,
BufferTransport,
StreamTransport,
} from "@vercel/queue";
const queue = new QueueClient({
region: process.env.QUEUE_REGION!,
transport: new JsonTransport({
replacer: (key, value) => (key === "password" ? undefined : value),
reviver: (key, value) => (key === "date" ? new Date(value) : value),
}),
});
const binQueue = new QueueClient({
region: process.env.QUEUE_REGION!,
transport: new BufferTransport(),
});
await binQueue.send("binary-topic", myBuffer);
const streamQueue = new QueueClient({
region: process.env.QUEUE_REGION!,
transport: new StreamTransport(),
});
await streamQueue.send("large-file", myReadableStream);
Manual Receive
Use receive to pull and process messages directly. This is an advanced alternative to handleCallback that works in any Node.js environment, both on and off Vercel.
Region considerations
Messages can only be received from the region they were sent to. When using receive, use a fixed region (e.g. "iad1") for both sending and receiving — do not use VERCEL_REGION (or QUEUE_REGION=${VERCEL_REGION}), because Vercel may route requests to different regions due to failover or load balancing, distributing your messages across regions unpredictably.
QUEUE_REGION=iad1
QUEUE_REGION=iad1
A single region is still highly available — Vercel deploys across 3+ availability zones within each region. If you need multi-region availability, you are responsible for designing your own HA strategy (e.g. sending to multiple regions and receiving from each).
For most use cases on Vercel, handleCallback is the recommended approach — the platform handles region routing automatically and the SDK routes follow-up calls to the correct region via the ce-vqsregion header.
Usage
import { QueueClient } from "@vercel/queue";
const { receive } = new QueueClient({ region: "iad1" });
const result = await receive(
"my-topic",
"my-group",
async (message, metadata) => {
console.log("Processing:", message);
},
);
if (!result.ok) {
console.log("Queue was empty:", result.reason);
}
await receive("my-topic", "my-group", handler, { limit: 10 });
await receive("my-topic", "my-group", handler, { messageId: "msg-123" });
Note: limit and messageId are mutually exclusive options. The handler is never called when the queue is empty — check result.ok instead.
Error Handling
import {
BadRequestError,
DuplicateMessageError,
ForbiddenError,
InternalServerError,
UnauthorizedError,
} from "@vercel/queue";
import { send } from "@/lib/queue";
try {
await send("my-topic", payload);
} catch (error) {
if (error instanceof UnauthorizedError) {
console.log("Invalid token - refresh authentication");
} else if (error instanceof ForbiddenError) {
console.log("Environment mismatch - check configuration");
} else if (error instanceof BadRequestError) {
console.log("Invalid parameters:", error.message);
} else if (error instanceof DuplicateMessageError) {
console.log("Duplicate message:", error.idempotencyKey);
} else if (error instanceof InternalServerError) {
console.log("Server error - retry with backoff");
}
}
All error types:
BadRequestError | Invalid request parameters |
UnauthorizedError | Authentication failed (invalid/missing token) |
ForbiddenError | Access denied (wrong environment/project) |
DuplicateMessageError | Idempotency key already used |
ConsumerDiscoveryError | Could not reach consumer deployment |
ConsumerRegistryNotConfiguredError | Project not configured for queues |
InternalServerError | Unexpected server error |
InvalidLimitError | Batch limit outside valid range (1-10) |
MessageNotFoundError | Message doesn't exist or expired |
MessageNotAvailableError | Message exists but cannot be claimed |
MessageAlreadyProcessedError | Message already successfully processed |
MessageLockedError | Message being processed by another consumer |
MessageCorruptedError | Message data could not be parsed |
QueueEmptyError | No messages available in queue |
Environment Variables
QUEUE_REGION | Region code for the queue client (user-defined) | - |
VERCEL_REGION | Current region (auto-set by Vercel) | - |
VERCEL_QUEUE_DEBUG | Enable debug logging (1 or true) | - |
VERCEL_DEPLOYMENT_ID | Deployment ID (auto-set by Vercel) | - |
Service Limits & Constraints
Throughput & Storage
| Message throughput | 10,000+ msg/sec/topic | Scales horizontally |
| Payload size | 1 GB | Smaller messages have lower latency |
| Number of topics | Unlimited | No hard limit |
| Consumer groups per message | ~4,000 | Per-message limit |
| Messages per queue | Unlimited | No hard limit |
Parameter Constraints
Publishing Messages
retentionSeconds | 86,400 (24h) | 60 | 86,400 | Message TTL |
delaySeconds | 0 | 0 | ≤ retention | Cannot exceed retention |
idempotencyKey | — | — | — | Dedup window: min(retention, 24h) |
Receiving Messages
visibilityTimeoutSeconds | 300 | 30 | 3,600 | Lock duration during processing |
limit | 1 | 1 | 10 | Messages per request |
Identifier Formats
| Topic name | [A-Za-z0-9_-]+ | my-queue, task_queue_v2 |
| Consumer group | [A-Za-z0-9_-]+ | worker-1, analytics_consumer |
| Message ID | Opaque string | 0-1, 3-7K9mNpQrS |
| Receipt handle | Opaque string | Used for acknowledge/visibility ops |
Wildcard Topics
{
"functions": {
"app/api/queue/route.ts": {
"experimentalTriggers": [{ "type": "queue/v2beta", "topic": "user-*" }]
}
}
}
* may only appear once in the pattern
* must be at the end of the topic name
- Valid:
user-*, orders-*
- Invalid:
*-events, user-*-data
API Reference
QueueClient
import { QueueClient } from "@vercel/queue";
const queue = new QueueClient({
region: process.env.QUEUE_REGION!,
resolveBaseUrl: (r) => `https://${r}.vercel-queue.com`,
token: "my-token",
headers: { "X-Custom": "value" },
transport: new JsonTransport(),
deploymentId: undefined,
});
const { send, receive, handleCallback, handleNodeCallback } = queue;
send(topicName, payload, options?)
Returns { messageId: string | null }. messageId is null when the server accepted the message for deferred processing (e.g. during a server-side outage).
const { messageId } = await send("my-topic", payload, {
idempotencyKey: "unique-key",
retentionSeconds: 3600,
delaySeconds: 60,
headers: { "X-Custom": "val" },
});
receive(topicName, consumerGroup, handler, options?)
Returns a discriminated result: { ok: true } on success, or { ok: false, reason } when no message was processed. The handler is never called when the queue is empty.
For receive-by-id, operational errors are returned instead of thrown:
const result = await receive("my-topic", "my-group", handler, {
messageId: "msg-123",
});
if (!result.ok) {
console.log(result.reason, result.messageId);
}
const result = await receive("my-topic", "my-group", handler, {
limit: 10,
visibilityTimeoutSeconds: 60,
});
handleCallback(handler, options?)
Vercel only. Returns (request: Request) => Promise<Response> — for frameworks that export Web API route handlers.
export const POST = handleCallback(
async (message, metadata) => {
await processMessage(message);
},
{
visibilityTimeoutSeconds: 300,
retry: (error, metadata) => {
},
},
);
handleNodeCallback(handler, options?)
Vercel only. Returns (req, res) => Promise<void> — for frameworks that export Connect-style handlers.
export default handleNodeCallback(
async (message, metadata) => {
await processMessage(message);
},
{
retry: (error, metadata) => ({ afterSeconds: 60 }),
},
);
Handler Signature
type MessageHandler<T> = (
message: T,
metadata: MessageMetadata,
) => Promise<void> | void;
interface MessageMetadata {
messageId: string;
deliveryCount: number;
createdAt: Date;
expiresAt?: Date;
topicName: string;
consumerGroup: string;
region: string;
}
License
MIT