// Publish to the group - all endpoints receive the message
await qstash.publishJSON({
urlGroup: 'order-processors',
body: {
orderId: '789',
event: 'order.placed',
},
});
Message Deduplication
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
});
Sharp Edges
Not verifying QStash webhook signatures
Severity: CRITICAL
Situation: Endpoint accepts any POST request. Attacker discovers your callback URL.
Fake messages flood your system. Malicious payloads processed as trusted.
Symptoms:
No Receiver import in webhook handler
Missing upstash-signature header check
Processing request before verification
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:
Always verify signatures with both keys:
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
}
Why two keys?
QStash rotates signing keys
nextSigningKey becomes current during rotation
Both must be checked for seamless key rotation
Callback endpoint taking too long to respond
Severity: HIGH
Situation: Webhook handler does heavy processing. Takes 30+ seconds. QStash times out.
Marks message as failed. Retries. Double processing begins.
Symptoms:
Webhook timeouts in QStash dashboard
Messages marked failed then retried
Duplicate processing of same message
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:
Design for fast acknowledgment:
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
}
For Vercel: Consider using Edge runtime for faster cold starts
Hitting QStash rate limits unexpectedly
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:
429 errors from QStash
Messages not being delivered
Sudden drop in processing during peak times
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:
Check your plan limits:
Free: 500 messages/day
Pay as you go: Check dashboard
Pro: Higher limits, check dashboard
Implement rate limit handling:
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;
}
Situation: Network hiccup during publish. SDK retries. Same message sent twice.
Customer charged twice. Email sent twice. Data corrupted.
Symptoms:
Duplicate charges or emails
Double processing of same event
User complaints about duplicates
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:
Use deduplication for critical messages:
// 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
});
Deduplication window:
Default: 60 seconds
Messages with same ID in window are deduplicated
Plan for this in your retry logic
Also make endpoints idempotent:
Check if operation already completed before processing
Expecting QStash to reach private/localhost endpoints
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:
Messages show "failed" in QStash dashboard
Works locally but fails in "production"
Using http:// instead of https://
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:
Production requirements:
URL must be publicly accessible
HTTPS required (HTTP will fail)
No localhost, 127.0.0.1, or private IPs
Local development options:
Option 1: ngrok/localtunnel
ngrok http 3000
# Use the ngrok URL for QStash testing
Option 2: QStash local development mode
// 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 });
}
Option 3: Use Vercel preview URLs
Preview deploys give you public URLs for testing
Using default retry behavior for all message types
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:
Critical messages marked failed
Manual intervention needed for retries
Temporary outages causing permanent failures
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.
Situation: Message contains entire document (5MB). QStash rejects - body too large.
Even if accepted, slow to transmit. Expensive. Wastes bandwidth.
Symptoms:
Message publish failures
Slow message delivery
High bandwidth costs
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.
export async function POST(req: NextRequest) {
const { documentId } = await req.json();
const document = await storage.get(documentId); // Fetch actual data
await processDocument(document);
}
Large data storage options:
S3/R2/Blob storage for files
Database for structured data
Redis for temporary data (Upstash Redis pairs well)
Not using callback/failureCallback for critical flows
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:
No visibility into message delivery
Users waiting for actions that completed
No alerting on failures
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.
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:
Schedules running at unexpected times
Off-by-one-hour issues during DST
User complaints about report timing
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:
QStash uses UTC:
// This runs at 9am UTC, not local time
await qstash.schedules.create({
destination: 'https://myapp.com/api/daily-report',
cron: '0 9 * * *', // 9am UTC
});
Update schedules when DST changes, or accept UTC timing
URL groups with dead or outdated endpoints
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:
Failed deliveries in URL groups
Messages to deprecated services
Slow fan-out due to timeouts
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:
Audit URL groups regularly:
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}`);
}
}
}