@vercel/queue
v0.11.0
Published
A Node.js library for interacting with the Vercel Queue Service API
Readme
Vercel Queues
A TypeScript client library for interacting with the Vercel Queue Service API, designed for seamless integration with Vercel deployments.
Features
- Simple API:
sendandhandleCallbackare all you need for push-based workflows - Automatic Triggering on Vercel: Vercel invokes your route handlers when messages are ready
- Works Anywhere:
sendandreceivework in any Node.js environment, including self-hosted and non-Vercel platforms - 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/queueQuick Start
1. Link your Vercel project and pull credentials:
The SDK authenticates via OIDC. Link your project if you haven't already, then pull to get fresh tokens:
npm i -g vercel
vc link # if you haven't already
vc env pullWhen using a Vercel CLI version with local Queues support, the CLI provides local credentials instead. See Local Development.
2. Send a message anywhere in your app:
import { send } from "@vercel/queue";
await send("my-topic", { message: "Hello world" });3. Handle incoming messages with a route handler:
// app/api/queue/my-topic/route.ts
import { handleCallback } from "@vercel/queue";
export const POST = handleCallback(async (message, metadata) => {
console.log("Processing:", message);
});4. Configure vercel.json:
{
"functions": {
"app/api/queue/my-topic/route.ts": {
"experimentalTriggers": [{ "type": "queue/v2beta", "topic": "my-topic" }]
}
}
}That's it. The top-level send and handleCallback use an auto-configured default client. The region is auto-detected from VERCEL_REGION (set automatically on Vercel). If the region can't be detected (e.g. local dev), it falls back to iad1.
To target a specific region for sending with the top-level send, pass the region option:
await send("my-topic", payload, { region: "sfo1" });Note: The
regionoption is only available on the top-levelsend()convenience export. When using aQueueClientinstance, the region is set once in the constructor vianew QueueClient({ region: "sfo1" })and applies to all operations on that client.
Local Development
With VERCEL_CLI_VERSION set to 59.26.0 or later, the SDK automatically connects to the broker identified by VERCEL_QUEUE_BASE_URL and authenticates with VERCEL_QUEUE_TOKEN. This requires both the updated SDK and a CLI that provisions the local resource. Detection applies only when NODE_ENV=development and VERCEL_DEPLOYMENT_ID is unset. Missing, invalid, or older CLI versions retain SDK-managed development delivery. Versions use semantic version ordering, so 59.26.0-canary.0 does not meet the minimum.
The broker owns delivery, delays, and redelivery through your HTTP callback routes. The SDK does not register or invoke in-process dev handlers, prime routes, or schedule local redelivery for these clients. handleCallback and handleNodeCallback still process HTTP callbacks, renew visibility, acknowledge messages, and forward retry directives to the broker. Single sends, batch sends, and PollingQueueClient all use the same local connection.
An explicit resolveBaseUrl overrides the injected endpoint. If it resolves to the broker's origin and pathname (ignoring trailing slashes), delivery remains broker-owned. An explicit token overrides the injected token. The injected token is never used for a different endpoint; that endpoint uses the explicit token or OIDC. Selecting the broker without an injected or explicit token produces a local configuration error instead of falling back to OIDC.
Resource configuration and the CLI version check are captured when each client is created, including the default client's first use. Callback region changes preserve this configuration. Region validation and polling's required region option remain unchanged.
During local resource detection, a malformed VERCEL_QUEUE_BASE_URL throws when the client is constructed (or on the default client's first use), unless an explicit resolveBaseUrl overrides it. The SDK does not silently fall back to the cloud endpoint for malformed local configuration.
Without a selected local broker, development mode retains the existing behavior: messages are sent to the Vercel Queue Service, then registered handlers are invoked in-process. Failed handlers are re-delivered locally using retryAfterSeconds from vercel.json or the retry callback's afterSeconds.
SDK-managed development delivery works with Next.js (Turbopack and webpack), Nuxt, SvelteKit, and other server-side JavaScript frameworks. The SDK discovers handlers from your vercel.json configuration and loads route modules on demand.
Note: Direct framework development servers can continue using OIDC and SDK-managed delivery. Automatic broker connection requires the local resource environment variables supplied by a supporting CLI.
Publishing Messages
import { send } from "@vercel/queue";
// Simple send
await send("my-topic", { message: "Hello world" });
// With options
await send(
"my-topic",
{ message: "Hello world" },
{
idempotencyKey: "unique-key", // Deduplicate message delivery
retentionSeconds: 3600, // 1 hour TTL (default: 24h, max: 7d)
delaySeconds: 60, // Delay delivery by 1 minute
region: "sfo1", // Top-level send() only — not available on QueueClient.send()
},
);Example usage in an API route:
// app/api/send-message/route.ts
import { send } from "@vercel/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:
messageIdisnullwhen the server accepts the message for deferred processing (e.g. during a server-side outage). The message will still be delivered.
Batch sends
Use sendBatch to publish between 1 and 100 messages in a single HTTP request. Each entry takes the same per-message options as send.
import { sendBatch } from "@vercel/queue";
const results = await sendBatch("orders", [
{ payload: { orderId: 1 } },
{ payload: { orderId: 2 }, idempotencyKey: "order-2", delaySeconds: 60 },
]);Results come back in input order, one per input message. A successful request can still contain failed entries, so inspect every result:
results.forEach((result, index) => {
if (result.status === "failed" && result.retryable) {
// retry messages[index]
}
});| status | Fields | Meaning |
| ------------ | ---------------------------------- | ----------------------------------------------------- |
| "sent" | messageId: string | Accepted with an assigned ID |
| "deferred" | messageId: null | Accepted for deferred processing, no ID available yet |
| "failed" | statusCode, error, retryable | Not accepted; retry only when retryable is true |
A request-level error (anything that makes the call throw) returns no per-message results and can happen after the server has already accepted some messages. To make the batch safe to retry, set idempotencyKey on every message in the first attempt, not only in the retry: the server deduplicates a republished message against the key it already saw, and a message sent without a key has nothing to deduplicate against. Keys cover publishing only — delivery stays at-least-once, so consumers still need to handle a message they have already processed.
Note:
sendBatchwas previously exported asexperimental_sendBatch. That name still works as a deprecated alias of the same function, so existing code keeps running — switch tosendBatchwhen convenient.
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:
// app/api/queue/my-topic/route.ts
import { handleCallback } from "@vercel/queue";
export const POST = handleCallback(
async (message, metadata) => {
// metadata: { messageId, deliveryCount, createdAt, expiresAt, topicName, consumerGroup, region }
await processMessage(message);
// Throwing an error will automatically retry the message
},
{
visibilityTimeoutSeconds: 600, // Lock duration while processing (default: 300)
},
);Nuxt:
// server/api/queue.ts
import { handleCallback } from "@vercel/queue";
const handler = handleCallback(async (message, metadata) => {
await processMessage(message);
});
export default defineEventHandler(async (event) => {
return handler(toWebRequest(event));
});SvelteKit:
// src/routes/api/queue/+server.ts
import { handleCallback } from "@vercel/queue";
export const POST = handleCallback(async (message, metadata) => {
await processMessage(message);
});Hono:
import { Hono } from "hono";
import { handleCallback } from "@vercel/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 (Vercel Node.js functions, Express, Next.js Pages Router, etc.). handleNodeCallback is not a top-level export — it is only available via a QueueClient instance:
// lib/queue.ts
import { QueueClient } from "@vercel/queue";
const queue = new QueueClient();
export const { handleNodeCallback } = queue;Vercel Node.js Functions (plain api/ directory):
// api/queue.ts
import { handleNodeCallback } from "./lib/queue";
export default handleNodeCallback(async (message, metadata) => {
await processMessage(message);
});Next.js Pages Router:
// pages/api/queue/my-topic.ts
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, max: 7 days).
For finer control over retry timing, pass a retry option. You can also set visibilityTimeoutSeconds to control how long the message is locked during processing (default: 300):
import { handleCallback } from "@vercel/queue";
export const POST = handleCallback(
async (message, metadata) => {
await processMessage(message);
},
{
visibilityTimeoutSeconds: 600, // Lock duration while processing (default: 300)
retry: (error, metadata) => {
if (error instanceof RateLimitError) return { afterSeconds: 60 };
// Return undefined to let the error propagate normally
},
},
);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):
import { handleCallback } from "@vercel/queue";
export const POST = handleCallback(
async (message, metadata) => {
await processMessage(message);
},
{
retry: (error, metadata) => {
// 5s → 10s → 20s → 40s → ... capped at 5 min
const delay = Math.min(300, 2 ** metadata.deliveryCount * 5);
return { afterSeconds: delay };
},
},
);Conditional retry — only retry transient errors:
import { handleCallback } from "@vercel/queue";
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 };
// Permanent errors: return undefined → retried at the default interval
},
},
);Acknowledging poison messages — stop retrying messages that can never succeed:
import { handleCallback } from "@vercel/queue";
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.
Experimental: Send and Hold
Experimental.
experimental_holdneeds a Queue Service with send-and-hold enabled, and every part of this API — the option, theexperimental_held/experimental_notHeldresult fields, and their types — may change in any minor release. Servers without send-and-hold ignore the option and deliver the message as usual, so the same code runs either way — but always branch on the result instead of assuming the hold was taken.
Sometimes the code that sends a message wants to do the work itself, right away, instead of waiting for a delivery: for example a route that starts a long model call and streams the first tokens back. With experimental_hold, send asks the queue to claim the message for one consumer group on your behalf before it returns. You run the message here with experimental_held.consume(handler), and the queue is the backstop: if this process dies, the message is delivered to that consumer group as usual about visibilityTimeoutSeconds after the last renewal.
import { waitUntil } from "@vercel/functions";
import { QueueClient, type MessageMetadata } from "@vercel/queue";
const queue = new QueueClient();
const retry = (error: unknown, metadata: MessageMetadata) => ({
afterSeconds: Math.min(60, 2 ** metadata.deliveryCount),
});
// The same handler the consumer route uses:
// export const POST = queue.handleCallback(runTurn, { retry });
async function runTurn(turn: Turn, metadata: MessageMetadata) {
await runModel(turn);
}
export async function POST(request: Request) {
const turn = await request.json();
const { experimental_held: held } = await queue.send("turns", turn, {
// Always send an idempotencyKey with a hold — see "Limits and caveats".
idempotencyKey: turn.id,
experimental_hold: {
consumerGroup: "default",
visibilityTimeoutSeconds: 30,
},
});
if (held) {
// Runs here. If this instance dies, "turns" delivers the message to the
// consumer route about 30 seconds after the last renewal.
waitUntil(held.consume(runTurn, { retry }).catch(console.error));
}
// Not held: someone else runs the message. Nothing to do here.
return Response.json({ id: turn.id });
}experimental_held.consume(handler, { retry }) runs the handler the way a delivery to consumerGroup runs it, with the same metadata:
- The SDK renews the hold every
min(60, max(10, visibilityTimeoutSeconds / 5))seconds while the handler runs (every 10 seconds for the default 30-second hold), so the handler can run for as long as it needs. - When the handler returns, the message is acknowledged. When it throws,
retrycan reschedule or acknowledge it, as for a delivery. Without a directive,consumerejects with the error and the hold lapses, so the message is delivered as usual. - The handler gets the payload deserialized from the bytes that were sent,
deliveryCount1, and the message'screatedAtandexpiresAt.
Call consume at most once, and as soon as send returns: nothing renews the hold until it starts. To give the message back without running it, call experimental_held.release(delaySeconds) instead: it is delivered as usual after the delay (default 0). If you call neither, nothing renews the hold and the message is delivered once it lapses.
When the message is not held
The result then has experimental_notHeld.reason instead of experimental_held, and something other than this process is responsible for the message. Do not process it yourself:
| experimental_notHeld.reason | Meaning |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| unsupported | The server ignored the hold (send-and-hold not enabled), or local development. The queue delivers the message |
| deferred | The server accepted the message for deferred processing (messageId is null) and delivers it once it is ingested |
| lost | Another claim on the message won the race. The queue delivers it |
| coalesced | Only with experimental_concurrencyKey: the key is held by another live delivery, which covers this message. It is not delivered on its own |
| concurrency_limited | Only on consumer groups with maxConcurrency: no delivery slot was free. The queue delivers the message once one frees |
| duplicate | The idempotency key belongs to experimental_notHeld.originalMessageId, which runs elsewhere |
| error | The hold could not be taken, or its answer could not be read. The queue delivers the message once any lease taken for it lapses |
Limits and caveats
- Always send an
idempotencyKey. A send that fails with a network error may already have enqueued the message: without a key, retrying it enqueues a second message, and both the retry's hold and the first message's delivery run the work. With one, the retry getsexperimental_notHeld: { reason: "duplicate" }and only one of them runs. consumerGroupmust be a registered consumer of the topic on the target deployment, when it has any (for a Vercel trigger, the trigger'sconsumer), and must not havemaxAttemptsconfigured. Otherwisesendthrows aBadRequestErrorand the message is not enqueued.visibilityTimeoutSecondsis an integer from 30 to 3600 (default 30) and cannot exceedretentionSeconds. Holds that may run for a long time need enough retention: the hold cannot be renewed past the message'sexpiresAt.- A hold cannot be combined with
delaySeconds. A hold already keeps the message from every other consumer until it lapses or you release it. - The transport must serialize to a
Buffer(JsonTransport,BufferTransport, or your own);StreamTransportis rejected. - Single sends only:
sendBatchcannot hold messages. - Delivery stays at-least-once. If this process stalls for longer than the hold, the queue can deliver the message elsewhere while the handler is still running here, so make the handler idempotent. The SDK logs and stops renewing once it sees the lease is gone, but it cannot interrupt a handler that is already running — the handler is not signalled when the lease is lost.
- A hold counts against a consumer group's
maxConcurrencylike a delivery does. On amaxConcurrency: 1group, a sender that is itself handling a delivery of that group holds the only slot until it acknowledges, so its hold is alwaysconcurrency_limited. - On an unpinned client (
deploymentId: null) no deployment ID is sent, so the server checksconsumerGroupagainst whichever deployment consumer discovery picks, and a lapsed hold is delivered there — which may be code other than the handler running here. Pin sends (the default) when the two must match. - Local development: the hold is not requested, so the result is always
experimental_notHeld: { reason: "unsupported" }and the message is delivered by the dev handlers or the local broker, as for any send. The held path —consume, its renewals andrelease— therefore only runs against a real Queue Service, so exercise it on a preview deployment rather than locally. Everything that can be checked before the request (the option's shape,delaySeconds,retentionSeconds, the transport) is still validated locally, so a hold that cannot work fails the same way in both places.
Custom Client Configuration
For most use cases, the top-level send and handleCallback functions are all you need. For advanced configuration (custom transports, explicit tokens, deployment pinning), create a QueueClient instance directly.
QueueClient (push mode)
For push-based workflows where Vercel delivers messages to your route handlers:
import { QueueClient, BufferTransport } from "@vercel/queue";
const queue = new QueueClient({
region: "iad1", // Optional — auto-detected from VERCEL_REGION, falls back to "iad1"
token: "my-token", // Auth token (default: OIDC auto-detection)
transport: new BufferTransport(), // Serialization (default: JsonTransport)
headers: { "X-Custom": "header" }, // Custom headers on all requests
deploymentId: null, // null = unpinned, omit = auto from env, or explicit string
});
export const { send, handleCallback, handleNodeCallback } = queue;PollingQueueClient (poll mode)
For manual polling workflows where you call receive to poll for messages. Works anywhere — on Vercel, self-hosted, or any Node.js environment:
import { PollingQueueClient, BufferTransport } from "@vercel/queue";
const queue = new PollingQueueClient({
region: "iad1", // Required — messages must be received from a fixed region
token: "my-token",
transport: new BufferTransport(),
headers: { "X-Custom": "header" },
deploymentId: null,
});
export const { send, receive } = queue;Both clients send 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 that returns a URL:
// Custom domain
const queue = new QueueClient({
resolveBaseUrl: (region) => new URL(`https://${region}.my-proxy.example`),
});
// Custom domain with a base path (e.g. reverse proxy prefix)
const queue = new QueueClient({
resolveBaseUrl: (region) =>
new URL(`https://my-proxy.example/queues/${region}`),
// → requests go to https://my-proxy.example/queues/<region>/api/v3/…
});The SDK always appends its own API path (/api/v3/…) to the returned URL.
Transports
The transport controls how message payloads are serialized and deserialized.
| Use Case | Transport | Memory Usage | Notes |
| --------------- | ----------------- | ------------ | ----------------------- |
| 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";
// JSON with custom serialization
const queue = new QueueClient({
transport: new JsonTransport({
replacer: (key, value) => (key === "password" ? undefined : value),
reviver: (key, value) => (key === "date" ? new Date(value) : value),
}),
});
// Binary data
const binQueue = new QueueClient({
transport: new BufferTransport(),
});
await binQueue.send("binary-topic", myBuffer);
// Streaming for large payloads
const streamQueue = new QueueClient({
transport: new StreamTransport(),
});
await streamQueue.send("large-file", myReadableStream);Manual Receive
Use PollingQueueClient to poll for 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, because Vercel may route requests to different regions due to failover or load balancing, distributing your messages across regions unpredictably.
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 via QueueClient 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 { PollingQueueClient } from "@vercel/queue";
const { send, receive } = new PollingQueueClient({ region: "iad1" });
// Send a message
await send("my-topic", { message: "Hello world" });
// Process next available message
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);
}
// Batch processing: up to 10 messages in one request
await receive("my-topic", "my-group", handler, { limit: 10 });
// Prefer the newest messages when a source-shard cursor is missing
await receive("my-topic", "my-group", handler, { cursorFallback: "latest" });
// Process a specific message by ID
await receive("my-topic", "my-group", handler, { messageId: "msg-123" });Note:
limitandmessageIdare mutually exclusive options.cursorFallbackapplies only to polling receives and is sent on every polling receive when configured, so consumers sharing a group stay consistent. It can be combined withlimit, only affects missing source-shard cursors, and never resets existing cursors. Omit it to preserve the server default. The handler is never called when the queue is empty — checkresult.okinstead.
Error Handling
import {
BadRequestError,
ForbiddenError,
InternalServerError,
UnauthorizedError,
send,
} from "@vercel/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 InternalServerError) {
console.log("Server error - retry with backoff");
}
}All error types:
| Error | Description |
| ------------------------------------ | --------------------------------------------- |
| BadRequestError | Invalid request parameters |
| UnauthorizedError | Authentication failed (invalid/missing token) |
| ForbiddenError | Access denied (wrong environment/project) |
| 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
| Variable | Description | Default |
| ---------------------- | ------------------------------------ | ------- |
| 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
| Limit | Value | Notes | | --------------------------- | --------------------- | ----------------------------------- | | Message throughput | 10,000+ msg/sec/topic | Scales horizontally | | Payload size | 100 MB | 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
| Parameter | Default | Min | Max | Notes |
| ------------------ | ------------ | --- | ------------ | --------------------------------------- |
| retentionSeconds | 86,400 (24h) | 60 | 604,800 (7d) | Message TTL |
| delaySeconds | 0 | 0 | 604,800 (7d) | Cannot exceed retention |
| idempotencyKey | — | — | 256 chars | Dedup window: min(retention, 24h) |
| messages | — | 1 | 100 | sendBatch only — messages per request |
Receiving Messages
| Parameter | Default | Min | Max | Notes |
| -------------------------- | ------- | --- | ----- | ------------------------------- |
| visibilityTimeoutSeconds | 300 | 30 | 3,600 | Lock duration during processing |
| limit | 1 | 1 | 10 | Messages per request |
Identifier Formats
| Identifier | Pattern | Example |
| -------------- | ---------------- | ----------------------------------- |
| 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
Top-level send(topicName, payload, options?)
The simplest way to send a message. Uses an auto-configured default client that detects the region from VERCEL_REGION, falling back to "iad1" with a console warning on first use.
import { send } from "@vercel/queue";
const { messageId } = await send("my-topic", payload, {
idempotencyKey: "unique-key", // Dedup window: min(retention, 24h), max 256 chars
retentionSeconds: 3600, // Message TTL (default: 86400, max: 604800)
delaySeconds: 60, // Delay before visible (default: 0, max: 7d)
headers: { "X-Custom": "val" }, // Custom headers
region: "sfo1", // Override the auto-detected region for this send
});The region option is exclusive to the top-level send(). It creates a one-off client targeting the given region. When using QueueClient, the region is set once in the constructor and applies to all operations.
Returns { messageId: string | null }. messageId is null when the server accepted the message for deferred processing (e.g. during a server-side outage). With the experimental experimental_hold option the result also carries experimental_held or experimental_notHeld — see Send and Hold.
Top-level sendBatch(topicName, messages, options?)
Publishes between 1 and 100 messages in one HTTP request. Uses the same auto-configured default client as send. A batch size outside that range throws a RangeError before any request is made.
import { sendBatch } from "@vercel/queue";
const results = await sendBatch(
"orders",
[
{ payload: { orderId: 1 } },
{ payload: { orderId: 2 }, idempotencyKey: "order-2", delaySeconds: 60 },
],
{ region: "sfo1" }, // Top-level sendBatch() only — routes the whole batch
);Each entry accepts the per-message options of send (idempotencyKey, retentionSeconds, delaySeconds, headers). region applies to the whole batch and, like send, is exclusive to the top-level function.
Returns BatchSendResult[] in input order — see Batch sends for the result shapes and retry guidance.
Also available as sendBatch on QueueClient and PollingQueueClient instances (without the region option — the client's region applies). The previous name, experimental_sendBatch, is a deprecated alias of the same function on all three.
Top-level handleCallback(handler, options?)
The simplest way to handle incoming queue messages. Uses the same auto-configured default client as send.
import { handleCallback } from "@vercel/queue";
export const POST = handleCallback(
async (message, metadata) => {
await processMessage(message);
},
{
visibilityTimeoutSeconds: 300, // Lock duration (default: 300)
retry: (error, metadata) => {
// Optional: return { afterSeconds: N } to reschedule, { acknowledge: true } to ack, or undefined to propagate
},
},
);Returns (request: Request) => Promise<Response> — for frameworks that export Web API route handlers. Vercel only. The region for follow-up API calls is determined automatically from the ce-vqsregion header in the incoming event.
Callback responses
| Outcome | Status | Body |
| ------------------------------------------------------- | ------ | ---------------------------------------------- |
| Handler completed and the message was acknowledged | 200 | { status: "success" } |
| Redundant delivery — nothing left to do | 200 | { status: "skipped", reason, messageId } |
| Malformed CloudEvent (bad content type, missing fields) | 400 | { error } |
| Handler threw, or the message could not be processed | 500 | { error: "Failed to process queue message" } |
Delivery is at-least-once, so the same message can arrive more than once. When the server reports that a message was already processed (HTTP 410) or is no longer available to this consumer, e.g. because another consumer holds the lease (HTTP 409), that delivery is skipped rather than failed: it is logged below error level and answered with 200, because failing it would only schedule another delivery of a message that is already settled. reason is "already_processed" or "not_available" — the same vocabulary receive reports. A message that does not exist or has expired (HTTP 404) is not treated this way and still responds 500.
QueueClient
Push-based client for workflows where Vercel delivers messages to your route handlers. Use this when you need custom configuration (transport, token, headers, deployment pinning). Region is auto-detected from VERCEL_REGION (set automatically on Vercel), falling back to "iad1" with a console warning.
import { QueueClient } from "@vercel/queue";
const queue = new QueueClient({
region: "iad1", // Optional — auto-detected from VERCEL_REGION, falls back to "iad1"
resolveBaseUrl: (r) => new URL(`https://${r}.vercel-queue.com`), // Default resolver
token: "my-token", // Auto-fetched via OIDC if omitted
headers: { "X-Custom": "value" },
transport: new JsonTransport(), // Default: JsonTransport
deploymentId: undefined, // omit = auto from env (pinned), null = unpinned, or explicit string
});
// Methods (arrow functions — safe to destructure)
const { send, sendBatch, handleCallback, handleNodeCallback } = queue;PollingQueueClient
Poll-based client for manually receiving messages. Works in any Node.js environment, including self-hosted and non-Vercel platforms. Region is required to ensure send and receive target the same endpoint.
import { PollingQueueClient } from "@vercel/queue";
const queue = new PollingQueueClient({
region: "iad1", // Required — use a fixed region for polling
// ... same options as QueueClient (except region is required)
});
// Methods (arrow functions — safe to destructure)
const { send, sendBatch, receive } = queue;receive(topicName, consumerGroup, handler, options?)
Available on PollingQueueClient only.
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) {
// result.reason is "not_found" | "not_available" | "already_processed"
console.log(result.reason, result.messageId);
}// Batch mode
const result = await receive("my-topic", "my-group", handler, {
limit: 10, // Max messages (default: 1, max: 10)
visibilityTimeoutSeconds: 60, // Lock duration (default: 300)
});handleNodeCallback(handler, options?)
Available on QueueClient instances only (not a top-level export). Vercel only.
Returns (req, res) => Promise<void> — for frameworks that export Connect-style handlers.
import { QueueClient } from "@vercel/queue";
const queue = new QueueClient();
const { handleNodeCallback } = queue;
// pages/api/queue/my-topic.ts
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
