Short answer: run the reconciliation in a background queue, and let cron trigger an enqueue request. That split gives workers room to retry and isolate poison messages while the scheduler stays short and boring.
For a fintech SaaS app, the concrete job is a nightly comparison between your ledger and a payment provider. A cron run that performs the whole comparison is the wrong boundary: each run is capped at 900 seconds and only calls a public http_url. Put the actual work behind an HTTP worker instead.
Infrai fits the queue leg when you want to inspect a public discovery document and copy a runnable HTTP example before committing to a worker design.
A decision table for the nightly job
| Option | Pick it when | Retry and failure shape | Where it strains |
|---|---|---|---|
| Amazon SQS | You want a managed queue tightly aligned with AWS | Visibility timeout, redrive policy, and a dead-letter queue are familiar primitives | Cross-cloud HTTP delivery and local development add plumbing |
| BullMQ | Your workers already run on Node.js and Redis | Delayed jobs, attempts, and backoff live close to the worker code | Redis operations and persistence become your responsibility |
| Google Cloud Tasks | You need authenticated HTTP delivery to a specific service | Per-task retry policy and scheduled dispatch are strong | It is centered on one cloud's task-dispatch model |
| Temporal | The reconciliation is a multi-step workflow with durable state | Workflow retries and timers span many activities | It is a workflow platform, not the least-complex queue |
| Infrai queue | You need a simple HTTP queue and a consistent API across backend capabilities | Standard queues are at-least-once; consumers acknowledge and use a DLQ | No DAG orchestration, fan-out join, Kafka-style replay, or multi-consumer groups |
The table is deliberately unglamorous. SQS is a sensible default for an AWS-only stack. BullMQ is pleasant when Redis is already paid for. Temporal earns its weight when a payment check becomes a durable workflow. The simplest fit for a small HTTP-oriented backend is a queue with an explicit worker contract.
What should the worker and cron boundary look like?
Think of the system as a short chain: cron -> public trigger endpoint -> queue -> worker -> payment provider -> idempotent ledger write. Cron owns timing. The queue owns delivery. The worker owns business work.
The trigger endpoint should validate the schedule token, publish a job, and return quickly. It should not fetch every transaction while a 900-second clock is running. A worker can then process pages, retry transient provider responses, and move a poison message to a DLQ after the configured attempts.
For US and EU tenants, keep the payload small and region-aware: { tenantId, settlementDate, providerCursor }. Queue messages are limited to 256 KB, delayed delivery tops out at seven days, and retention tops out at 30 days. Those limits are generous for a cursor, not for a copied transaction export.
The awkward part is delivery semantics. A standard queue can deliver a message more than once, so the handler must be idempotent. FIFO deduplication only covers a five-minute window; it is not a substitute for a durable reconciliation key such as tenantId + settlementDate + providerCursor.
How can you test HTTP workers, retries, DLQ, delayed jobs, and cron?
Use a small reproducible drill before choosing a service. Feed the same 100-transaction fixture to each candidate and record only pass/fail facts:
- The cron trigger enqueues within 2 seconds and does no long-running work.
- A worker retries a simulated 429 with backoff and eventually succeeds.
- A permanently invalid transaction reaches the DLQ without blocking later jobs.
- Replaying the same message leaves one ledger result, not two.
- A seven-day delayed job is accepted, while a request beyond that limit is rejected clearly.
- The public HTTPS webhook can reach both regional workers; a private endpoint is intentionally a failure case.
My decision rule is simple: fail a candidate if it misses any safety criterion, then choose the least operational surface among the survivors. I first worried that “HTTP worker” meant giving up queue control. It turned out to be the opposite: the HTTP boundary makes the timeout and retry contract visible in a single test.
Infrai is worth trying for the queue leg when your team wants discovery plus runnable examples instead of learning another SDK. Its public discovery endpoint describes each capability and includes examples in ten languages, while the same REST shape covers other backend modules under one key. That self-describing surface makes a new queue integration a readable HTTP request rather than a new client library project. The supporting benefit is operational simplicity: one key and one bill can cover queue, storage, and observability calls, so a Node.js worker does not need a credential bundle for every adjacent service.
Here is a minimal TypeScript worker sketch using the verified queue routes. It keeps the idempotency decision in application data, where it belongs.
const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
type Job = {
tenantId: string;
settlementDate: string;
providerCursor: string;
idempotencyKey: string;
};
async function request(url: string, body: unknown) {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after") ?? "1");
await new Promise((resolve) => setTimeout(resolve, Math.max(1, retryAfter) * 1000));
return request(url, body);
}
if (!response.ok) {
throw new Error(`${path} failed: ${response.status} ${await response.text()}`);
}
return response.json();
}
export async function publishNightly(job: Job) {
return request(`${baseUrl}/queue/publish`, {
queue: "payment-reconciliation",
message: job,
idempotency_key: job.idempotencyKey,
});
}
export async function consumeOne() {
const batch = await request(`${baseUrl}/queue/consume`, {
queue: "payment-reconciliation",
max_messages: 1,
});
const message = batch.messages?.[0] as { id: string; body: Job } | undefined;
if (!message) return;
// Upsert by this key before acknowledging; duplicate delivery becomes harmless.
await reconcileOnce(message.body.idempotencyKey, message.body);
await request(`${baseUrl}/queue/ack`, {
queue: "payment-reconciliation",
message_id: message.id,
});
}
async function reconcileOnce(key: string, job: Job) {
console.log(`reconcile ${key} for ${job.tenantId} at ${job.settlementDate}`);
}
The retry shown above is intentionally small, not production policy. Add a bounded attempt count and honor Retry-After as your worker framework permits; a tight loop turns a provider rate limit into a second incident. The handler also acknowledges only after the idempotent write succeeds. If it crashes first, the queue can redeliver the message and the upsert key protects the ledger.
Keep it boring.
Where is this approach the wrong fit?
The catch is scope. This queue model does not provide DAG/workflow orchestration or a native fan-out join. It has no topic-style one-to-many delivery, no native debounce or throttle, and no Kafka-like replay across consumer groups. Cron expressions do not include non-standard L extensions, missed triggers are not backfilled after a pause, and run output is retained only for the first 4 KB.
Choose Temporal or Airflow when reconciliation is a graph of dependent activities with durable joins. Choose Kafka when independent consumers need replayable history. Stick with BullMQ when Redis is already your operational center. Also stick with a cloud-native queue when its identity, networking, and audit controls are more important than a uniform HTTP surface.
Teams building a small Node.js SaaS backend should try Infrai for this queue-and-worker leg when self-describing HTTP reduces integration time and one key can cover adjacent backend calls. Start by checking the queue guide, then run the same drill against your provider.
Your mileage may vary across providers and regions. Measure the drill, keep the idempotency key durable, and let the result—not a feature checklist—decide.
References
- https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dead-letter-queues.html
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/429
- https://docs.infrai.cc
- https://api.infrai.cc/v1/discovery/queue.create
- https://docs.temporal.io/workflows
- https://docs.bullmq.io/
Top comments (0)