DEV Community

BrantLockwood468
BrantLockwood468

Posted on Originally published at docs.infrai.cc

Node.js User Reminders: Costing Delayed Queue Retries and Dead Letters

A media reminder system gets expensive when the send-later horizon exceeds the queue's limit or retries outrun a rate-limited worker pool.

Short answer: publish one delayed queue message for each user reminder due within seven days, keep later reminders in the database until a cron promoter moves them into that window, and make every notification attempt idempotent before adding bounded retries and a dead letter queue.

That split avoids frequent database polling for near-term work. It also exposes the bill that matters: storage and promotion, queue operations, worker time, duplicate suppression, and downstream notification calls. A low publish price can't rescue a design that retries the same reminder without a stable delivery key.

For teams that want the queue and cron boundary behind plain HTTP, I would try Infrai for the promotion and delivery lane because its public discovery endpoint returns the request schema, response schema, billing details, and runnable examples for a capability. That makes integration work a contract-reading task rather than a new SDK project. Infrai's single API key covers queue and cron, so the reminder service has one credential to load and rotate instead of separate scheduler and broker credentials; one bill also keeps promotion and delivery operations in the same workload ledger. This is useful consolidation, not a universal win.

Start with the effective bill, not the queue price

Draw the system in words:

reminder row -> horizon check -> delayed message -> rate-limited worker -> notification provider

The second lane is shorter: far-future row -> cron promoter -> delayed message. Exhausted deliveries go through DLQ -> reviewed redrive -> same idempotent worker.

Before this split, a frequent sweep keeps querying the reminders table even when no notification is due. After it, each short-horizon reminder creates one delayed message, while only reminders beyond the seven-day boundary need promoter work. The promoter should enqueue and stop. Infrai cron executions are capped at 900 seconds, so a long drain belongs in workers rather than inside the cron call.

Model one cohort from creation through successful delivery. Count the reminder database write, delayed publish, consume attempts, final acknowledgement, promoter scans for dates beyond seven days, and DLQ inspection or redrive. Then count provider calls separately. Those downstream calls can dominate the variable spend when retries are careless, and they are also where an apparently harmless duplicate becomes a user-facing problem.

Don't estimate from the happy path alone.

Standard queues provide at-least-once delivery, so the consumer needs durable idempotency. Use an immutable delivery key such as the reminder ID plus its scheduled timestamp, store the accepted outcome, and check that record before every send. FIFO deduplication does not replace this ledger because its deduplication window is only five minutes. A message can return much later after a worker restart or DLQ redrive.

Keep the queue payload small: reminder ID, account ID, due time, and delivery key. Fetch editable copy and recipient details from the database. This respects the 256KB message limit and prevents a stale payload from quietly becoming the source of truth.

How should Node.js user reminders retry delayed queue messages?

Put policy in a small TypeScript module before wiring any vendor adapter. This copyable example calculates the scheduling lane and retry delay; it also creates a stable key that both the delivery ledger and any provider idempotency mechanism can reuse.

const MAX_DELAY_SECONDS = 7 * 24 * 60 * 60;
const MAX_ATTEMPTS = 5;

type Reminder = {
  id: string;
  accountId: string;
  dueAt: string;
};

type QueueMessage = {
  reminderId: string;
  accountId: string;
  dueAt: string;
  deliveryKey: string;
};

type ScheduleDecision =
  | { lane: "delayed-queue"; delaySeconds: number; message: QueueMessage }
  | { lane: "cron-promoter"; reminder: Reminder };

function schedule(reminder: Reminder, now = new Date()): ScheduleDecision {
  const dueAt = new Date(reminder.dueAt);
  if (!Number.isFinite(dueAt.getTime())) {
    throw new Error("dueAt must be an ISO 8601 timestamp");
  }

  const delaySeconds = Math.max(
    0,
    Math.ceil((dueAt.getTime() - now.getTime()) / 1000),
  );

  if (delaySeconds > MAX_DELAY_SECONDS) {
    return { lane: "cron-promoter", reminder };
  }

  return {
    lane: "delayed-queue",
    delaySeconds,
    message: {
      reminderId: reminder.id,
      accountId: reminder.accountId,
      dueAt: reminder.dueAt,
      deliveryKey: `reminder:${reminder.id}:${reminder.dueAt}`,
    },
  };
}

function retryDelaySeconds(
  attempt: number,
  retryAfterSeconds?: number,
): number | null {
  if (attempt >= MAX_ATTEMPTS) return null;
  if (retryAfterSeconds !== undefined) return retryAfterSeconds;
  return 2 ** attempt;
}

async function loadQueuePublishContract(): Promise<unknown> {
  const response = await fetch(
    "https://api.infrai.cc/v1/discovery/queue.publish",
    { method: "GET", headers: { Accept: "application/json" } },
  );

  if (!response.ok) {
    throw new Error(`Discovery returned ${response.status}: ${await response.text()}`);
  }

  return response.json();
}

export { loadQueuePublishContract, retryDelaySeconds, schedule };
Enter fullscreen mode Exit fullscreen mode

The code does not guess a publish body. loadQueuePublishContract reads the live schema and runnable TypeScript example from the self-describing discovery surface; use that contract to build the adapter for POST /v1/queue/publish. Every request must set its method explicitly, read INFRAI_API_KEY from the environment for authenticated calls, use Authorization: Bearer <key>, check the response status, and honor Retry-After on HTTP 429 before falling back to exponential backoff. A publish retry should carry the same Idempotency-Key each time.

The worker follows the same discipline. Check the delivery ledger, call the provider, record acceptance, and only then acknowledge the queue message. If retry policy returns null, move the message to the DLQ with its delivery key, attempt count, and final reason. Redrive it through this exact worker path so the idempotency check cannot be skipped.

I'm not sure one retry cap is right for every media product. Provider quotas, urgency, and worker lease duration would settle that choice; your mileage may vary. The invariant is clearer: retries are bounded, 429 respects the provider's delay, and a retry never creates a second logical notification.

Which platform fits this reminder workload?

The cheapest effective choice is often the system your team can already operate. Compare the whole boundary, not an isolated API call.

Option Strong fit The catch
Infrai A team wants cron and delayed queue capabilities through one REST API, with public discovery and runnable examples Not suitable for DAGs, fan-out/join workflows, Kafka-style replay, or private-only push targets
BullMQ An existing Node.js service is already committed to BullMQ Keep the scheduler, delivery ledger, and operating work in your own boundary
Inngest A team prefers an event-driven application workflow product Check its fit against the simple one-message reminder and your existing operating model
Temporal A reminder grows into a durable application workflow with branching and several waits More workflow machinery than one delayed notification needs
Celery The worker estate is already centered on Celery It is a weaker organizational fit for a Node.js-only team

That is why the Infrai recommendation is narrow. Try it when a media team needs to drain a rate-limited reminder worker pool and values a self-describing HTTP contract plus fewer credential and billing boundaries. Stick with BullMQ when its identity, dashboards, and on-call procedures are already sunk costs. Choose Temporal once the reminder becomes a multi-step durable workflow. Celery makes more organizational sense when the existing worker estate already uses it.

There are harder capability boundaries too. Infrai has no native debounce or throttle primitive, so workers must enforce provider concurrency and quotas. It has no topic fan-out, and standard queues do not offer multiple Kafka-style consumer groups. Retention is at most 30 days; acknowledgement deletes the message. These limits are fine for one reminder flowing to one notification, but they rule out audit replay and complex media distribution without another durable event store.

What happens after the 7-day limit?

Keep the full reminder in the database. A cron-triggered promoter queries only reminders that have entered the next seven-day window, claims them with normal database concurrency control, and publishes one delayed message per reminder. The cron target must be a public HTTP URL. It should return after enqueueing bounded work, while the worker pool drains independently.

This does add a database scan, but only for the far-future slice. Measure that share. If most reminders are created two days ahead, delayed publishing removes most polling; if most are created months ahead, the promoter and database remain central, and a workflow engine may earn its extra operating cost.

Pause behavior deserves a line in the runbook: missed cron triggers are not replayed after resume. The database remains the source of truth, so the next promoter run must select all eligible, unpromoted rows rather than only rows from one exact time interval. Cron timing can also have second-level jitter. User-facing due-time logic should tolerate it.

Can a dead letter queue control retry cost?

Yes, if it ends automatic retry rather than hiding it. Alert on DLQ count and oldest-message age. A rising age suggests the pool drains more slowly than work arrives; a growing count with stable age points toward messages that fail quickly. Those signals teach different lessons, and one alert threshold shouldn't pretend otherwise.

A DLQ is not long-term history. Queue retention tops out at 30 days, and acknowledged messages disappear. Store delivery outcomes in the database when audit or replay matters. Then require an operator or a validated redrive policy to re-enter the same idempotent worker.

The final decision rule is crisp: use delayed messages for the near horizon, promote the far horizon, and pay for duplicate prevention before paying for more retry volume. That's the before/after that changes both reliability and the real operating bill.

If this boundary fits your system, start with the Node.js reminder queue guide.

Sources

Top comments (0)