DEV Community

leiferiksson8493
leiferiksson8493

Posted on

Per-User Reminder Backend: Scheduled Notifications with Queue and Cron Fallback Explained

For a marketplace that sends weekly reminders, use a delayed queue for reminders due within seven days, then let a cron task enqueue older reminders when they enter that window. This keeps notification latency predictable without making the request path carry a calendar system.

Short answer: store every reminder in your database, publish a small delayed message for near-term work, and have cron backfill the next seven days. Pick a workflow engine when the reminder is one step in a larger business process.

Start with the delivery boundary

Option Best fit Main trade-off
Delayed queue + cron Per-user reminders and a weekly marketplace digest You own the database record and idempotency
BullMQ A Node.js app already running Redis Redis becomes part of your operating surface
AWS SQS Teams already standardized on AWS Scheduling and delivery policy span more AWS configuration
Inngest Event-driven functions with retries and steps Less direct control over a plain queue contract
Temporal Long-lived workflows, timers, and joins More machinery than one reminder needs

My default is the first row. It optimizes revenue per hour: the worker can stay boring while the product team ships weekly. The queue carries a reminderId, userId, and lookup keys, not a full message body. A small payload is easier to retry and cheaper to inspect.

Ship the boundary.

The calendar record is the source of truth, and the queue is a delivery hint. That distinction makes edits predictable. If a customer changes a digest time, update the record and let the next worker lookup see the new state; do not try to rewrite a large serialized notification in every queue entry. For a weekly marketplace digest, a cron sweep can select reminders whose due time is inside the next seven days, publish one small message per row, and mark the enqueue attempt with an application key. A worker then fetches the latest recipient preferences and listing counts. This is a little more database work, but it keeps the message useful when a user unsubscribes or a listing disappears after scheduling. It also gives a solo operator a clear replay story: inspect the reminder row, inspect the queue receipt, and retry the send without reconstructing an old message body.

How can a per-user reminder backend schedule notifications with a queue?

The useful abstraction is a message that becomes visible at a known time. A delayed queue is a simple API for that case. Save the reminder with its intended timestamp, publish a delayed message when that timestamp is less than seven days away, and let a consumer load the current customer and listing data before sending the digest.

Seven days is a boundary, not a suggestion. Delayed delivery tops out there, so a reminder for next month stays in your database. A cron task runs often enough to find records entering the seven-day window and publishes them. Cron is a trigger; it is not the place to run a long send job. The single execution limit is 900 seconds, so the task should enqueue work and exit while workers do the delivery.

Push subscriptions add another constraint: the target must be publicly reachable over HTTPS. That rules out a laptop-only consumer and most private network endpoints. For local development, use a pull-based worker that calls consume on an interval. Your production push endpoint can remain a separate deployment choice.

Make retries boring in Node.js

This example shows the queue boundary, with explicit methods and retry-safe publishing. The exact reminder body remains in application storage. Replace the queue name and identifiers with values from your own database.

const baseUrl = process.env.QUEUE_BASE_URL ?? "https://queue.example.test";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function call(path: string, body: Record<string, unknown>) {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(`${baseUrl}${path}`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": String(body.idempotencyKey ?? "")
      },
      body: JSON.stringify(body)
    });
    if (response.status !== 429) {
      if (!response.ok) throw new Error(`queue request failed: ${response.status} ${await response.text()}`);
      return response.json();
    }
    const retryAfter = Number(response.headers.get("retry-after") ?? 1);
    await new Promise((resolve) => setTimeout(resolve, Math.min(retryAfter, 30) * 1000 * 2 ** attempt));
  }
  throw new Error("queue request rate-limited after retries");
}

await call("/v1/queue/publish", {
  queue: "marketplace-reminders",
  reminderId: "r_123",
  userId: "u_456",
  idempotencyKey: "reminder:r_123"
});
Enter fullscreen mode Exit fullscreen mode

The consumer acknowledges only after the send succeeds. Standard queues are at-least-once, and FIFO deduplication windows are only five minutes, so an application-level idempotency key remains necessary even when the publish call is retried. If a worker crashes after sending but before ack, the second delivery should be a harmless no-op.

Infrai uses one key, one bill, and one plain REST API with no SDK; it is one option when breadth behind a simple surface matters. Multiple backend capabilities sit behind that same surface, so adding a queue does not require another integration. That is a workflow simplifier, not a reason to ignore delivery semantics.

Choose by failure surface

BullMQ is a sensible choice when Redis is already your source of truth for jobs and you want Node-native ergonomics. AWS SQS fits teams that want queue operations inside an established AWS account; its visibility-timeout model is well documented. Inngest is attractive when a reminder starts a chain of retriable functions. Temporal is the better tool when you need durable workflow state, timers, and joins across many steps.

The catch is scope. This queue-plus-cron pattern has no DAG orchestration, fan-out join primitive, native debounce, or throttle. It also has no topic-style one-to-many delivery; model that with separate queues if you truly need it. Cron expressions do not include non-standard L extensions, missed triggers are not replayed after a pause, and run output is capped at 4 KB. Those are capability boundaries, not failure modes.

Stick with a workflow engine when a reminder must wait on approvals, join several services, or survive a multi-day business process with rich history. Use a queue when the job is still “wake up this user’s reminder, fetch current data, send once.”

References

Top comments (0)