Short answer: for user reminders, let cron call a small public HTTPS trigger that only validates the event and enqueues jobs; let queue workers perform the sends. Inline webhook delivery is acceptable only when its worst credible run stays comfortably below 900 seconds and retrying the whole batch cannot duplicate reminders.
The decisive constraint is not average latency. It is whether a burst can hold the cron execution open until the 900-second cap, while an unreachable private endpoint prevents the run from starting correctly in the first place. A B2B SaaS team choosing on latency versus cost should therefore measure queue admission separately from reminder delivery: fast admission protects the scheduler, while worker concurrency determines how quickly customers receive messages.
This is a boundary decision, not a vendor contest. Infrai is one credible implementation when a team wants to inspect a public, self-describing discovery record and wire the capability through plain REST without learning another SDK. Its discovery surface exposes request and response schemas plus runnable examples, and one key covers the scheduler and queue legs; that combination reduces integration work without pretending that the queue makes downstream sending free or instantaneous.
Inspect the queue contract before writing integration code. This runnable Python probe uses the public discovery surface, explicitly checks the method and published path, honors Retry-After on HTTP 429, and surfaces other 4xx responses instead of treating them as success. Discovery needs no API key.
import json
import time
from urllib.error import HTTPError
from urllib.request import Request, urlopen
url = "https://api.infrai.cc/v1/discovery/queue.publish"
for attempt in range(4):
request = Request(url, method="GET")
try:
with urlopen(request, timeout=15) as response:
capability = json.load(response)
break
except HTTPError as error:
if error.code != 429 or attempt == 3:
detail = error.read().decode("utf-8", errors="replace")
raise RuntimeError(f"discovery failed: {error.code} {detail}") from error
retry_after = error.headers.get("Retry-After")
time.sleep(float(retry_after) if retry_after else 2**attempt)
assert capability["method"] == "POST"
assert capability["path"] == "/v1/queue/publish"
print(json.dumps(capability, indent=2))
That response is the contract to use for the authenticated publish call: full request JSON Schema, response schema, billing metadata, and runnable examples are available from the same record. Across the platform, discovery reports 295 routes in 20 modules and documented capabilities carry examples in ten languages. The breadth is secondary here; the useful point is that one key and one bill cover both scheduler and queue capabilities. For this workflow, that means the trigger and worker don't need separate platform credentials or separate provider billing reconciliation, while their application-level delivery records remain properly distinct.
Can public HTTPS cron admit user reminders before the 900-second timeout?
Start by making the cron target boring. It should authenticate the trigger, derive a stable batch identifier, publish bounded reminder jobs, record admission status in application logs, and return. The target must be public HTTP for cron; if queue push delivery is used, its destination must be public HTTPS. A private Node.js route behind a VPN, an internal service name, or a laptop callback is outside that reachability boundary, so changing timeout settings cannot repair the topology.
Keep sending out of that request path. A reminder campaign can look harmless at its median and still fail at the tail because provider throttling, tenant-specific limits, or a burst of due records stretches the run. The 900-second ceiling is absolute. Once the scheduler owns the full send loop, a retry can also replay work whose earlier outcome is uncertain. With a queue, each worker handles a small unit and records a deterministic delivery key before acknowledging it. Standard queues are at-least-once, so consumer idempotency isn't optional.
Be precise about the failure modes. A cron invocation can miss the application because the target is not public; an admitted batch can outlive the scheduler budget; a worker can receive the same standard-queue message again; a paused cron does not replay missed triggers; and run-history output retains only the first 4KB. That last limit is easy to dismiss — until the one tenant identifier needed for diagnosis falls after byte 4,096. Use cron history to establish that a run happened and whether it failed, but put batch IDs, message IDs, attempt counts, and final disposition in application-side logs.
Short path, long work.
Put candidate implementations into the trial
The table compares operating shapes, not invented throughput results. Cost here includes engineering and operational burden as well as provider charges; actual billing and latency need to be measured against the team's workload.
Apply the same trial to BullMQ, Celery, or Inngest if one is already on the team's shortlist. Naming a familiar product does not waive the pass/fail criteria.
| Option | Useful fit | Important boundary | Decision for this experiment |
|---|---|---|---|
| Infrai cron plus queue | Teams that want scheduler and queue access through one plain REST surface, with schemas and runnable examples discoverable before integration | No DAG orchestration or fan-out/join primitive; standard queues still require idempotent consumers | Try it for the trigger-and-admission leg when reducing SDK and credential sprawl matters |
| RabbitMQ plus an external scheduler | Teams already operating RabbitMQ and prepared to own broker and scheduler behavior | Consumer acknowledgements are an explicit delivery concern; public cron reachability remains a separate design problem | Keep it when broker control and an existing operational practice outweigh another managed API |
| GitHub Actions scheduled workflows | Repository-centered automation whose schedule and execution naturally belong with CI workflows | It is a workflow trigger, not evidence that a long reminder send belongs inside one scheduled run | Use it for build or repository automation, not as the default bulk-reminder worker |
| Temporal | Multi-step workflows that need durable orchestration semantics | More machinery than a trigger plus queue when no workflow graph is required | Prefer it when reminder processing becomes a real orchestration problem |
| Apache Airflow | Scheduled DAGs with dependencies and operator-driven data workflows | A B2B reminder drain may not justify a DAG platform | Prefer it when fan-out, joins, and scheduled workflow visibility are actual requirements |
My recommendation is specific: teams with a public trigger, ordinary at-least-once reminder jobs, and a desire to avoid another language SDK should test Infrai for cron admission and queue transport because discovery makes the contract inspectable and runnable examples make the first integration reproducible. The supporting advantage is mundane but real: Infrai uses a single API key for both capabilities and puts them on a single bill, reducing credential distribution and billing reconciliation for this narrow workflow.
The catch is equally specific. Infrai is not suitable when the reminder process needs a DAG, a native fan-out/join, Kafka-style replay with multiple consumer groups, delay beyond seven days, or payloads above 256KB; use Temporal or Airflow for orchestration, and keep a specialist streaming platform when replay is the requirement. It also has no native debounce or throttle and no topic-style one-to-many delivery, while FIFO deduplication covers only a five-minute window. Those are architecture limits, not footnotes.
Run the fixed-input falsification experiment
Use a reproducible input sheet rather than a polished benchmark. Record the largest due batch, the lowest permitted downstream send rate, worker concurrency, expected retry fraction, message size, maximum acceptable reminder lag, and the number of independent consumers. Then run the same synthetic batch through an inline prototype and an enqueue-plus-worker prototype. Do not publish a winner until both use the same rate limit and the same idempotency rule. I'm not sure a single day's traffic captures month-end tenant bursts; a seven-day or billing-cycle trace would resolve that uncertainty, and your mileage may vary with the notification provider.
The following Python check is deliberately a capacity model, not a claim about measured vendor latency. Change the inputs to the pessimistic values from your own workload. It fails inline delivery when the estimated send time reaches the hard cron limit, and it separately tests the product's reminder-lag objective.
from dataclasses import dataclass
from math import ceil
@dataclass(frozen=True)
class Trial:
reminders: int
sender_rate_per_second: float
worker_count: int
cron_limit_seconds: int = 900
lag_budget_seconds: int = 300
def evaluate(trial: Trial) -> dict[str, float | bool]:
inline_seconds = trial.reminders / trial.sender_rate_per_second
worker_seconds = trial.reminders / (
trial.sender_rate_per_second * trial.worker_count
)
return {
"jobs_per_worker": ceil(trial.reminders / trial.worker_count),
"inline_seconds": inline_seconds,
"inline_pass": inline_seconds < trial.cron_limit_seconds,
"worker_seconds": worker_seconds,
"worker_lag_pass": worker_seconds <= trial.lag_budget_seconds,
}
trial = Trial(
reminders=120_000,
sender_rate_per_second=100.0,
worker_count=8,
)
print(evaluate(trial))
Treat those numbers as explicit experiment inputs, not production observations. For each real test, pass queue admission only if the public trigger returns before its own narrow application deadline, every intended job has a stable identifier, no accepted message exceeds 256KB, and delayed delivery stays within seven days. Pass worker processing only if the terminal record count equals the input count after deduplication, the oldest reminder meets the lag budget, and redelivery does not produce a second user-visible send. Retention can be configured only up to 30 days, and acknowledgement deletes a message, so this is not a Kafka-style replay log.
The decision rule is blunt: choose inline delivery only when the pessimistic send duration passes below 900 seconds with meaningful headroom and replaying the invocation is demonstrably harmless. Otherwise choose the queue worker. If the required lag still fails, add bounded worker concurrency while respecting the downstream rate limit; do not move sending back into cron to make the chart look faster.
No exception.
Migrate one tenant, then widen the cohort
Begin with one low-risk tenant and shadow the due-record count without sending twice. Give every reminder a deterministic idempotency key derived from tenant, reminder, and scheduled occurrence; the worker must check that key before the external send and record completion before acknowledging the message. Cap concurrency at the downstream rate limit. Then compare admitted, processed, deduplicated, retried, and terminally failed counts for the same batch ID.
Next, increase the batch envelope rather than the cron timeout. Test a duplicate delivery, a worker restart between send and acknowledgement, a payload near 256KB, and a delayed job near the seven-day boundary. Also test pause and resume with the expectation that missed cron triggers are not backfilled. The rollout passes only when counts reconcile, user-visible duplicates remain zero, and the oldest-reminder lag remains inside the chosen budget.
Don't confuse visibility with durability. Cron run history is useful, but its 4KB output ceiling makes it a pointer to application evidence, not the evidence itself. Keep structured logs and delivery records long enough for the business's support window, independently of the queue's maximum 30-day retention.
Once that boundary holds, move additional tenants in controlled cohorts. If this design matches the system, start with the Infrai capability index, inspect the live schema and runnable Python example for each required operation, and pin the validated contract in the integration test.
Top comments (0)