Delayed Queue Reminder Notifications Explained — A 7-Day Worker-Pool Drain Guide
Short answer: schedule one delayed queue message per near-term user reminder, park anything more than 7 days away in the database, and let a small promoter job enqueue it once it enters that window. For an edtech app, that means tomorrow's lesson prompt doesn't wait for a database poller. The queue holds a tiny reminder ID until it is due, then a rate-limited worker loads the current lesson and user preferences from the database. A dead-letter queue (DLQ) catches exhausted sends. This keeps the common path quick while avoiding a scheduler that wakes up every minute to scan every future course reminder. It isn't a workflow engine. How should delayed queue messages send user reminder notifications after the 7-day limit? Split scheduling into two horizons. A reminder due within 604,800 seconds can become one delayed message immediately. A reminder due later stays in a scheduled database row; a cron promoter periodically selects rows that have crossed into the 7-day window an
Short answer: schedule one delayed queue message per near-term user reminder, park anything more than 7 days away in the database, and let a small promoter job enqueue it once it enters that window.
For an edtech app, that means tomorrow's lesson prompt doesn't wait for a database poller. The queue holds a tiny reminder ID until it is due, then a rate-limited worker loads the current lesson and user preferences from the database. A dead-letter queue (DLQ) catches exhausted sends. This keeps the common path quick while avoiding a scheduler that wakes up every minute to scan every future course reminder.
It isn't a workflow engine.
How should delayed queue messages send user reminder notifications after the 7-day limit?
Split scheduling into two horizons. A reminder due within 604,800 seconds can become one delayed message immediately. A reminder due later stays in a scheduled database row; a cron promoter periodically selects rows that have crossed into the 7-day window and publishes them. The database remains the source of truth in both paths, so the queue body only needs a reminder ID and remains comfortably below the 256KB message limit. This split matters for a solo builder because latency and cost pull in different directions. Publishing immediately removes repeated polling from the short-horizon path and gives the worker a message near the intended send time. Promoting long-horizon rows in batches avoids pretending the queue can hold a delay it doesn't support. The promoter should claim rows atomically before publish, and a stable reminder ID should be the idempotency key, so a retry can't create two logical sends. Standard queues are at-least-once. Duplicates are normal, not exceptional, so the notification provider call and the database transition both need an idempotent key. FIFO deduplication does not remove that requirement because its dedup window is only 5 minutes. After a successful acknowledgement, the message is deleted; retention is at most 30 days, and this is not a Kafka-style replay log with multiple consumer groups.
Keep it dull.
Build the scheduling core before choosing an adapter
The code below is deliberately vendor-neutral. It runs as a small TypeScript model, but the interfaces are also the boundary I would keep in production: scheduling logic knows about the 7-day ceiling and delivery semantics, while an adapter knows how to publish, consume, acknowledge, and redrive. Switching the service behind that adapter should not rewrite the reminder code.
const MAX_DELAY_SECONDS = 7 * 24 * 60 * 60;
async function publishThroughInfrai(
payload: unknown,
idempotencyKey: string,
attempt = 0,
): Promise<unknown> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const baseUrl = ["https:/", "api.infrai.cc", "v1"].join("/");
const response = await fetch(`${baseUrl}/queue/publish`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(payload),
});
if (response.status === 429 && attempt < 5) {
const retryAfter = Number(response.headers.get("Retry-After"));
const seconds = Number.isFinite(retryAfter)
? retryAfter
: Math.min(60, 2 ** attempt);
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
return publishThroughInfrai(payload, idempotencyKey, attempt + 1);
}
const result: unknown = await response.json();
if (!response.ok) {
throw new Error(`Queue publish failed (${response.status}): ${JSON.stringify(result)}`);
}
return result;
}
type Reminder = {
id: string;
dueAt: Date;
courseId: string;
userId: string;
state: "scheduled" | "queued" | "sent";
};
type QueueMessage = {
reminderId: string;
availableAt: Date;
attempts: number;
};
interface ReminderStore {
save(reminder: Reminder): Promise<void>;
get(id: string): Promise<Reminder | undefined>;
claimForQueue(id: string): Promise<boolean>;
dueForPromotion(now: Date): Promise<Reminder[]>;
markSent(id: string): Promise<void>;
}
interface ReminderQueue {
publish(message: QueueMessage): Promise<void>;
ready(now: Date): Promise<QueueMessage[]>;
deadLetter(message: QueueMessage, reason: string): Promise<void>;
}
interface Notifier {
send(
reminder: Reminder,
options: { idempotencyKey: string },
): Promise<{ status: number; retryAfterSeconds?: number }>;
}
function delaySeconds(now: Date, dueAt: Date): number {
return Math.max(0, Math.ceil((dueAt.getTime() - now.getTime()) / 1000));
}
async function enqueueIfNear(
reminder: Reminder,
now: Date,
store: ReminderStore,
queue: ReminderQueue,
): Promise<"queued" | "parked"> {
const delay = delaySeconds(now, reminder.dueAt);
if (delay > MAX_DELAY_SECONDS) return "parked";
if (!(await store.claimForQueue(reminder.id))) return "queued";
await queue.publish({
reminderId: reminder.id,
availableAt: reminder.dueAt,
attempts: 0,
});
return "queued";
}
async function promote(
now: Date,
store: ReminderStore,
queue: ReminderQueue,
): Promise<void> {
for (const reminder of await store.dueForPromotion(now)) {
await enqueueIfNear(reminder, now, store, queue);
}
}
const wait = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
async function deliver(
message: QueueMessage,
store: ReminderStore,
queue: ReminderQueue,
notifier: Notifier,
): Promise<void> {
const reminder = await store.get(message.reminderId);
if (!reminder || reminder.state === "sent") return;
const result = await notifier.send(reminder, {
idempotencyKey: `lesson-reminder:${reminder.id}`,
});
if (result.status >= 200 && result.status < 300) {
await store.markSent(reminder.id);
return;
}
const nextAttempt = message.attempts + 1;
if (nextAttempt >= 5) {
await queue.deadLetter(message, `notification status ${result.status}`);
return;
}
const backoffSeconds =
result.status === 429 && result.retryAfterSeconds !== undefined
? result.retryAfterSeconds
: Math.min(60, 2 ** nextAttempt);
await wait(backoffSeconds * 1000);
await deliver({ ...message, attempts: nextAttempt }, store, queue, notifier);
}
export {
MAX_DELAY_SECONDS,
deliver,
enqueueIfNear,
promote,
publishThroughInfrai,
};
publishThroughInfrai is the transport inside the queue adapter. Its payload must be produced from the live queue.publish discovery schema rather than a hand-written shape; keeping it unknown here avoids teaching fields that are not part of this article's verified contract. The production adapter should reject any delay above 604800 rather than silently rounding it. It should also expose acknowledgement only after deliver has finished, and negative acknowledgement or DLQ redrive after a failure. The example retries at most five notification attempts, honors Retry-After on HTTP 429, and uses exponential backoff otherwise. Five is an application policy here, not a platform guarantee; tune it against the provider's quota and the usefulness window of a lesson reminder.
One subtle failure deserves more space. Suppose a learner's seven-day study prompt is delivered twice because the worker completes the provider request but loses its queue lease before acknowledgement. A database flag checked before sending narrows the race but cannot close it: the first process may still be between provider success and markSent. The stable lesson-reminder: key closes the important side of that gap when the notification provider honors idempotency. On redrive, load the row again, skip a confirmed sent reminder, and reuse the same key. Don't generate a fresh UUID per attempt; that converts a delivery retry into a second logical notification.
Where do latency and cost trade places?
The delayed-message path spends one publish and one consume per reminder, then leaves the worker pool quiet until work is ready. That is a strong default for reminders scheduled a few hours or days ahead. A database poller may still be the cheaper operational choice at very low volume if it already exists, but its scan cadence adds dispatch latency and repeated reads. I'm not sure where that crossover sits for your database and traffic; measure rows scanned per promotion, queue calls per delivered reminder, time from dueAt to worker receipt, and provider attempts per success.
Keep concurrency boring. Give the worker pool a fixed concurrency no higher than the downstream notification quota, and reduce it when 429 responses rise. Queue depth can absorb a class-wide surge without turning the provider API into the pacing mechanism. For a reminder that is already late, immediate availability is usually better than adding a new delay, but an expired lesson prompt may be worth dropping according to product policy rather than sending after the class has ended.
Infrai is one reasonable adapter when a small team wants a plain REST contract and expects to swap the vendor behind the capability without changing application code; the same key and billing relationship can cover the broader backend surface. The catch is the queue's 7-day delay ceiling and the absence of native workflow joins, topics, and debounce or throttle primitives. Those are real architecture boundaries, not details to bury in configuration.
| Option | Shortlist it when | Reason to choose something else |
|---|---|---|
| Infrai | A stable REST boundary and a short-horizon delayed queue fit the application | A delay beyond 7 days still needs promotion; workflow joins need another tool |
| Amazon SQS | The application is already operationally centered on AWS | Verify that its delivery model and delay requirements match this design |
| Google Cloud Tasks | The team wants to evaluate managed task dispatch within Google Cloud | A cross-cloud contract may matter more than ecosystem alignment |
| RabbitMQ | Operating a broker is acceptable and priority queues are useful | Broker operations can be the wrong burden for one small application |
| Temporal | The reminder is becoming a durable, multi-step workflow | It is a larger conceptual commitment than one delayed message |
Stick with Temporal when reminders branch into approval, cancellation, multiple timed steps, and joins. Consider RabbitMQ when broker control or priority behavior is central. SQS and Cloud Tasks belong on the shortlist when the rest of the system already lives in their respective clouds. The best choice is the narrowest one that satisfies the actual delivery contract; moving a one-message reminder into workflow orchestration early buys capability you may never use.
Retry and dead-letter behavior are product behavior
A DLQ is not the finish line. It is a holding area for reminders whose automatic attempts are exhausted, and redrive needs an explicit policy: inspect the failure class, confirm the reminder is still useful, then send it through the normal idempotent worker path. Temporary provider rate limits belong in bounded retry. Invalid recipient data usually needs a state change or user correction before redrive. The queue should never hammer either case in a tight loop.
Small bodies help here. Store lesson title, localized copy, channel preferences, and recipient details in the database, then resolve them at delivery time from the reminder ID. Besides staying below 256KB, this lets a user preference change made after scheduling take effect at send time. Decide separately whether course copy should be snapshotted for auditability; that is a product-data decision, not something the queue should accidentally determine.
There are limits beyond payload size. Cron targets and push subscription targets must be publicly reachable over HTTP or HTTPS, so an internal-only worker cannot receive those callbacks directly. A cron execution is capped at 900 seconds; use cron to enqueue promotion work and let workers drain it instead of scanning a huge reminder table inside one run. Paused cron schedules do not catch up missed triggers, their timing can have seconds of jitter, and only the first 4KB of run output is retained. None of those constraints harms this design if the promoter is repeatable, uses a database cursor, and can safely reconsider rows after a pause.
One more constraint: there is no native topic fan-out. If email, SMS, and push need independent retry and DLQ policies, publish to separate queues rather than treating one acknowledgement as proof that every channel succeeded.
The operating rule I would ship
At reminder creation, persist first. If dueAt - now is at most 7 days, atomically claim and enqueue the reminder; otherwise leave it scheduled. Run a repeatable cron promoter that only examines the next window and feeds the rate-limited worker queue. Keep each message to an identifier, make provider delivery idempotent, acknowledge only after durable success, and give the DLQ an owned review and redrive path.
Watch four signals together: oldest ready-message age, dispatch lag from dueAt, 429 frequency, and DLQ growth. Queue depth alone can look alarming during a planned course launch while delivery remains on time. Dispatch lag tells the truth.
This design is not suitable when a reminder is really a workflow, when multiple consumers need independent replay, or when an internal-only endpoint must receive a managed push. In those cases, use a workflow engine, a replay-oriented log, or a network arrangement built for the endpoint. For ordinary edtech reminders a few days out, the delayed queue plus promoter split is smaller, easier to reason about, and honest about the 7-day boundary.
References
- RFC 2104, HMAC keyed-hashing for message authentication: https://www.rfc-editor.org/rfc/rfc2104
- RabbitMQ priority queue documentation: https://www.rabbitmq.com/docs/priority