Upstash QStash: Serverless Background Jobs Without the Infrastructure Pain
Background jobs are the unsexy problem that breaks every serverless-first project. Your Vercel Function times out at 10 seconds. Your email needs to send. Your webhook needs to retry. You end up with a Redis queue on a $20/month droplet just to run background work.
QStash is Upstash's answer: an HTTP-based message queue built for serverless.
What QStash Does
QStash sits between your app and your background work endpoints:
- Your app POSTs a message to QStash (fast, non-blocking)
- QStash delivers the message to your endpoint (with retries)
- Your endpoint processes it and returns 200
No persistent connection. No worker process. No Redis to manage.
// Publishing a background job
const qstash = new Client({ token: process.env.QSTASH_TOKEN! });
await qstash.publishJSON({
url: 'https://yourapp.com/api/jobs/send-email',
body: {
to: 'user@example.com',
templateId: 'welcome',
userId: user.id,
},
retries: 3,
delay: '30s', // optional delay
});
That's the entire publish side.
The Receiving Endpoint
Your job endpoint is a normal HTTP handler:
// app/api/jobs/send-email/route.ts (Next.js App Router)
import { verifySignatureAppRouter } from '@upstash/qstash/nextjs';
async function handler(req: Request) {
const body = await req.json();
await sendWelcomeEmail(body.to, body.userId);
return new Response('OK', { status: 200 });
}
// Verifies the request came from QStash (not arbitrary internet traffic)
export const POST = verifySignatureAppRouter(handler);
The verifySignatureAppRouter wrapper checks QStash's signing key. Unauthenticated requests get a 401.
Retry Behavior
QStash retries on any non-2xx response. The default schedule:
- Attempt 1: immediate
- Attempt 2: 10s
- Attempt 3: 30s
- Attempt 4: 1m
- Attempt 5: 2m
Configurable per-message:
await qstash.publishJSON({
url: 'https://yourapp.com/api/jobs/process-payment',
body: payload,
retries: 5,
backoff: 'exponential',
});
For idempotency, include a deduplication ID:
await qstash.publishJSON({
url: '...',
body: payload,
deduplicationId: `payment-${paymentId}`,
retries: 3,
});
Same deduplicationId within the dedup window (default 15 minutes) won't publish twice. Critical for payment and email jobs.
Scheduled Jobs (Cron)
QStash handles cron too, replacing the need for Vercel Cron or a separate scheduler:
await qstash.schedules.create({
destination: 'https://yourapp.com/api/cron/daily-digest',
cron: '0 9 * * *', // 9 AM daily
body: JSON.stringify({ type: 'daily-digest' }),
});
Or via the Upstash console. Schedules survive deploys and don't require vercel.json config.
Queues and Fan-Out
For rate-limited processing (like sending 10k emails without hammering your provider):
// Create a queue with concurrency limits
const queue = qstash.queue({ queueName: 'email-sends' });
await queue.upsert({ parallelism: 5 }); // max 5 concurrent jobs
// Enqueue jobs
for (const user of users) {
await queue.enqueueJSON({
url: 'https://yourapp.com/api/jobs/send-email',
body: { userId: user.id },
});
}
QStash will drain the queue at 5 concurrent jobs regardless of how fast you publish.
Pricing Reality Check
Free tier: 500 messages/day. More than enough for early-stage SaaS.
Paid: $1 per 100K messages. For an app sending 50K emails/month, that's $0.50. Compare to:
- BullMQ on Redis: ~$15-25/month for managed Redis
- Inngest: $12/month base for hobby
- AWS SQS: ~$0.40/million — comparable but more infrastructure
For pure serverless apps, QStash wins on simplicity + cost.
QStash vs Trigger.dev vs Inngest
| QStash | Trigger.dev v3 | Inngest | |
|---|---|---|---|
| Model | HTTP queue | SDK + runtime | SDK + runtime |
| Self-hostable | No | Yes | Yes |
| Background AI tasks | Basic | ✅ native | ✅ native |
| Free tier | 500/day | 50K runs/month | 50K runs/month |
| Best for | Simple HTTP jobs | Complex AI workflows | Event-driven SaaS |
QStash wins for simple, HTTP-native background work. Trigger.dev and Inngest win for complex, multi-step workflows with state — especially AI agent tasks.
Practical Setup for Next.js
npm install @upstash/qstash
// lib/qstash.ts
import { Client } from '@upstash/qstash';
export const qstash = new Client({
token: process.env.QSTASH_TOKEN!,
});
Environment variables needed:
QSTASH_TOKEN=your-token
QSTASH_CURRENT_SIGNING_KEY=signing-key
QSTASH_NEXT_SIGNING_KEY=next-signing-key
All three are in the Upstash console. The two signing keys rotate — QStash uses both for a transition period so deploys don't break in-flight messages.
When QStash Isn't the Right Tool
- Long-running compute jobs (>5 min): Use Trigger.dev or a dedicated worker. QStash has a 60-second endpoint timeout.
- Complex workflow orchestration: Inngest or Temporal handle multi-step workflows better.
- High-throughput processing (>1M/day): Custom BullMQ + Redis is cheaper at scale.
- You need local dev queue simulation: QStash dev mode exists but requires ngrok-style tunneling. BullMQ is simpler locally.
Conclusion
QStash is the right default for serverless background jobs under 1M/day. No workers, no Redis, no infrastructure — just publish and forget. Retries, scheduling, and fan-out are built in.
For new Vercel/serverless projects where you need anything async — email, webhooks, report generation — reach for QStash before reaching for a queue infrastructure.
Building a Next.js SaaS that needs background jobs, email, and webhooks wired from day one? The AI SaaS Starter Kit includes QStash job patterns, Resend email, and Stripe webhooks configured end-to-end for $99.
Top comments (0)