We schedule social posts to the exact second with Upstash QStash. Turns out the delivery signature can go stale before the message ever fires — here's the 401-into-a-dead-letter-queue bug, the fix, and the free-tier gotcha we hit chasing it.
title: "QStash JWTs Expire Before Delivery: A Far-Future Scheduling Postmortem"
description: "We schedule social posts to the exact second with Upstash QStash. Turns out the delivery signature can go stale before the message ever fires — here's the 401-into-a-dead-letter-queue bug, the fix, and the free-tier gotcha we hit chasing it."
tags: showdev, serverless, webdev, nextjs
published: false
I run PostStage, a social-media scheduler — connect Instagram, LinkedIn, YouTube, Pinterest, etc., write a post once, pick an exact publish time. The scheduling engine is Upstash QStash: enqueue a message with a notBefore timestamp, QStash holds it and delivers a webhook at that exact second. No polling loop, no cron guessing — it just fires.
That worked great until users started scheduling things more than a few days out, and a slice of those posts silently died.
The symptom
Posts scheduled roughly 4–5 days ahead were coming back from QStash as an HTTP 401 on delivery, retrying three times, then landing in QStash's dead-letter queue. They'd eventually fire — hours late — only because a separate recovery sweep happened to re-enqueue them. Nobody was touching auth code. The webhook worked fine for anything scheduled a day or two out.
Root cause: the signature has its own clock, separate from the delivery time
QStash signs the webhook request as a JWT at the moment you enqueue the message, not at delivery time. We verify it with jose.jwtVerify(), which checks exp/nbf with zero clock tolerance. That signature has its own expiry — and if a message sits in QStash's queue long enough before its scheduled delivery slot, the signature goes stale before the message ever gets sent. QStash still delivers it (that part works exactly as documented) — it just arrives carrying a JWT that's already expired by the time our endpoint checks it. Receiver.verify() rejects it, we return 401, QStash retries into the same expired signature three more times, then gives up and parks it in the DLQ.
At creation time:
scheduledAt within QSTASH_WINDOW_MS of now?
YES → enqueue to QStash immediately (status → QUEUED)
NO → leave status SCHEDULED, qstashMessageId: null
(expected state — not a failure, just "not due for dispatch yet")
dispatchDuePosts() (runs on a daily cron, or a frequent external driver):
find posts: SCHEDULED + no qstashMessageId + scheduledAt <= now + QSTASH_WINDOW_MS
→ enqueue each to QStash now
QStash still delivers at the exact scheduled second — the window only controls how long a signature has to survive before it's used, never delivery accuracy. Default QSTASH_WINDOW_MS is 24h, which piggybacks on a cron we already run daily and keeps every hold well under the ~4 day threshold where we saw 401s. A tighter setup points an external scheduler at a dedicated dispatch endpoint every ~5 minutes and shrinks the window to ~30 minutes, so signatures barely age before use — but that requires the frequent driver to actually be running, or early-scheduled posts wait for the daily sweep and fire late.
We also added a recovery pass for anything that did slip through: a QUEUED post, well past due, with zero publish-log entries (our webhook's first action is always logging "publishing started," so zero rows means the delivery never actually executed) gets its stale message cancelled and re-enqueued fresh. And the publish webhook does an atomic status-claim (SCHEDULED|QUEUED → PUBLISHING) so an at-least-once redelivery or a recovery re-enqueue can never double-publish the same post.
The gotcha hiding behind the "just poll more often" instinct
The obvious way to shrink the signature-staleness window further is to run the dispatch driver more frequently. We tried that on our own free-tier Postgres (Neon) — and burned through a full month's compute allowance in days. Neon's free tier autosuspends the database after ~5 minutes idle; a driver polling faster than that never lets it sleep, so the database stays "active" around the clock even when there's nothing to do. It's an easy trap for anyone bootstrapping on free-tier infra: the fix for one problem (signature staleness) can quietly reintroduce a cost problem you already solved with autosuspend.
Takeaways
- If a queue signs its delivery payload at enqueue time rather than delivery time, a long hold can expire the signature before the message ever fires — check whether your queue's docs mention this before you assume "scheduled but not yet due" is a safe state to sit in indefinitely.
- A 401 on webhook delivery isn't always an auth code bug — it can be a timing bug wearing an auth error's clothes.
- When you fix a timing problem by polling more often, check what else is on the clock you're now keeping alive.
PostStage's scheduler runs on this today — posts fire to the exact second whether they're scheduled for five minutes from now or three months out. If you're building something similar on QStash (or any provider that signs webhooks at enqueue time), happy to compare notes in the comments.
The exact-second delivery mechanism was never broken. The signature just wasn't built to survive being enqueued days in advance.
The fix: don't hand a message to QStash before you have to
The real fix isn't in QStash's configuration — it's in not enqueueing far-future posts early at all. We hold them locally and only push to QStash once they're close to due:
Top comments (0)