Why your delayed reminder never fired, and how to find out
Five states swallow a scheduled notification: the 7-day cap, a message still maturing, an idle consumer, an expired lease, the DLQ. Each with the counter that identifies it.
A reminder that was scheduled and never showed up has a small number of possible explanations, and a single call separates them. GET /v1/queue/stats/{queue} returns five counters, and the shape of those counters points at the cause faster than reading your worker logs. Infrai’s queue is pull-based with a hard seven-day delay ceiling, so nearly every “the nudge never went out” report lands in one of five states.
Start by ruling out the one people hit most: delay_seconds accepts 0 to 604800, and a fortnight is rejected at publish time. Everything below was run against api.infrai.cc on 27 July 2026.
Read the counters first
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/queue/stats/reminders-delayed" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"queue": "reminders-delayed",
"message_count": 1,
"available_count": 0,
"in_flight_count": 0,
"delayed_count": 1,
"dlq_count": 0,
"oldest_message_age_seconds": 0
}
}
delayed_count is messages still maturing. available_count is what a consumer would receive right now. in_flight_count is what somebody already took and hasn’t acknowledged. dlq_count is the graveyard. If the name comes back as QUEUE_NOT_FOUND you’re polling a different queue than you published to — worth checking first, since a queue is created implicitly by its first publish and a typo produces a perfectly healthy queue nobody reads.
| What you see | Counter signature | What actually happened |
|---|---|---|
| Nothing was ever scheduled | queue missing, or all counters 0 | the publish was rejected, or went to another queue name |
| Reminder is still pending | delayed_count above 0 | working as intended; it hasn’t matured |
| Reminder is stuck ready | available_count above 0 and not falling | no consumer is polling |
| Reminder keeps re-appearing | in_flight_count cycling | the handler outlives its lease |
| Reminder is gone | dlq_count above 0 | it exhausted max_retries |
The publish was rejected and nobody read the envelope
Fourteen days is a natural thing to ask for, and it comes back as a 400 that names the ceiling:
curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"queue":"reminders-delayed","payload":{"user_id":"u_4412","kind":"trial_ending"},"delay_seconds":1209600}'
{
"ok": false,
"error": {
"code": "QUEUE_DELAY_INVALID",
"http_status": 400,
"message": "delay_seconds must be 0..604800 (7 days)",
"retryable": false,
"hint": "Queue delay_seconds is outside 0..604800."
}
}
retryable: false is the instruction: fix the schedule, don’t back off and try again. The trap isn’t the error, it’s the client — a publish helper that checks the HTTP status and never inspects the ok field in the JSON envelope will treat this as a send. That’s how a whole cohort of trial-ending nudges goes missing at once.
The message is parked, not lost
A successful publish echoes the maturity date, and that’s the field to compare against your intent:
curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"queue":"reminders-delayed","payload":{"user_id":"u_4412","kind":"trial_ending"},"delay_seconds":60}'
{
"ok": true,
"data": {
"message_id": "qmsg_S0aWYUNgBHEgLtmw1K5YntDB",
"queue": "reminders-delayed",
"payload": { "user_id": "u_4412", "kind": "trial_ending" },
"delivery_count": 0,
"published_at": "2026-07-27T11:43:06.544899Z",
"available_at": "2026-07-27T11:44:06Z"
}
}
available_at minus published_at is your delay, in writing. A 60-second canary that returns nothing from POST /v1/queue/consume at five seconds and returns the message once the minute is up is the cheapest end-to-end proof you can run before blaming your own scheduler.
It failed too many times and left
A queue created with dead_letter_queue and max_retries moves a poison message aside once the nack count is reached; the default is three. The dead-letter listing is a first-class read and agrees with the counter:
curl -sS "https://api.infrai.cc/v1/queue/dlq/list/reminders-delayed" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{
"message_id": "qmsg_buDTp1LIqM9X8u00Q9gYaHAh",
"queue": "reminders-delayed.dlq",
"payload": { "user_id": "u_4412", "kind": "trial_ending" },
"status": "available",
"delivery_count": 0,
"published_at": "2026-07-27T11:48:26.634253Z"
}
],
"next_cursor": null
}
}
Every row there is a user who was promised something and didn’t get it, with the payload attached. Once the underlying fault is fixed — an expired push token, a mailer answering 500 — put them all back with one call, or pass a single message_id to replay just one:
curl -sS -X POST "https://api.infrai.cc/v1/queue/dlq/redrive/reminders-delayed" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"queue":"reminders-delayed"}'
{ "ok": true, "data": { "queue": "reminders-delayed", "redriven": 1 } }
Nobody was polling, or the lease kept lapsing
A matured message sits in available_count until something asks for it. If that number climbs while your worker is up, the worker is polling a queue it only thinks it shares with the producer. And if in_flight_count oscillates without the message ever leaving, your handler is running past the 300-second default visibility timeout, so the lease expires and the same reminder is delivered again. Delivery is at-least-once by design, which is the trade-off you accept for a broker that never drops work: your send path has to be idempotent, keyed on something like user_id plus reminder kind plus due date.
Retention is 14 days. A reminder nobody consumed for a fortnight is gone, with no dead-letter row to show for it.
One script that answers all five
import process from "node:process";
const API = "https://api.infrai.cc";
const key = process.env.INFRAI_API_KEY;
const queue = process.argv[2] ?? "reminders-delayed";
if (!key) throw new Error("INFRAI_API_KEY is required");
const headers = { Authorization: `Bearer ${key}`, "Content-Type": "application/json" };
async function get(path) {
const res = await fetch(`${API}${path}`, { headers });
const out = await res.json();
if (!out.ok) throw new Error(`${path}: ${out.error.code} ${out.error.message}`);
return out.data;
}
const q = encodeURIComponent(queue);
const stats = await get(`/v1/queue/stats/${q}`);
console.log("counters:", stats);
if (stats.delayed_count > 0) console.log(`${stats.delayed_count} still maturing — early, not lost`);
if (stats.available_count > 0) console.log(`${stats.available_count} ready and unclaimed — is a consumer running?`);
if (stats.in_flight_count > 0) console.log(`${stats.in_flight_count} leased but unacked — compare handler duration with the 300s timeout`);
if (stats.dlq_count > 0) {
const dead = await get(`/v1/queue/dlq/list/${q}`);
for (const msg of dead.items) console.log(`dead: ${msg.message_id}`, msg.payload);
}
Run it with the queue name as the first argument and it prints a verdict instead of a wall of counters.
Past seven days, own the schedule yourself
Holding a notification inside a broker for days makes the schedule invisible to your product: you can’t list what’s pending, you can’t cancel when the user finally does the thing you were going to nag them about, and you can’t move a send time. A due_at column plus a sweep that publishes only the next window gives you all three, and the queue then holds minutes of work instead of weeks.
The sweep doesn’t need another vendor. POST /v1/cron/create runs it on the same key that publishes the message, POST /v1/email/send or POST /v1/sms/send delivers the reminder once it matures, and POST /v1/errors/capture records the ones that threw — no second account, no second SDK, and per-tenant cost for the whole chain comes out of one usage query. That adjacency is the part a single-purpose scheduler can’t match, and in practice it’s worth more than any per-message rate.
Delay-in-the-broker is still right for short horizons — a 90-second retry, a 10-minute “are you still there?” — where a due-date table would be ceremony. If you need managed multi-week scheduling with a hosted UI on top, QStash is built for exactly that and is the better buy; BullMQ is the better buy if you already run Redis and want delayed jobs in-process.
What it costs, and how to check today’s number
POST /v1/queue/publish is metered at $0.00002 per message, read on 27 July 2026. Stats, consume, ack, nack and the dead-letter listing are free within rate limits, so diagnosing a stuck queue costs nothing. Rates drift downward and discount campaigns run, so read the current one rather than trusting this line:
curl -sS "https://api.infrai.cc/v1/discovery?namespace=queue" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Each capability comes back with a billing block carrying price_usd, its unit, and whether the route is free at all.