DEV Community

SladeBarrett9642
SladeBarrett9642

Posted on

Node.js Cron Queue Pattern: Batch-Enqueue Nightly Pending Webhooks for Workers

Short answer: use cron to call a public HTTP endpoint that finds pending weekly-digest webhooks and batch-enqueues them, then let idempotent queue workers perform delivery.

That split is the architecture decision. The scheduled request must finish quickly; it is a trigger, not a place to run a delivery loop. A cron run has a 900-second ceiling, paused schedules do not replay missed triggers, and a standard queue can deliver a message more than once. The application therefore owns both reconciliation and delivery deduplication.

For a gaming backend, I would make the unit of work one customer digest for one weekly period. The stable identity is something like customer_id + digest_week, not the time at which cron happened to wake up. This matters when a schedule is manually triggered, delayed by second-level jitter, or resumed after a pause. The scan can see the same row twice. That is expected.

Duplicates are normal.

Reliability starts at the failure boundary

It should enqueue references to durable application records, in batches, and return after publication. The public endpoint first claims or reads pending digest deliveries from storage. It then derives a deterministic job ID for every customer and digest week, publishes those jobs in a batch, and records enough state to reconcile any item that remains pending on the next scan. A worker receives a job, loads the current record, attempts the webhook, and marks the stable job ID complete only after the delivery policy says the attempt succeeded.

The invariants are more useful than a framework diagram:

  1. A scheduled call never performs the full fan-out of customer webhook requests.
  2. Every logical digest has a stable idempotency identity across scans and retries.
  3. Storage remains the authority for work that still needs to happen; cron history is not the backlog.
  4. A worker can receive the same standard-queue message again without sending the digest twice.
  5. A batch publication failure leaves records eligible for reconciliation rather than silently declaring them delivered.

The failure boundaries follow those invariants. Cron may call the endpoint again, the endpoint may scan overlapping records, and a worker may see redelivery. None of those events may create a second customer message. A provider-side FIFO deduplication window is only five minutes, so it cannot represent a weekly business invariant. Keep the durable deduplication record in application storage.

This is also a deliverability boundary. Queue acknowledgement should follow the durable delivery decision, not precede it. For email or SMS digests, a retry that escapes application idempotency can become duplicate content, trigger complaints, and make a technically successful campaign look like abuse. OTP traffic makes the same lesson harsher: timing and identity are part of correctness, not operational polish.

What should a Node.js nightly cron trigger enqueue for pending webhook workers?

The selected design has four components: a cron definition, a public scan endpoint, a queue, and workers. Cron calls the scan endpoint on the weekly schedule. The endpoint finds pending records and uses batch publish for throughput. Workers handle long-running webhook delivery and application-level retry. A reconciliation scan is deliberately repeatable because missed schedules are not replayed after cron has been paused.

The payload should stay small. Queue messages are capped at 256KB, but a task reference plus a deterministic identity is preferable even far below that limit: the worker reads the latest destination and delivery state from storage instead of acting on a stale copy. Delayed messages can be scheduled no more than seven days ahead, retention is at most 30 days, and acknowledgement deletes a message. This queue is not a Kafka-style replay log and does not offer multiple consumer groups. If compliance requires a durable audit trail, store delivery decisions separately.

The public HTTP constraint deserves an explicit threat-model review. Cron can call only a public http_url, and a push subscription target must be public HTTPS. Authenticate the scan endpoint, reject unexpected methods, rate-limit it, and make repeated authorized calls harmless. Do not expose an endpoint whose caller can choose arbitrary customer IDs or webhook destinations. The endpoint should only request a bounded reconciliation pass over server-owned records.

Use a batch size that allows the endpoint to remain comfortably below 900 seconds, including storage latency and rate-limit backoff. There is no universal number. I'm not sure which batch size is right for a given game because the supplied evidence does not include its pending-row distribution or queue latency; production histograms for scan duration, rows found, rows published, and HTTP 429 responses would resolve that choice. Start with a bound, measure, and preserve a cursor or repeatable pending predicate.

No tight loops.

Trade-off matrix for queues and workflow engines

The right comparison is about responsibility, not a feature-count contest. BullMQ, Google Cloud Tasks, and a plain batch-publish API can all sit behind the application boundary when the job is "enqueue independent deliveries." Temporal and Airflow belong in the decision when the work is actually a workflow. The current requirement has no join and no DAG, so adding an orchestration model would not remove the need for customer-level idempotency.

Option Sensible fit for this weekly digest Boundary that changes the decision
BullMQ A Node.js service already organized around its BullMQ queue and workers Keep it when the existing queue is the operational standard; this article does not establish a migration benefit
Google Cloud Tasks A team evaluating a managed task service for scheduled delivery work Validate its retry and task-identity behavior against the same storage invariants
Unified REST scheduling and queue API A team that wants cron and queue capabilities through one plain REST surface Public self-describing discovery provides the request schema and runnable examples without a new SDK; shared credentials cover both capabilities, but it is not a workflow engine
Temporal Delivery is one step in a durable multi-step workflow Prefer it when workflow orchestration or fan-out/join semantics are the real requirement
Airflow The digest belongs to a broader DAG-oriented data workflow Prefer it when scheduling a DAG, rather than dispatching independent customer webhooks, is the job

For the REST option, discovery is material engineering ergonomics rather than decoration. The adapter can read the capability detail, including the full request JSON Schema and runnable language examples, before calling the verified POST /v1/queue/publish_batch route. That keeps the queue boundary plain HTTP and avoids guessing fields. It also makes the application-facing publisher interface stable if the underlying provider changes.

Infrai exposes 295 routes across 20 modules under one key, with one bill; in this design, that means cron and queue share a credential rather than adding separate rotation and usage-reconciliation paths. The weekly-digest service still keeps a provider adapter, so that convenience does not outweigh an existing queue that the team already operates well, but it reduces integration and governance work for a new service.

Price is not the deciding axis here. Retry ownership, replay expectations, public endpoint constraints, and the team's existing operational model will dominate the outcome.

Wire the batch boundary in Python

The production service in the query is Node.js, but the scheduling pattern is language-neutral; the following Python program keeps the provider boundary compact enough to inspect in one place. It is runnable with the standard library after INFRAI_BASE_URL, INFRAI_API_KEY, and INFRAI_PUBLISH_BATCH_BODY are set. The body must be copied from the live discovery schema and runnable example rather than reconstructed from prose. That rule prevents a field-name guess from becoming production code.

import json
import os
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from hashlib import sha256


def retry_delay(header: str | None, attempt: int) -> float:
    if header is None:
        return float(2**attempt)
    try:
        return max(0.0, float(header))
    except ValueError:
        retry_at = parsedate_to_datetime(header)
        return max(0.0, (retry_at - datetime.now(timezone.utc)).total_seconds())


def publish_batch(payload: dict) -> dict:
    base_url = os.environ["INFRAI_BASE_URL"].rstrip("/")
    api_key = os.environ["INFRAI_API_KEY"]
    body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
    idempotency_key = sha256(body).hexdigest()

    for attempt in range(5):
        request = urllib.request.Request(
            f"{base_url}/v1/queue/publish_batch",
            data=body,
            method="POST",
            headers={
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/json",
                "Idempotency-Key": idempotency_key,
            },
        )
        try:
            with urllib.request.urlopen(request, timeout=30) as response:
                return json.load(response)
        except urllib.error.HTTPError as error:
            response_body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == 4:
                raise RuntimeError(
                    f"queue publish rejected with HTTP {error.code}: {response_body}"
                ) from error
            time.sleep(retry_delay(error.headers.get("Retry-After"), attempt))

    raise AssertionError("retry loop exhausted")


def main() -> None:
    payload = json.loads(os.environ["INFRAI_PUBLISH_BATCH_BODY"])
    result = publish_batch(payload)
    print(json.dumps(result, indent=2, sort_keys=True))


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

The adapter makes the HTTP method explicit, reads the bearer key from the environment, derives an idempotency key from the canonical batch body, checks status, surfaces rejected-response details, and backs off on HTTP 429 while honoring Retry-After. The scan endpoint should construct that discovery-validated payload from rows such as player-1042 plus 2026-W34; the worker should keep the same logical identity when it claims delivery.

A real network delivery introduces an ambiguous interval: the receiver may accept a request while the sender loses the response. Resolve that at the receiver with the same stable event ID, or use a transactional boundary appropriate to the actual webhook contract. A transactional outbox can ensure that a database state change and the intent to publish are recorded together; it does not, by itself, prove that a remote receiver processed the webhook exactly once.

These transport controls belong at the adapter boundary, while the customer-and-week identity remains a domain rule. The worker still needs a durable uniqueness constraint or equivalent atomic claim before sending; a successful batch publication is not proof of eventual customer delivery.

Rejected option: delivery inside the cron callback

The rejected design is a cron target that scans every pending customer and delivers all webhooks before returning. Its failure mode is structural: delivery duration grows with the audience, yet one cron run cannot exceed 900 seconds. A timeout or retry also re-enters a partly completed loop, where the distinction between "sent" and "safe to send again" becomes difficult unless the application has already implemented the same durable per-customer state required by workers.

Direct cron-to-handler execution still has a valid, narrow use case. Keep it for a bounded callback whose only responsibility is to reconcile and enqueue, or for a genuinely short idempotent administrative action with a known upper bound. It is not suitable when one trigger fans out to an unbounded active-customer population, when work needs a DAG or join, or when replay and multiple consumer groups are requirements. Stick with Temporal or Airflow for orchestration; keep an existing BullMQ deployment when it already satisfies the queue boundary and the team can operate it confidently.

Rollout checks for missed schedules and duplicate delivery

The operational acceptance test is simple: pause the schedule for one weekly period, create pending digest records, resume it, and invoke the reconciliation endpoint. Every pending record should eventually be enqueued even though cron does not replay the missed trigger. Then redeliver a consumed message and verify that the customer receives no duplicate digest. Finally, push the scan past one batch and confirm that the endpoint stays bounded while later scans continue from durable state.

That is the decision rule: cron discovers work, storage remembers work, the queue transports work, and workers deliver it idempotently.

References

Top comments (0)