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 seeCounter signatureWhat actually happened
Nothing was ever scheduledqueue missing, or all counters 0the publish was rejected, 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 handler outlives its lease
Reminder is gonedlq_count above 0it 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.

References

Browse more queue developer guides