Why your delayed reminder never fired, and how to find out

Five things swallow a scheduled notification: the 7-day cap, a lying publish echo, the DLQ, an idle consumer, expired retention. Each with the check that identifies it.

A reminder that was scheduled and never showed up has a small number of possible explanations, and one 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. This walks through each failure we’ve reproduced against Infrai’s queue, with the command that confirms it.

Start by ruling out the one people hit most: a delay longer than seven days is not accepted, and the error text doesn’t say so.

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": 0,
    "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 queue name comes back as QUEUE_NOT_FOUND, you published to a different name than the one you’re polling — check for a typo before anything else, since queues are created implicitly by the first publish and a misspelling produces a perfectly healthy queue nobody reads.

What you seeCounter signatureWhat actually happened
Nothing was ever scheduledqueue missing, or all counters 0the publish returned 400, or went to another queue name
Reminder is still pendingdelayed_count above 0working as intended; it hasn’t matured
Reminder is stuck readyavailable_count above 0 and not fallingno consumer is polling
Reminder keeps re-appearingin_flight_count cyclingthe worker never acks, so the lease keeps expiring
Reminder is gonedlq_count above 0three failed deliveries; it’s in <queue>.dlq

The publish was rejected and you didn’t notice

Anything above 604800 seconds fails. The response is an HTTP 400 whose message talks about the queue already existing, which sends people hunting for a naming collision that isn’t there. Reproduce it deliberately once so you recognise it later:

BAD=$(cat <<'JSON'
{"queue":"reminders-delayed","body":{"user_id":"u_4412","kind":"trial_ending"},"delay_seconds":1209600}
JSON
)

curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d "$BAD"

Fourteen days is a natural thing to ask for and it comes back as INVALID_ARGUMENT. If your publish helper only checks res.ok on the HTTP layer and never inspects the JSON envelope’s ok field, this failure is completely silent — which is how a whole cohort of trial-ending nudges goes missing. Anything further out than a week needs the chained-hop approach in delayed retries beyond 7 days, or a due-date column.

The publish said “available” and you believed it

A successful publish echoes "status": "available" whether the message is ready or parked for six days. It’s a cosmetic bug in the echo, not in the queue — the delay is honoured — but it means you cannot use the publish response to confirm scheduling. The counter is the source of truth, so a canary is the cheap way to prove the path end to end:

CANARY=$(cat <<'JSON'
{"queue":"reminders-delayed","body":{"canary":true},"delay_seconds":60}
JSON
)

curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d "$CANARY"

sleep 5
curl -sS -X POST "https://api.infrai.cc/v1/queue/consume" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"reminders-delayed","max_messages":5}'

An empty items array five seconds in, followed by a non-empty one after the minute is up, is proof the delay works. That’s the check to run before you go looking for a bug in your own code.

It arrived, failed three times, and left

Three deliveries is the threshold, and it’s a fixed limitation rather than a setting — the queue record reports max_receive_count: 3 regardless of what you write to it. A reminder whose send throws (an expired push token, a 500 from your mailer) is nacked back twice and then moved to the companion dead-letter queue named <queue>.dlq.

Reading it has a trap. The dedicated listing route returns an empty array in our testing even when dlq_count is 1, so consume the companion queue by name:

curl -sS -X POST "https://api.infrai.cc/v1/queue/consume" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"reminders-delayed.dlq","max_messages":10}'
{
  "ok": true,
  "data": {
    "items": [
      {
        "message_id": "qmsg_ssURFXHvZXjCMdGtmrQ1c7wY",
        "queue": "reminders-delayed.dlq",
        "payload": { "user_id": "u_4412", "kind": "trial_ending" },
        "status": "in_flight",
        "delivery_count": 1,
        "published_at": "2026-07-26T00:57:29.474221Z"
      }
    ],
    "next_cursor": null
  }
}

Every entry there is a user who was promised something and didn’t get it, with the payload attached. Once the underlying fault is fixed, POST /v1/queue/dlq/redrive/{queue} puts a message back — one message_id at a time, because the bulk form currently errors out.

Nobody was polling, or the lease kept lapsing

This queue is pull-based: a matured message sits in available_count until something asks for it. If that number climbs while your worker is up, check that the worker is polling the queue it thinks it is. And if in_flight_count oscillates without the message ever leaving, your handler is taking longer than the 300-second default visibility timeout, so the lease expires and the same reminder is delivered again — the classic duplicate-notification bug, and the reason SQS documents visibility timeout as prominently as it does.

Messages also expire. Retention is 14 days, so a reminder that nobody consumed for a fortnight is simply gone, with no DLQ entry 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;
}

async function post(path, payload) {
  const res = await fetch(`${API}${path}`, { method: "POST", headers, body: JSON.stringify(payload) });
  const out = await res.json();
  if (!out.ok) throw new Error(`${path}: ${out.error.code} ${out.error.message}`);
  return out.data;
}

const stats = await get(`/v1/queue/stats/${encodeURIComponent(queue)}`);
console.log("counters:", stats);

if (stats.delayed_count > 0) console.log(`${stats.delayed_count} message(s) still maturing — not lost, just early`);
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 — check handler duration against the 300s timeout`);

if (stats.dlq_count > 0) {
  const dead = await post("/v1/queue/consume", { queue: `${queue}.dlq`, max_messages: 10 });
  for (const msg of dead.items) {
    console.log(`dead: ${msg.message_id} after ${msg.delivery_count} deliveries`, msg.payload);
  }
}

Run it with the queue name as the first argument and it prints a verdict instead of a wall of counters.

Making the class of bug go away

Holding a notification inside a broker for days means the schedule is invisible to your product: you can’t list what’s pending, you can’t cancel when the user completes the thing you were going to nag them about, and you can’t move a send time. A due_at column with a partial index gives you all three, and a sweep every few minutes publishes only what’s due in the next window — so the queue holds minutes of work rather than weeks, and the failure modes above shrink to the ones you can see in your own database.

Delay-in-the-broker is still the right call for short horizons — a 90-second retry, a 10-minute “are you still there?” — where a table would be ceremony. QStash’s delay feature and BullMQ’s delayed jobs make the same trade-off with different ceilings, and SQS caps a single message at 15 minutes precisely because the vendor expects a scheduler to own anything longer.

The publish itself is metered at $0.00002 per message, verified 2026-07-26, and every diagnostic call above — stats, consume, ack — is free within rate limits, so debugging a stuck queue costs nothing.

curl -sS "https://api.infrai.cc/v1/account/usage" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Rates drift downward over time, so treat that call as the number and this line as the shape.

References

Browse more queue developer guides