DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Upstash QStash: Serverless Background Jobs Without the Infrastructure Pain

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:

  1. Your app POSTs a message to QStash (fast, non-blocking)
  2. QStash delivers the message to your endpoint (with retries)
  3. 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
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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',
});
Enter fullscreen mode Exit fullscreen mode

For idempotency, include a deduplication ID:

await qstash.publishJSON({
  url: '...',
  body: payload,
  deduplicationId: `payment-${paymentId}`,
  retries: 3,
});
Enter fullscreen mode Exit fullscreen mode

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' }),
});
Enter fullscreen mode Exit fullscreen mode

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 },
  });
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// lib/qstash.ts
import { Client } from '@upstash/qstash';

export const qstash = new Client({
  token: process.env.QSTASH_TOKEN!,
});
Enter fullscreen mode Exit fullscreen mode

Environment variables needed:

QSTASH_TOKEN=your-token
QSTASH_CURRENT_SIGNING_KEY=signing-key
QSTASH_NEXT_SIGNING_KEY=next-signing-key
Enter fullscreen mode Exit fullscreen mode

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)