QStash is Upstash's HTTP-based message queue — it delivers messages to your endpoints with automatic retries, scheduling, and delivery guarantees, all without a persistent server. For AI pipelines running on serverless infrastructure (Vercel, Cloudflare Workers, AWS Lambda), QStash solves the core problem: you can't run a long-lived worker process, but you still need reliable job execution with retries.
How QStash Works
You publish a message to QStash with a destination URL. QStash delivers it via HTTP POST to that URL, waits for a 2xx response, and retries on failure with exponential backoff. Your endpoint processes the job and returns 200 when done. No persistent workers, no WebSocket connections, no infrastructure to manage.
| Feature | Description |
|---|---|
| Delivery guarantee | At-least-once delivery with configurable retries |
| Retry policy | Exponential backoff, up to 5 retries by default |
| Scheduling | Publish with a delay or on a cron schedule |
| Deduplication | Content-based or custom deduplication keys |
| Callbacks | POST to a callback URL when the job completes |
Installation and Setup
npm install @upstash/qstash
# .env.local QSTASH_TOKEN=your-qstash-token # from console.upstash.com QSTASH_CURRENT_SIGNING_KEY=... # for verifying incoming messages QSTASH_NEXT_SIGNING_KEY=... # for key rotation
Publishing a Job
// app/api/trigger-analysis/route.ts import { Client } from "@upstash/qstash"; import { NextResponse } from "next/server"; const qstash = new Client({ token: process.env.QSTASH_TOKEN! }); export async function POST(req: Request) { const { documentId, userId } = await req.json(); // Publish a message — QStash will POST to /api/analyze with the payload const result = await qstash.publishJSON({ url: "https://yourapp.com/api/analyze", // must be a public HTTPS URL body: { documentId, userId }, retries: 5, delay: 0, // seconds (or "1m", "2h", etc.) headers: { "Content-Type": "application/json", }, }); return NextResponse.json({ messageId: result.messageId }); }
Processing Jobs
Your processing endpoint receives the job payload and must return 200 when done. QStash handles retries automatically if you return 4xx/5xx or the request times out.
// app/api/analyze/route.ts import { verifySignatureAppRouter } from "@upstash/qstash/nextjs"; import { NextResponse } from "next/server"; // verifySignatureAppRouter middleware validates that requests come from QStash async function handler(req: Request) { const { documentId, userId } = await req.json(); try { // Your processing logic const document = await fetchDocument(documentId); const analysis = await analyzeWithOpenAI(document.content); await saveAnalysis(documentId, analysis); // Notify user await sendNotification(userId, "Your document analysis is complete."); return NextResponse.json({ success: true }); } catch (error) { // Return 500 to trigger QStash retry console.error("Analysis failed:", error); return NextResponse.json({ error: "Processing failed" }, { status: 500 }); } } export const POST = verifySignatureAppRouter(handler);
Scheduled Jobs with QStash
// Create a recurring schedule (run every day at 9am UTC) const schedule = await qstash.schedules.create({ destination: "https://yourapp.com/api/daily-summary", cron: "0 9 * * *", body: JSON.stringify({ type: "daily-digest" }), headers: { "Content-Type": "application/json" }, }); console.log("Schedule ID:", schedule.scheduleId); // List all schedules const schedules = await qstash.schedules.list(); // Delete a schedule await qstash.schedules.delete(schedule.scheduleId);
Delayed Publishing
// Send a follow-up email 24 hours after signup await qstash.publishJSON({ url: "https://yourapp.com/api/emails/followup", body: { userId, email }, delay: 60 * 60 * 24, // 24 hours in seconds }); // Or use a natural language duration: await qstash.publishJSON({ url: "https://yourapp.com/api/emails/followup", body: { userId, email }, notBefore: Math.floor(Date.now() / 1000) + 86400, // Unix timestamp });
Chaining Jobs with Callbacks
// Job A — triggers Job B when complete via callback await qstash.publishJSON({ url: "https://yourapp.com/api/process-document", body: { documentId }, callback: "https://yourapp.com/api/on-document-processed", failureCallback: "https://yourapp.com/api/on-document-failed", });
For local development, use the Upstash QStash local emulator or use ngrok to expose your localhost to QStash. Alternatively, use a provider like Railway for your dev environment so you have a real public URL.QStash vs Inngest vs Trigger.dev
| Feature | QStash | Inngest | Trigger.dev |
|---|---|---|---|
| Architecture | HTTP queue — stateless | HTTP durable steps | WebSocket durable steps |
| Best for | Simple job queuing on serverless | Complex multi-step workflows | Long-running durable jobs |
| Step orchestration | None (single endpoint) | Full step.run() API | Full io.runTask() API |
| Self-hostable | No | Early access | Yes — open source |
| Free tier | 500 messages/day | 50k runs/month | 5k runs/month |
QStash is the right choice when you need simple, reliable job delivery on serverless infrastructure and don't need multi-step orchestration. For complex AI pipelines with branching logic, use Inngest or Trigger.dev instead.