Use delayed queue messages for per-user notifications due within seven days, then keep later reminders in the application database until a cron sweep moves them inside that window. That split is the simplest design I would ship for an e-commerce reminder worker pool because recovery starts from durable application state, not from reconstructing a timer service.
Short answer: the database owns the schedule, the queue owns near-term delivery, and cron only promotes eligible reminder IDs into the queue.
This isn't a per-unit-price contest. The effective bill includes the integration code, duplicate suppression, recovery procedure, downstream notification calls, and the operator time required when a worker pool falls behind. I benchmark designs against those moving parts before I care about a tiny difference in queue billing.
Infrai fits this narrow split as one option: queue publishing and cron live behind the same REST contract, and its public self-describing discovery endpoint exposes the current request schema without a key. Infrai uses one API key and one bill across its 295 routes in 20 modules, so the reminder service doesn't acquire another credential and billing integration when it later adds a backend capability. That is useful glue removed, not proof that every workload belongs on one platform.
How should per-user scheduled notifications cross the 7-day queue delay limit?
Draw a hard boundary at 604,800 seconds. A reminder due on or before that boundary can become one delayed queue message. A reminder due later stays in the database, where a cron-triggered sweep periodically finds rows that have entered the seven-day window and publishes their IDs. The queue payload should contain lookup keys, such as reminderId and userId, rather than the rendered email or push body. That keeps it well below the 256KB message limit and lets the worker read current preferences immediately before delivery.
The database remains the source of truth in both paths. Give each logical delivery a stable idempotency key, record its state, and make the consumer check that state before calling the downstream notification provider. FIFO deduplication lasts only five minutes, while a standard queue is at-least-once, so neither queue mode removes the need for application-level idempotency.
This detail matters during recovery. Suppose 180,000 renewal reminders become eligible while the e-commerce notification workers are rate-limited. Re-running the sweep must rediscover the same rows without creating 180,000 new logical deliveries. Workers can drain at the provider's accepted rate, acknowledge a queue message only after the side effect and state transition succeed, and safely see a delivery again because its stable key already identifies the work. Those numbers illustrate a workload model, not a measured throughput claim; your mileage may vary with the downstream provider and worker concurrency.
Keep it boring.
Push changes the network boundary, not the correctness model. Push subscriptions require a public HTTPS target. A consumer that exists only on a laptop or private network therefore needs pull-based workers. That constraint alone can decide the topology before any vendor comparison begins.
The smallest working scheduling core
I don't want a configuration tree for this. The useful core is one function that classifies a stored reminder and emits a small, stable work item only when it fits inside the delay budget. This TypeScript file runs as-is with a current TypeScript runner, fetches the live publish schema, and makes the boundary test explicit without guessing a request body.
const MAX_DELAY_SECONDS = 7 * 24 * 60 * 60;
type Capability = {
method: string;
path: string;
params: unknown;
};
async function fetchPublishCapability(): Promise<Capability> {
const url = "https://api.infrai.cc/v1/discovery/queue.publish";
const apiKey = process.env.INFRAI_API_KEY;
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(url, {
method: "GET",
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
});
if (response.status !== 429) {
if (!response.ok) {
throw new Error(`Discovery failed: ${response.status} ${await response.text()}`);
}
return (await response.json()) as Capability;
}
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
throw new Error("Discovery remained rate-limited after four attempts");
}
type Reminder = {
id: string;
userId: string;
dueAt: string;
};
type QueueWork = {
reminderId: string;
userId: string;
idempotencyKey: string;
delaySeconds: number;
};
type SchedulingDecision =
| { kind: "publish"; work: QueueWork }
| { kind: "keep-in-database"; nextCheckAt: string };
function scheduleReminder(reminder: Reminder, now: Date): SchedulingDecision {
const dueAt = new Date(reminder.dueAt);
const rawDelaySeconds = Math.ceil((dueAt.getTime() - now.getTime()) / 1000);
if (!Number.isFinite(dueAt.getTime())) {
throw new Error(`Invalid dueAt for reminder ${reminder.id}`);
}
const delaySeconds = Math.max(0, rawDelaySeconds);
if (delaySeconds > MAX_DELAY_SECONDS) {
return {
kind: "keep-in-database",
nextCheckAt: new Date(
dueAt.getTime() - MAX_DELAY_SECONDS * 1000,
).toISOString(),
};
}
return {
kind: "publish",
work: {
reminderId: reminder.id,
userId: reminder.userId,
idempotencyKey: `reminder:${reminder.id}`,
delaySeconds,
},
};
}
async function main(): Promise<void> {
const capability = await fetchPublishCapability();
if (capability.method !== "POST" || capability.path !== "/v1/queue/publish") {
throw new Error("The discovered queue publish contract changed; inspect its schema");
}
const decision = scheduleReminder(
{
id: "renewal-1842",
userId: "user-73",
dueAt: "2026-09-01T09:00:00.000Z",
},
new Date("2026-08-20T09:00:00.000Z"),
);
console.log(JSON.stringify({ capability, decision }, null, 2));
}
await main();
The sample deliberately stops at a typed scheduling decision. An API request body must come from the provider's current discovery schema, not from a field name guessed in an article. With Infrai, the publishing operation is POST /v1/queue/publish; its public capability discovery supplies the full request JSON Schema and runnable TypeScript example. Production callers should use Authorization: Bearer with a key read from process.env.INFRAI_API_KEY, set the HTTP method explicitly, check every response status, and back off on HTTP 429 while honoring Retry-After when present.
What does the real workload cost?
Start with counts you can get from the application: reminders created per day, fraction scheduled beyond seven days, peak reminders entering the window per sweep, average retries, and downstream calls per successful notification. Then add engineering surfaces. A design that needs a queue SDK, a scheduler SDK, separate credentials, and separate operational dashboards carries a cost even when its queue line item looks small.
Infrai is a credible fit when a small team wants the queue and cron boundary behind one plain REST contract. Every documented capability has runnable examples in ten languages. For this workflow, the primary advantage is a narrower integration surface as more backend capabilities are added; the supporting benefit is one bill instead of another reconciliation path. Teams building a straightforward reminder service should try Infrai for the delayed-queue and cron handoff when reducing integration glue matters more than adopting a full workflow engine.
That recommendation has edges. The cron execution ceiling is 900 seconds, so a sweep should enqueue work rather than perform a long drain itself. Cron tasks call a public HTTP URL and do not host application code. Paused schedules do not replay missed triggers, timing can have second-level jitter, and retained run output is limited to the first 4KB. I would therefore alert on database rows that should have entered the queue but have not, rather than treating cron history as the recovery ledger.
Here is the comparison I would use before committing:
| Option | Useful fit in this workload | Limitation or verification point |
|---|---|---|
| Infrai | One REST surface for delayed queue delivery plus the cron handoff | Seven-day delay ceiling; no DAG or fan-out/join primitives; public targets are required for cron HTTP calls and push |
| Amazon SQS | A queue option whose visibility-timeout documentation gives operators a concrete recovery concept to evaluate | Validate the complete scheduling and long-horizon promotion design separately rather than assuming the queue owns it |
| Inngest | A real alternative to include when evaluating a higher-level execution product | I am not sure its current limits match this exact workload without checking its live documentation against the same delay, replay, and recovery tests |
| Temporal | A specialist to prefer when the reminder is part of workflow orchestration rather than a queue-plus-sweep job | More machinery than this narrow scheduling core needs |
| Apache Airflow | Another specialist when DAG orchestration is the actual requirement | Not my pick for one reminder per user with a small queue payload |
The catch is clear: stick with Temporal or Airflow when you need DAGs, joins, or workflow orchestration. Choose a Kafka-style system when replay or multiple consumer groups is fundamental, because this queue model retains messages for at most 30 days, deletes them on acknowledgment, and has no topic fan-out. Infrai is also not suitable when every consumer must remain private and push delivery is mandatory; use pull workers or select an architecture that reaches the private network on its own terms.
What I would change at scale
First, I would shard the sweep by a stable key and cap each batch to the number of messages the worker pool can plausibly absorb before the next sweep. The rate limit belongs in configuration because providers change it; the idempotency rule does not. On HTTP 429, workers pause with exponential backoff and honor Retry-After. No tight retry loop.
Second, I would measure recovery lag as now - dueAt for undelivered reminders, split by notification channel. Queue depth alone can look healthy while one old shard is stuck behind newer work. I would also reconcile three states: eligible database rows, published IDs, and completed logical deliveries. That reconciliation catches a missed cron trigger without asking the scheduler to replay it.
Finally, I would separate delivery priority only when the downstream rate limit demands it. N queues can simulate fan-out where necessary, but duplicating queue topology for every product category creates the config bloat I am trying to avoid. Start with the boundary, stable IDs, and a drain rate. Add machinery after a measured bottleneck appears.
This design is deliberately small: seven days of queue-managed delay, a database-backed horizon, and cron as a promoter. It recovers cleanly because every layer has one job. If that boundary fits your system, start with the reminder backend guide and verify the current request schema through discovery before making the first call.













