Upstash QStash expert for serverless message queues, scheduled jobs, and reliable HTTP-based task delivery without managing infrastructure.
Upstash QStash expert for serverless message queues, scheduled jobs, and reliable HTTP-based task delivery without managing infrastructure.
Sending messages to be delivered to endpoints
When to use: Need reliable async HTTP calls
import { Client } from '@upstash/qstash';
const qstash = new Client({ token: process.env.QSTASH_TOKEN!, });
// Simple message to endpoint await qstash.publishJSON({ url: 'https://myapp.com/api/process', body: { userId: '123', action: 'welcome-email', }, });
// With delay (process in 1 hour) await qstash.publishJSON({ url: 'https://myapp.com/api/reminder', body: { userId: '123' }, delay: 60 * 60, // seconds });
// With specific delivery time await qstash.publishJSON({ url: 'https://myapp.com/api/scheduled', body: { report: 'daily' }, notBefore: Math.floor(Date.now() / 1000) + 86400, // tomorrow });
Setting up recurring scheduled tasks
When to use: Need periodic background jobs without infrastructure
import { Client } from '@upstash/qstash';
const qstash = new Client({ token: process.env.QSTASH_TOKEN!, });
// Create a scheduled job const schedule = await qstash.schedules.create({ destination: 'https://myapp.com/api/cron/daily-report', cron: '0 9 * * *', // Every day at 9 AM UTC body: JSON.stringify({ type: 'daily' }), headers: { 'Content-Type': 'application/json', }, });
console.log('Schedule created:', schedule.scheduleId);
// List all schedules const schedules = await qstash.schedules.list();
// Delete a schedule await qstash.schedules.delete(schedule.scheduleId);
Verifying QStash message signatures in your endpoint
When to use: Any endpoint receiving QStash messages (always!)
// app/api/webhook/route.ts (Next.js App Router) import { Receiver } from '@upstash/qstash'; import { NextRequest, NextResponse } from 'next/server';
const receiver = new Receiver({ currentSigningKey: process.env.QSTASH_CURRENT_SIGNING_KEY!, nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY!, });
export async function POST(req: NextRequest) { const signature = req.headers.get('upstash-signature'); const body = await req.text();
// ALWAYS verify signature const isValid = await receiver.verify({ signature: signature!, body, url: req.url, });
if (!isValid) { return NextResponse.json( { error: 'Invalid signature' }, { status: 401 } ); }
// Safe to process const data = JSON.parse(body); await processMessage(data);
return NextResponse.json({ success: true }); }
Getting notified when messages are delivered or fail
When to use: Need to track delivery status for critical messages
import { Client } from '@upstash/qstash';
const qstash = new Client({ token: process.env.QSTASH_TOKEN!, });
// Publish with callback await qstash.publishJSON({ url: 'https://myapp.com/api/critical-task', body: { taskId: '456' }, callback: 'https://myapp.com/api/qstash-callback', failureCallback: 'https://myapp.com/api/qstash-failed', });
// Callback endpoint receives delivery status // app/api/qstash-callback/route.ts export async function POST(req: NextRequest) { // Verify signature first! const data = await req.json();
// data contains: // - sourceMessageId: original message ID // - url: destination URL // - status: HTTP status code // - body: response body
if (data.status >= 200 && data.status < 300) { await markTaskComplete(data.sourceMessageId); }
return NextResponse.json({ received: true }); }
Sending messages to multiple endpoints at once
When to use: Need to notify multiple services about an event
import { Client } from '@upstash/qstash';
const qstash = new Client({ token: process.env.QSTASH_TOKEN!, });
// Create a URL group await qstash.urlGroups.addEndpoints({ name: 'order-processors', endpoints: [ { url: 'https://inventory.myapp.com/api/process' }, { url: 'https://shipping.myapp.com/api/process' }, { url: 'https://analytics.myapp.com/api/track' }, ], });
// Publish to the group - all endpoints receive the message await qstash.publishJSON({ urlGroup: 'order-processors', body: { orderId: '789', event: 'order.placed', }, });
Preventing duplicate message processing
When to use: Idempotency is critical (payments, notifications)
import { Client } from '@upstash/qstash';
const qstash = new Client({ token: process.env.QSTASH_TOKEN!, });
// Deduplicate by custom ID (within deduplication window) await qstash.publishJSON({ url: 'https://myapp.com/api/charge', body: { orderId: '123', amount: 5000 }, deduplicationId: 'charge-order-123', // Won't send again within window });
// Content-based deduplication await qstash.publishJSON({ url: 'https://myapp.com/api/notify', body: { userId: '456', message: 'Hello' }, contentBasedDeduplication: true, // Hash of body used as ID });
Severity: CRITICAL
Situation: Endpoint accepts any POST request. Attacker discovers your callback URL. Fake messages flood your system. Malicious payloads processed as trusted.
Symptoms:
Why this breaks: QStash endpoints are public URLs. Without signature verification, anyone can send requests. This is a direct path to unauthorized message processing and potential data manipulation.
Recommended fix:
import { Receiver } from '@upstash/qstash';
const receiver = new Receiver({
currentSigningKey: process.env.QSTASH_CURRENT_SIGNING_KEY!,
nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY!,
});
export async function POST(req: NextRequest) {
const signature = req.headers.get('upstash-signature');
const body = await req.text(); // Raw body required
const isValid = await receiver.verify({
signature: signature!,
body,
url: req.url,
});
if (!isValid) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
// Safe to process
}
Severity: HIGH
Situation: Webhook handler does heavy processing. Takes 30+ seconds. QStash times out. Marks message as failed. Retries. Double processing begins.
Symptoms:
Why this breaks: QStash has a 30-second timeout for callbacks. If your endpoint doesn't respond in time, QStash considers it failed and retries. Long-running handlers create duplicate message processing and wasted retries.
Recommended fix:
export async function POST(req: NextRequest) {
// 1. Verify signature first (fast)
// 2. Parse and validate message (fast)
// 3. Queue for async processing (fast)
const message = await parseMessage(req);
// Don't do this:
// await processHeavyWork(message); // Could timeout!
// Do this instead:
await db.jobs.create({ data: message, status: 'pending' });
// Or use another QStash message for the heavy work
return NextResponse.json({ queued: true }); // Respond fast
}
// Webhook receives trigger
await qstash.publishJSON({
url: 'https://myapp.com/api/heavy-process',
body: { jobId: message.id },
});
return NextResponse.json({ delegated: true });
Severity: HIGH
Situation: Burst of events triggers mass message publishing. QStash rate limit hit. Messages rejected. Users don't get notifications. Critical tasks delayed.
Symptoms:
Why this breaks: QStash has plan-based rate limits. Free tier: 500 messages/day. Pro: higher but still limited. Bursts can exhaust limits quickly. Without monitoring, you won't know until users complain.
Recommended fix:
try {
await qstash.publishJSON({ url, body });
} catch (error) {
if (error.message?.includes('rate limit')) {
// Queue locally and retry later
await localQueue.add('qstash-retry', { url, body });
}
throw error;
}
// Instead of 100 individual publishes
await qstash.batchJSON({
messages: items.map(item => ({
url: 'https://myapp.com/api/process',
body: { itemId: item.id },
})),
});
Upstash Console shows usage and limits
Severity: HIGH
Situation: Network hiccup during publish. SDK retries. Same message sent twice. Customer charged twice. Email sent twice. Data corrupted.
Symptoms:
Why this breaks: Network failures and retries happen. Without deduplication, the same logical message can be sent multiple times. QStash provides deduplication, but you must use it for critical operations.
Recommended fix:
// Custom ID (best for business operations)
await qstash.publishJSON({
url: 'https://myapp.com/api/charge',
body: { orderId: '123', amount: 5000 },
deduplicationId: `charge-${orderId}`, // Same ID = same message
});
// Content-based (good for notifications)
await qstash.publishJSON({
url: 'https://myapp.com/api/notify',
body: { userId: '456', type: 'welcome' },
contentBasedDeduplication: true, // Hash of body
});
Check if operation already completed before processing
Severity: CRITICAL
Situation: Development works with local server. Deploy to production with internal URL. QStash can't reach it. All messages fail silently. No processing happens.
Symptoms:
Why this breaks: QStash runs in Upstash's cloud. It can only reach public, internet-accessible URLs. localhost, internal IPs, and private networks are unreachable. This is a fundamental architecture requirement, not a configuration issue.
Recommended fix:
ngrok http 3000
# Use the ngrok URL for QStash testing
// In development, skip QStash and call directly
if (process.env.NODE_ENV === 'development') {
await fetch('http://localhost:3000/api/process', {
method: 'POST',
body: JSON.stringify(data),
});
} else {
await qstash.publishJSON({ url, body: data });
}
Preview deploys give you public URLs for testing
Severity: MEDIUM
Situation: Critical payment webhook uses defaults. 3 retries over minutes. Payment processor is temporarily down for 15 minutes. Message marked as failed. Payment reconciliation manual work required.
Symptoms:
Why this breaks: Default retry behavior (3 attempts, short backoff) works for many cases but not all. Some endpoints need more attempts, longer backoff, or different strategies. One size doesn't fit all.
Recommended fix:
// Critical operations: more retries, longer backoff
await qstash.publishJSON({
url: 'https://myapp.com/api/payment-webhook',
body: { paymentId: '123' },
retries: 5,
// Backoff: 10s, 30s, 1m, 5m, 30m
});
// Non-critical notifications: fewer retries
await qstash.publishJSON({
url: 'https://myapp.com/api/analytics',
body: { event: 'pageview' },
retries: 1, // Fail fast, not critical
});
await qstash.publishJSON({
url: 'https://myapp.com/api/critical',
body: data,
failureCallback: 'https://myapp.com/api/dead-letter',
});
Severity: MEDIUM
Situation: Message contains entire document (5MB). QStash rejects - body too large. Even if accepted, slow to transmit. Expensive. Wastes bandwidth.
Symptoms:
Why this breaks: QStash has message size limits (around 500KB body). Large payloads slow delivery, increase costs, and can fail entirely. Messages should be lightweight triggers, not data carriers.
Recommended fix:
// BAD: Large payload
await qstash.publishJSON({
url: 'https://myapp.com/api/process',
body: { document: largeDocumentContent }, // 5MB!
});
// GOOD: Reference only
await qstash.publishJSON({
url: 'https://myapp.com/api/process',
body: { documentId: 'doc_123' }, // Fetch in handler
});
export async function POST(req: NextRequest) {
const { documentId } = await req.json();
const document = await storage.get(documentId); // Fetch actual data
await processDocument(document);
}
Severity: MEDIUM
Situation: Important task published. QStash delivers. Endpoint processes. But your system doesn't know it succeeded. User stuck waiting. No feedback loop.
Symptoms:
Why this breaks: QStash is fire-and-forget by default. Without callbacks, you don't know if messages were delivered successfully. For critical flows, you need the feedback loop to update state and handle failures.
Recommended fix:
await qstash.publishJSON({
url: 'https://myapp.com/api/send-email',
body: { userId: '123', template: 'welcome' },
callback: 'https://myapp.com/api/email-callback',
failureCallback: 'https://myapp.com/api/email-failed',
});
// app/api/email-callback/route.ts
export async function POST(req: NextRequest) {
// Verify signature first!
const data = await req.json();
// data.sourceMessageId - original message
// data.status - HTTP status code
// data.body - response from endpoint
await db.emailLogs.update({
where: { messageId: data.sourceMessageId },
data: { status: 'delivered' },
});
return NextResponse.json({ received: true });
}
// app/api/email-failed/route.ts
export async function POST(req: NextRequest) {
const data = await req.json();
await alerting.notify(`Email failed: ${data.sourceMessageId}`);
await db.emailLogs.update({
where: { messageId: data.sourceMessageId },
data: { status: 'failed', error: data.body },
});
}
Severity: MEDIUM
Situation: Scheduled daily report at "9am". But 9am in which timezone? QStash uses UTC. Report runs at 4am local time. Users confused. Support tickets filed.
Symptoms:
Why this breaks: QStash cron schedules run in UTC. If you think in local time but configure in UTC, schedules will run at unexpected times. This is especially tricky with daylight saving time changes.
Recommended fix:
// This runs at 9am UTC, not local time
await qstash.schedules.create({
destination: 'https://myapp.com/api/daily-report',
cron: '0 9 * * *', // 9am UTC
});
await qstash.schedules.create({
destination: 'https://myapp.com/api/daily-report',
cron: '0 14 * * *', // 9am EST (14:00 UTC)
body: JSON.stringify({
timezone: 'America/New_York',
localTime: '9:00 AM',
}),
});
Update schedules when DST changes, or accept UTC timing
Severity: MEDIUM
Situation: URL group has 5 endpoints. One service deprecated months ago. Messages still fan out to it. Failures in dashboard. Wasted attempts. Slower delivery.
Symptoms:
Why this breaks: URL groups persist until explicitly updated. When services change, endpoints become stale. QStash tries to deliver to dead URLs, wastes retries, and the failure noise obscures real issues.
Recommended fix:
const groups = await qstash.urlGroups.list();
for (const group of groups) {
console.log(`Group: ${group.name}`);
for (const endpoint of group.endpoints) {
// Check if endpoint is still valid
try {
await fetch(endpoint.url, { method: 'HEAD' });
console.log(` OK: ${endpoint.url}`);
} catch {
console.log(` DEAD: ${endpoint.url}`);
}
}
}
// Remove dead endpoint
await qstash.urlGroups.removeEndpoints({
name: 'order-processors',
endpoints: [{ url: 'https://old-service.myapp.com/api/process' }],
});
Check URL group health as part of deployment
Severity: CRITICAL
Message: QStash webhook handlers must verify signatures using Receiver
Fix action: Add signature verification: const receiver = new Receiver({ currentSigningKey, nextSigningKey }); await receiver.verify({ signature, body, url })
Severity: CRITICAL
Message: QStash Receiver must have both currentSigningKey and nextSigningKey for key rotation
Fix action: Configure both keys: new Receiver({ currentSigningKey: process.env.QSTASH_CURRENT_SIGNING_KEY, nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY })
Severity: CRITICAL
Message: QStash token must not be hardcoded - use environment variables
Fix action: Use process.env.QSTASH_TOKEN
Severity: CRITICAL
Message: QStash signing keys must not be hardcoded
Fix action: Use process.env.QSTASH_CURRENT_SIGNING_KEY and process.env.QSTASH_NEXT_SIGNING_KEY
Severity: CRITICAL
Message: QStash cannot reach localhost - endpoints must be publicly accessible
Fix action: Use a public URL (e.g., your deployed domain or ngrok for testing)
Severity: ERROR
Message: QStash requires HTTPS URLs for security
Fix action: Change http:// to https://
Severity: ERROR
Message: QStash publish calls should have error handling for rate limits and failures
Fix action: Wrap in try/catch and handle errors appropriately
Severity: CRITICAL
Message: Signature verification requires raw body (req.text()), not parsed JSON
Fix action: Use await req.text() to get raw body for verification
Severity: CRITICAL
Message: Callback endpoints must also verify signatures - they receive QStash requests too
Fix action: Add Receiver signature verification to callback handlers
Severity: ERROR
Message: QStash schedules require a destination URL
Fix action: Add destination: 'https://your-app.com/api/endpoint' to schedule options
Skills: upstash-qstash, nextjs-app-router, vercel-deployment
Workflow:
1. Define API route handlers (nextjs-app-router)
2. Configure QStash integration (upstash-qstash)
3. Deploy with environment vars (vercel-deployment)
Skills: upstash-qstash, stripe-integration, supabase-backend
Workflow:
1. Receive webhooks from Stripe (stripe-integration)
2. Queue for reliable processing (upstash-qstash)
3. Persist state to database (supabase-backend)
Skills: upstash-qstash, email-systems, supabase-backend
Workflow:
1. Configure cron schedule (upstash-qstash)
2. Query data for report (supabase-backend)
3. Send via email system (email-systems)
Skills: upstash-qstash, email-systems, slack-bot-builder
Workflow:
1. Publish to URL group (upstash-qstash)
2. Email handler receives (email-systems)
3. Slack handler receives (slack-bot-builder)
Skills: upstash-qstash, inngest
Workflow:
1. Start with simple QStash messages (upstash-qstash)
2. Identify multi-step patterns
3. Migrate complex flows to Inngest (inngest)
4. Keep simple schedules in QStash
Works well with: vercel-deployment, nextjs-app-router, redis-specialist, email-systems, supabase-backend, cloudflare-workers