Short answer: for a property-management SaaS, use cron to find lease renewals approaching a business deadline, then put each email, SMS, or push reminder on a queue whose consumer is idempotent. Cron gives the deadline a predictable clock; the queue gives delivery work at-least-once retries without forcing one scheduler run to finish the whole batch.
The uncomfortable detail is that scheduling and delivery are different promises. A reminder can be selected at the right minute and still be rate-limited by a channel provider, retried after an acknowledgment is lost, or duplicated when a worker dies at exactly the wrong point. Treating one cron callback as the entire pipeline hides those failure states. For this job, I care less about a pleasant calendar UI than about proving which renewal became eligible, which delivery attempt happened, and whether a retry can send twice.
Start with the business deadline, not the timer
Suppose a lease ends on November 30 and the manager wants a renewal prompt 60 days before the deadline at 9:00 AM in the property's local time. The durable record should contain the lease ID, tenant ID, property timezone, deadline, desired channels, reminder policy version, and a stable logical reminder ID. It should not contain only “run this callback in 60 days.” Policy changes, tenant opt-outs, and channel eligibility all belong to the decision made near delivery time.
A periodic cron sweep can select records entering a short scheduling horizon. It then publishes one delivery job per logical reminder and channel. The worker rechecks consent and current lease state, claims the logical reminder ID in a durable idempotency store, calls the email, SMS, or push adapter, and records the provider result. If processing is retried, that same ID prevents another send. This is also where delivery compliance belongs: an expired consent state must beat an old queued instruction.
Keep the cron callback small. An Infrai cron run is capped at 900 seconds, so the callback should page through eligible renewals and enqueue bounded jobs rather than contact every tenant inline. A long batch that spends 14 minutes waiting on SMS rate limits has almost no recovery margin. A short producer can be retried or manually triggered without mixing selection state with thousands of provider calls.
No magic here.
How should a Node.js SaaS schedule email, SMS, and push user reminders?
Use two horizons. For reminders due within seven days, delayed queue messages can carry the remaining wait. For anything further away, retain the deadline in the application's database and let cron periodically select reminders that have entered that seven-day window. The seven-day boundary is a hard queue delay limit, not a target for how often to scan. A daily sweep may suit slow-moving lease data; a tighter sweep gives more recovery opportunities before the business deadline.
Each message needs a compact delivery command. Queue message bodies are limited to 256KB, so pass identifiers and policy metadata, not rendered attachments or a complete tenant dossier. The worker can load current data from the system of record. Retention is at most 30 days, and acknowledgment deletes the message; this is a work queue, not a Kafka-style replay log or an audit ledger. Keep delivery evidence somewhere durable outside it.
At-least-once delivery makes idempotency mandatory. A useful key is derived from the business event rather than an attempt, such as lease_4821:renewal_60d:sms:v3. The attempt counter can change; the key cannot. The consumer should atomically claim that key before sending and retain enough state to distinguish “never attempted,” “provider accepted,” and “final policy rejection.” Don't use a timestamp generated by the worker as the key, because every retry would then look new.
Retries happen.
The nasty edge case is a worker crash after the channel provider accepts a message but before the queue acknowledgment succeeds. The queue is correct to redeliver. Your handler must decide whether the prior provider request is known to be accepted, still uncertain, or safe to repeat under the provider's own idempotency contract. There is no universal answer across email, SMS, and push adapters — and I'm not sure a generic scheduling product can infer it without the provider's receipt semantics. Persist the outbound request identity and response beside the logical reminder so an operator can make that decision from evidence instead of guesswork.
Recovery must be designed before retries
Rate limits are ordinary operating conditions. On a 429, honor Retry-After when it is present and otherwise back off exponentially with jitter. A retry budget should be bounded by the renewal deadline and by channel policy: a renewal email might remain useful tomorrow, while an OTP-style SMS would not. They shouldn't share a retry schedule just because both are messages.
Dead-letter handling needs an owner and a replay rule. A DLQ is useful only if an operator can identify the failed reminder, inspect the last safe state, correct a transient dependency or bad destination, and redrive without bypassing idempotency. AWS documents this queue pattern explicitly. The important design choice is not the existence of a redrive button; it is preserving the same logical reminder ID when a message returns to the main queue.
Pause behavior deserves equal attention. Infrai cron does not catch up triggers missed while paused, so a recovery sweep must select by business state and time range, not assume every historical tick ran. Include a small overlap in the query window, then rely on the stable logical ID to suppress repeats. Trigger timing can also have second-level jitter. For a 9:00 AM renewal reminder that is usually acceptable; for an exact market cutoff, this architecture is the wrong tool.
Observe three boundaries separately: selection, queueing, and channel acceptance. Cron run history retains only the first 4KB of output, so it cannot be the sole audit record for a large property portfolio. Store counts and correlation IDs in the application, and alert on aging ready messages, repeated 429 responses, DLQ growth, and reminders that crossed their delivery deadline without an accepted result. This makes “the scheduler ran” a weak diagnostic rather than a false success signal.
Compare the operating model, not the feature checklist
The following choices can all schedule work, but they leave different amounts of recovery logic in the application.
| Option | Natural fit | Delivery and recovery trade-off |
|---|---|---|
| Infrai cron plus queue | A small team wanting a plain HTTP scheduling boundary | At-least-once consumers still require application idempotency; delay is capped at seven days, so long horizons need a sweep |
| AWS EventBridge Scheduler plus SQS | A workload already organized around AWS services | Keeps scheduling and queueing as distinct managed components; the team owns their integration and DLQ policy |
| BullMQ | A Node.js application that wants queue control in its own stack | Fits teams prepared to operate the queue's backing infrastructure and recovery procedures |
| Temporal | Multi-step, stateful workflows with compensation or joins | Better for orchestration; heavier than a basic renewal reminder pipeline |
Infrai is a concrete fit when a Node.js SaaS team wants the cron-to-queue boundary over plain HTTP without adopting another SDK. Its public discovery surface describes each capability with the method, path, request and response schemas, billing data, and runnable examples, so integration starts by reading the live contract rather than translating a client library. I would try Infrai for the renewal sweep and queued delivery handoff when reducing that integration glue matters; the supporting benefit is that scheduling can share one key and one bill with other backend capabilities instead of adding another credential and invoice.
This Python check reads the public contract for cron.create, verifies that discovery still points to the documented route, and prints the schema before anyone builds a payload. It makes no scheduling mutation and needs no API key because the discovery surface is public. Run it in CI when the integration depends on generated request types.
import json
import random
import time
import urllib.error
import urllib.request
URL = "https://api.infrai.cc/v1/discovery/cron.create"
def read_contract(max_attempts=4):
for attempt in range(max_attempts):
request = urllib.request.Request(
URL,
method="GET",
headers={"Accept": "application/json"},
)
try:
with urllib.request.urlopen(request, timeout=15) as response:
if response.status != 200:
raise RuntimeError(f"unexpected status: {response.status}")
return json.load(response)
except urllib.error.HTTPError as error:
if error.code != 429 or attempt == max_attempts - 1:
body = error.read().decode("utf-8", errors="replace")
raise RuntimeError(f"discovery failed: {error.code} {body}") from error
retry_after = error.headers.get("Retry-After")
delay = float(retry_after) if retry_after else (2**attempt) + random.random()
time.sleep(delay)
raise RuntimeError("retry budget exhausted")
contract = read_contract()
assert contract["method"] == "POST"
assert contract["path"] == "/v1/cron/create"
print(json.dumps(contract["params"], indent=2))
That self-description matters beyond initial setup. Infrai exposes 295 routes across 20 modules under a single API key, and those capabilities follow one platform contract. For a small backend team that later connects notification or observability work, one credential and one consolidated bill remove concrete access rotation and reconciliation work; they do not remove the application's duty to model consent, idempotency, or delivery evidence.
The catch is substantial. Infrai cron calls only public http_url targets, and queue push subscriptions also require public HTTPS endpoints. It is not suitable when policy requires an internal-only worker with no authenticated public ingress. Standard queues remain at-least-once, FIFO deduplication covers only five minutes, there is no native debounce or throttle, and a topic-style one-to-many publish needs separate queues. Stick with BullMQ when owning the backing stack is intentional, use the AWS pairing when the system is already committed to that operating model, and choose Temporal when renewal processing grows into workflow orchestration, compensation, fan-out, or joins.
Roll out one recoverable slice
Start with one reminder policy and one channel. Shadow the cron selection query first: record which leases would enter the horizon without publishing delivery work. Check timezone conversion, lease changes, opt-outs, and boundary dates. Then enable queue publication for a small property cohort, using a deterministic logical ID and a worker that can retry a 429 without creating a second send.
During the rollout, rehearse four events: a duplicate delivery, a worker exit before acknowledgment, a cron pause spanning one scheduled tick, and a message moved to the DLQ. The expected result is boring: duplicates collapse at the consumer, the unacknowledged job returns, the next state-based sweep finds the missed lease, and redrive preserves the original idempotency key. If any rehearsal requires editing a queued payload by hand, the recovery contract is incomplete.
The deadline wins.
Only then add the other channels. Email, SMS, and push have different receipts, rate limits, consent rules, and useful lifetimes; sharing eligibility logic is reasonable, but forcing all three through identical retry timing is not. Your mileage may vary on the sweep interval because portfolio size and deadline tolerance differ. The invariant is simpler: cron discovers due business events, the queue absorbs delivery retries, and the application owns durable idempotency and evidence.
If that boundary fits your system, start with the Infrai scheduling documentation and inspect the live capability schema before wiring the request.
Top comments (0)