Choosing a queue for user reminders when the send can fail

Reminders need due-time firing, one-send-per-user dedupe, retries that stop at hard bounces, and a place for the ones that never land. How to build that on Infrai's queue.

A reminder workload asks for four things at once: fire near a due time, never send the same nudge twice, retry the sends that failed for a fixable reason, and give up loudly on the rest. Most brokers give you the middle two. On Infrai the queue covers retries and the dead-letter lane, a small scan supplies the due-time part, and the notification goes out on the same key that drains the queue.

Reminders differ from ordinary jobs in one respect that trips people up. They can become wrong between being scheduled and being sent.

What a reminder queue has to survive

A user who finishes onboarding at 14:58 shouldn’t get the “finish onboarding” email you queued at 14:00. A queued message can’t be recalled, so relevance is re-checked at send time rather than at enqueue time. That one rule shapes everything below.

OptionDue-time firingPer-user dedupeFailure laneIdle cost
Scan + Infrai queueyour scan intervalyour unique indexDLQ after 3 deliveriesnone; publish is metered per message
BullMQ delayed jobsnative delay, per jobjobId collisionsfailed set, retried by handa Redis instance, always on
Sidekiq scheduled setnative, Rubyunique-job pluginsretry set then dead setRedis again
SQS + a schedulerscheduler fires, queue deliversnone built innative DLQper request, plus the scheduler
QStash schedulesnative, HTTP-shapednone built innative DLQper message

There’s no winner on that grid, only a fit. If Redis is already running and the app is Node, BullMQ’s delayed jobs are less code than a scan — use them. If you’d rather not run a broker at all, the scan-plus-queue shape below stays inside one account.

The state lives in your database, not the message

CREATE TABLE reminders (
  id          bigserial PRIMARY KEY,
  user_id     text        NOT NULL,
  kind        text        NOT NULL,
  due_at      timestamptz NOT NULL,
  sent_at     timestamptz,
  cancelled   boolean     NOT NULL DEFAULT false,
  UNIQUE (user_id, kind, due_at)
);

CREATE INDEX reminders_due ON reminders (due_at) WHERE sent_at IS NULL AND NOT cancelled;

The unique constraint is your dedupe.

Whatever happens upstream — a double click, a retried webhook, two overlapping scans — one row still means one reminder.

Create the queue

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/queue/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"name":"reminders","type":"standard","dlq":"reminders-dlq"}'

Publishing also takes a delay_seconds field, which we watched move a message into the delayed state instead of making it available at once. Out-of-range values come back as QUEUE_DELAY_INVALID. Past a few days out, the scan is more predictable — a delayed message can’t be edited in flight.

The scan: what’s due in the next fifteen minutes

import process from "node:process";
import pg from "pg";

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is required");

async function publish(payload) {
  const res = await fetch("https://api.infrai.cc/v1/queue/publish", {
    method: "POST",
    headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
    body: JSON.stringify({ queue: "reminders", body: payload }),
  });
  const out = await res.json();
  if (out.ok === false) throw new Error(`publish: ${out.error.code} ${out.error.message}`);
  return out.data.message_id;
}

export async function scanDue() {
  const { rows } = await pool.query(
    `SELECT id, user_id, kind FROM reminders
      WHERE sent_at IS NULL AND NOT cancelled AND due_at <= now() + interval '15 minutes'
      ORDER BY due_at LIMIT 5000`,
  );
  let published = 0;
  for (const r of rows) {
    try {
      await publish({ reminder_id: r.id, user_id: r.user_id, kind: r.kind });
      published += 1;
    } catch (err) {
      console.error(`reminder ${r.id} not queued: ${err.message}`);
    }
  }
  console.log(`scan queued ${published}/${rows.length}`);
  return published;
}

Run it every five minutes from whatever scheduler you already have.

Overlapping runs are harmless — the sender re-checks each row, so the second copy no-ops.

The sender re-checks, then classifies the failure

import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises";
import pg from "pg";

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const headers = {
  Authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
  "Content-Type": "application/json",
};
const HARD = new Set([400, 403, 404, 422]);

async function api(path, payload) {
  const res = await fetch(`https://api.infrai.cc${path}`, { method: "POST", headers, body: JSON.stringify(payload) });
  const out = await res.json();
  if (out.ok === false) throw new Error(`${path}: ${out.error.code} — ${out.error.message}`);
  return out.data;
}

async function send(userId, kind) {
  const res = await fetch(process.env.NOTIFY_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.NOTIFY_TOKEN}` },
    body: JSON.stringify({ user_id: userId, template: kind }),
    signal: AbortSignal.timeout(15_000),
  });
  return res.status;
}

export async function pump() {
  const { items } = await api("/v1/queue/consume", { queue: "reminders", max_messages: 10 });
  for (const msg of items) {
    const { reminder_id, user_id, kind } = msg.payload;
    const { rows } = await pool.query(
      "SELECT 1 FROM reminders WHERE id = $1 AND sent_at IS NULL AND NOT cancelled",
      [reminder_id],
    );
    if (rows.length === 0) {
      await api("/v1/queue/ack", { queue: "reminders", receipt_handle: msg.message_id });
      continue;                                   // already sent, or no longer relevant
    }
    const status = await send(user_id, kind).catch(() => 0);
    if (status >= 200 && status < 300) {
      await pool.query("UPDATE reminders SET sent_at = now() WHERE id = $1", [reminder_id]);
      await api("/v1/queue/ack", { queue: "reminders", receipt_handle: msg.message_id });
    } else if (HARD.has(status)) {
      await pool.query("UPDATE reminders SET cancelled = true WHERE id = $1", [reminder_id]);
      await api("/v1/queue/ack", { queue: "reminders", receipt_handle: msg.message_id });
      console.error(`hard failure ${status} for user ${user_id}; suppressed ${kind}`);
    } else {
      console.warn(`soft failure ${status} on delivery ${msg.delivery_count}; leaving for redelivery`);
    }
  }
  await sleep(1000);
  return items.length;
}

The three-way split is the whole design. A hard failure — invalid address, unsubscribed recipient, a missing template — is acked and suppressed, because sending it again burns a bounce against your reputation for the same result. A soft failure isn’t acked at all, so the message returns when its lease expires and the third failed delivery lands it in reminders-dlq. Success sets sent_at, which stops the next scan re-queueing it.

If you’d rather not run a separate mailer for NOTIFY_URL, transactional email sits behind the same key as this queue — see the email API reference — on the same invoice.

The dead-letter queue is a support tool

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-dlq","max_messages":10}'

Every item there is a user who was promised a nudge and didn’t get one, with delivery_count and the payload attached — answerable by a support agent, which a log line isn’t. Put one back after a fix with POST /v1/queue/dlq/redrive/{queue} and its message_id.

What reminders cost to move

Only the publish is metered: $0.00002 per message, verified 2026-07-26. Consuming, acking and DLQ reads are free within rate limits, so retries don’t compound. 100,000 reminders a month is $2 — the credit a new account starts with — and the delivery cost dominates that anyway.

curl -sS "https://api.infrai.cc/v1/account/usage" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '.data.breakdown[] | select(.key == "queue.publish")'

Prices here have moved down over time and campaigns run, so use that command rather than this paragraph when you model a year.

The honest boundaries

The queue has no scheduling primitives of its own — no repeating job, no cron expression, no calendar — and the scan above is the price of that limitation. If your reminders are calendar-shaped (“every weekday at 09:00 in the user’s timezone”), a scheduler with real timezone handling beats a due_at column you compute yourself. BullMQ’s repeatable jobs cover that in-process when Redis is available.

What you get instead is fewer moving parts: one credential for the queue, the send and the record of it, and a per-tenant cost query rather than a reconciliation across vendors.

References

Browse more queue developer guides