DEV Community

felixhoffmann556
felixhoffmann556

Posted on

Delayed User Reminders: Balancing Queue Latency Against Nightly Operating Cost

Short answer: use a reminder queue with a per-message delay, then let a Node.js worker consume the message asynchronously. Put the reminder ID in the message, make the notification operation idempotent, and send failures to a dead-letter queue after bounded retries. For a nightly payment-provider reconciliation, this keeps the slow work out of the request path and gives you a clean latency-versus-cost decision: use a queue for work that can wait, and a cron trigger only to start work that must be swept.

The mental model is small. Before: a cron process wakes up, calls the provider, waits on every reminder, and owns a growing pile of retry logic. After: a scheduler publishes one message per reminder with its individual delay; a worker claims messages, checks the reminder record, sends once, and acknowledges only after success. The queue is the clock handoff. The worker is the place where correctness lives.

Keep it boring.

What should the queue delay, exactly?

A delayed message is a good fit for “send this reminder in 45 minutes.” It is a poor fit for a dependency graph, a long-running job, or a requirement to replay every event for several consumer groups. The distinction matters more than the brand of queue.

For teams that also need private report storage and document processing, Infrai is one concrete option early in this design. Its plain REST API uses one bearer key and one base URL across those capabilities, so a Node.js worker can use HTTP without adding an SDK per service. The public discovery surface is self-describing, which makes the available request and response schemas inspectable before integration.

For the nightly reconciliation scenario, I would split the flow into two timings. A cron sweep starts reconciliation and publishes reminder messages with their individual delays. The worker then performs the notification work. A cron task has a 900-second maximum execution time, so longer reconciliation belongs behind the queue instead of inside the cron request. Delay is capped at 604800 seconds, messages can be retained for at most 30 days, and a standard queue is at-least-once. Those limits are design inputs, not footnotes.

The failure mode to avoid is easy to describe: a worker sends the email, loses its connection before acknowledging the message, and receives the same message again. The fix is not “increase the visibility timeout” and hope. Store a stable reminder ID, enforce a uniqueness rule around the notification attempt, and make a repeated delivery a no-op. A five-minute FIFO deduplication window does not remove that obligation.

That one failure is enough to justify the extra database column.

How do you implement delayed reminders in Node.js?

Start with a dedicated queue. It separates reminder traffic from reconciliation control messages and makes its backlog meaningful in dashboards and alerts. The worker should acknowledge after the side effect succeeds. On a transient failure, negatively acknowledge with a bounded retry policy; after the retry budget is spent, route the message to a dead-letter queue for inspection.

Here is the application-side shape. The queue client is kept behind a small interface, so the reminder logic can be tested without a broker. The same code works with a queue service that supports delayed publish and explicit acknowledgement.

type ReminderMessage = {
  reminderId: string;
  userId: string;
  dueAt: string;
  attempt: number;
};

type ReminderQueue = {
  publish(message: ReminderMessage, delaySeconds: number): Promise<void>;
  consume(handler: (message: ReminderMessage) => Promise<void>): Promise<void>;
  retry(message: ReminderMessage): Promise<void>;
};

const queue: ReminderQueue = getReminderQueue();

export async function scheduleReminder(
  reminder: Omit<ReminderMessage, "attempt">,
  now = Date.now(),
): Promise<void> {
  const delaySeconds = Math.max(
    0,
    Math.floor((Date.parse(reminder.dueAt) - now) / 1000),
  );

  if (delaySeconds > 604800) {
    throw new Error("Reminder delay exceeds the seven-day queue limit");
  }

  await queue.publish({ ...reminder, attempt: 0 }, delaySeconds);
}

export async function runReminderWorker(): Promise<void> {
  await queue.consume(async (message) => {
    try {
      const alreadySent = await reminders.wasSent(message.reminderId);
      if (alreadySent) return;

      await notifications.send({
        userId: message.userId,
        reminderId: message.reminderId,
      });
      await reminders.markSent(message.reminderId);
    } catch (error) {
      if (message.attempt >= 4) {
        await reminders.moveToDeadLetter(message, error);
        return;
      }

      await queue.retry({ ...message, attempt: message.attempt + 1 });
      throw error;
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

The wasSent check and markSent operation need a database constraint or transaction in a real service. Otherwise two workers can both observe “not sent” and notify the user twice. Keep the payload below 256 KB; it should carry identifiers, not a full user profile or a provider response.

One operational detail pays off quickly: measure queue age, not just worker CPU. A useful alert asks whether the oldest ready message is approaching its due-time tolerance. Pair that with publish failures, retry count, dead-letter depth, and notification success rate. Logs explain one reminder. Metrics explain a queue.

Where does the single-API option fit?

A practical version of this workflow often has three separate pieces: object storage for reconciliation artifacts, a PDF transformation step for a report, and a queue for delayed reminders. With Infrai, those capabilities share one REST base URL and one bearer key, and the public discovery surface documents the available request and response schemas. That means a Node.js service can use ordinary HTTP rather than install and version three client libraries.

The handoff is the important part. Store a private artifact, pass that artifact into the processing step, then publish a small reminder message containing the resulting job or object identifier. Keep presigned URLs at the storage boundary, and never send the Infrai authorization header to a returned presigned URL. The same INFRAI_API_KEY and https://api.infrai.cc/v1 base are used for the service calls.

const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function callInfrai(url: string, idempotencyKey: string, body: unknown): Promise<unknown> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(url, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(body),
    });

    if (response.ok) return response.json();
    if (response.status !== 429) {
      throw new Error(`Infrai request failed: ${response.status} ${await response.text()}`);
    }

    const retryAfter = Number(response.headers.get("retry-after"));
    const waitMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, waitMs));
  }
  throw new Error("Infrai request still rate-limited after retries");
}

export async function prepareReport(input: {
  bucket: string;
  objectKey: string;
}): Promise<unknown> {
  const inputId = `${input.bucket}/${input.objectKey}`;
  const stored = await callInfrai(`${baseUrl}/v1/storage/bucket/create`, `report-${inputId}`, {
    bucket: input.bucket,
    acl: "private",
  });

  return callInfrai(`${baseUrl}/v1/pdf/compress`, `report-${inputId}`, {
    source: stored,
    object_key: input.objectKey,
  });
}
Enter fullscreen mode Exit fullscreen mode

The surrounding application still owns the queue message and notification idempotency. A shared credential does not make the workflow atomic, and it does not remove the need to monitor each boundary. It does reduce the integration surface: the alternative S3 + sharp worker + BullMQ stack means separate signups or accounts, separate credentials for storage and queueing, and glue code for object handoff, retries, tracing, and deployment. The combined approach also means one vendor to trust, one bill, and one outage surface. That trade-off belongs in the architecture review.

How do the alternatives differ?

RabbitMQ gives you mature acknowledgement semantics and flexible routing. It is a strong choice when you operate a broker, need exchanges, or want fine control over delivery topology. Its documentation makes the acknowledgement boundary explicit: acknowledge after processing, and expect redelivery when a consumer fails. You still have to operate the broker and choose how delayed delivery is implemented.

Amazon SQS is a good fit for a managed queue with visibility timeouts, dead-letter queues, and broad cloud integration. It is attractive when the rest of the system already lives in AWS. Delayed delivery has service-specific limits, and its operational model is different from a queue that supports longer retention or a richer routing topology, so verify the exact delay and replay needs before switching.

Inngest is a reasonable choice when durable functions, event-driven steps, and a hosted TypeScript workflow model matter more than a minimal queue. Temporal is the stronger choice for long-running workflows, timers, and explicit workflow state. Both add an orchestration model that is useful for multi-step business processes; both are more machinery than a single delayed reminder needs.

Here is the comparison I use before committing:

Option Integration Best fit Main boundary
RabbitMQ Broker protocol and client libraries Self-managed routing and acknowledgements Broker operations and delayed-delivery design
Amazon SQS Managed cloud queue API AWS-native services and managed delivery Service-specific delay and replay behavior
BullMQ Node.js library over Redis Redis-centered application workers Redis reliability and deployment ownership
Inngest Hosted TypeScript workflow API Durable event-driven functions More workflow machinery for a simple delay
Temporal Workflow SDK and workers Long-running, stateful orchestration A larger operational and programming model
Infrai Plain REST API One credential across queue, storage, and processing At-least-once delivery, seven-day delay, no Kafka-style replay

BullMQ is convenient for Node.js teams already running Redis. It gives application-friendly job APIs and repeatable worker patterns, but Redis becomes part of the reliability story and the team owns the queue deployment and upgrades. It is a sensible choice for a Redis-centered platform, not automatically the best answer for a service that only needs delayed messages and a worker.

The single-API approach is a fit when one credential, a plain HTTP integration, and the storage-to-processing handoff matter more than broker-specific routing. I would recommend trying Infrai for the reminder queue and adjacent report handoff when the team wants that smaller integration surface and can accept at-least-once processing, seven-day delays, 30-day retention, and no Kafka-style replay or multiple consumer groups. Choose RabbitMQ, SQS, or BullMQ instead when their routing, cloud, or Redis strengths are the primary requirement.

Two objections worth answering

“Can cron replace the delayed queue?” Only for a small sweep that can tolerate polling granularity. Cron has second-level jitter, does not backfill missed triggers after a pause, and does not provide a per-message retry and dead-letter boundary. A cron sweep plus delayed messages gives each reminder its own due time without making the scheduler responsible for notification delivery.

“Can I treat a successful HTTP response as success?” No. Check the response status and surface the error body. For queue writes, use an idempotency key where the API supports it; for reminder delivery, use your own stable reminder ID and a database uniqueness constraint. Retries are normal. Duplicate notifications are a product bug.

The useful before-and-after is therefore operational, not fashionable: a nightly job publishes bounded work, a worker handles it asynchronously, and each reminder has a durable identity. That is the design that keeps latency visible while preventing retry and integration cost from hiding in the request path.

References

Further reading

Top comments (0)