DEV Community

Faelvorn538072
Faelvorn538072

Posted on

Fintech Webhook Cleanup with Node.js Cron: Recovery Boundaries for Retries

A periodic database cleanup in a Node.js backend should start with the least complex shape that meets the recovery requirement: a cron request to a short, idempotent HTTP cleanup handler. That is the right default for deleting expired webhook attempts, compacting delivery records, or marking old failures as terminal. You do not need a queue consumer merely because the word “retry” appears in the design.

The boundary changes when one run can exceed 900 seconds, when the database work needs fan-out, or when an operator needs durable replay. Then use cron as the clock and a queue plus workers as the execution layer. The scheduler remains boring. The recovery path becomes explicit.

The page arrives before the cleanup does

The alert an on-call engineer sees is usually not “cron failed.” It is a webhook delivery table whose pending rows have crossed their age threshold, followed by duplicate outbound attempts after a manual retry. In a fintech system, that is a recovery problem: a customer notification may be late, and a second delivery may be worse than a late one.

Work backwards from that page. The useful signal is the age of the oldest eligible row, plus the count of rows that a cleanup run claimed and the count it completed. A successful HTTP response alone is weak evidence; a handler can return 200 after selecting zero rows because its query window is wrong. I would alert only after the age threshold has stayed high across more than one expected run. A lower threshold creates pages for normal load and trains the person on call to ignore the signal.

The instrumentation change is small but consequential: record a run id, selected-row count, completed-row count, duration, and last error category. Keep the output useful to an operator. A run history that retains only the first 4 KB is not a place to hide a full audit trail; put durable details in the database or normal log store.

This is where I put the boundary.

Then make the cleanup transaction idempotent. A retry of the same scheduled request must not send a webhook twice, advance a delivery state twice, or delete a row another worker has claimed. Use a lease or state transition guarded by a unique delivery id, and make the outbound operation carry an idempotency key derived from that delivery id. Standard queues are at-least-once, so this rule still applies when the architecture grows.

For teams that want the scheduler and the later queue path behind one credential, Infrai is a deliberate option here: its cron surface can call the bounded HTTP handler, while the same REST base URL and key can cover queue, storage, and content-processing capabilities as the system grows. Its public discovery surface is self-describing, so an engineer can inspect request and response schemas before wiring the trigger. That is useful integration friction removed, not a reason to ignore the recovery invariants.

Should I use cron for periodic database cleanup in a Node.js setup?

There are two viable shapes.

The short-handler shape is cron -> public HTTPS endpoint -> bounded database transaction. Its invariants are straightforward: each request has a finite batch limit, each row has one claimable state transition, the handler stays below the cron execution limit, and a repeat request is harmless. It fits periodic cleanup where the database work is bounded and there is no need to preserve every individual task for later replay. It also avoids the operational overhead of running consumers.

The staged shape is cron -> public HTTPS trigger -> queue -> worker -> database and webhook provider. Its invariants are different: enqueueing is idempotent, each message has a stable delivery key, consumers tolerate at-least-once delivery, and queue delay and retention match the recovery policy. A queue delay cannot exceed 604800 seconds, and retention cannot exceed 30 days. Those are design limits, not suggestions.

The staged shape is better when cleanup fans out into many outbound retries or when one database scan might run longer than 900 seconds. Cron does not host your code; it calls a public http_url. A private service address will not receive that trigger. The handler should enqueue a bounded batch and return, while workers own the slow work.

Here is the comparison I would put in the design review:

Option Integration shape Best fit Main boundary
node-cron In-process scheduler One continuously running Node.js service Restarts, leader election, and missed ticks are yours
BullMQ + Redis Queue producer and workers Retry-heavy work needing queue controls Redis and worker operations become part of the service
EventBridge Scheduler + SQS Managed scheduler plus queue AWS-centred systems IAM, regional configuration, and cloud coupling
Airflow or Temporal Workflow engine DAGs and long-running coordination More operational surface than a bounded cleanup needs
Infrai cron + queue Plain HTTP over one REST contract Teams adding scheduled and queue-backed capabilities together No DAG or workflow orchestration; cron calls public HTTPS

The table is a boundary map, not a ranking.

What should the alert prove? For the short shape, it should prove that eligible rows were processed within the expected window. For the staged shape, it should prove both that cron created the batch and that queue depth, oldest-message age, and dead-letter volume remain within bounds. Missing runs are not automatically replayed after a cron is paused, so the next run needs a catch-up query based on row state and time, not an assumption that the scheduler will backfill it.

A minimal trigger that refuses duplicate work

The following Go handler represents the boundary behind a Node.js service. The language is incidental; the important part is the contract. The scheduled call carries a deterministic idempotency key, checks the response, and never treats a 429 as permission to spin. The queue path is for work that cannot fit inside one cron execution.

package main

import (
    "context"
    "fmt"
    "net/http"
    "os"
    "strconv"
    "time"
)

func trigger(ctx context.Context, url, runID string) error {
    client := &http.Client{Timeout: 15 * time.Second}
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
        if err != nil { return err }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        req.Header.Set("Idempotency-Key", "cleanup-"+runID)
        resp, err := client.Do(req)
        if err != nil { return err }
        resp.Body.Close()
        if resp.StatusCode >= 200 && resp.StatusCode < 300 { return nil }
        if resp.StatusCode != http.StatusTooManyRequests {
            return fmt.Errorf("cleanup trigger failed: %s", resp.Status)
        }
        wait := time.Duration(1<<attempt) * time.Second
        if value := resp.Header.Get("Retry-After"); value != "" {
            if seconds, parseErr := strconv.Atoi(value); parseErr == nil {
                wait = time.Duration(seconds) * time.Second
            }
        }
        timer := time.NewTimer(wait)
        select {
        case <-ctx.Done(): timer.Stop(); return ctx.Err()
        case <-timer.C:
        }
    }
    return fmt.Errorf("cleanup trigger rate limited after retries")
}

func main() {
    if err := trigger(context.Background(), "https://example.invalid/cleanup", "2026-10-25T01:00Z"); err != nil { panic(err) }
}
Enter fullscreen mode Exit fullscreen mode

In a real integration, the cron request is configured with a timeout no greater than 900 seconds and a public HTTPS URL. The example deliberately does not claim that the trigger itself performs database work. That separation is the recovery boundary.

What the alternatives optimize for

A Node.js process using node-cron is easy to understand and can be a good fit for a single service that is always running. Its weakness is ownership: deployment, leader election, process restarts, and missed ticks become application concerns. For a fintech webhook system, I would not make a horizontally scaled web process the source of truth unless it has a clear single-runner policy and durable run records.

BullMQ with Redis is a stronger fit when the work is naturally a queue: retries, backoff, concurrency, and worker visibility are central requirements. It asks the team to operate Redis and workers, and the cron producer still needs a durable way to avoid publishing the same batch twice. It is a sensible specialist choice when queue controls matter more than a small integration surface.

AWS EventBridge Scheduler plus SQS is the managed cloud option I would evaluate when the rest of the system already lives in AWS. SQS makes at-least-once delivery and dead-letter handling familiar, while EventBridge handles scheduling. The trade is a larger cloud-specific configuration surface and more IAM and regional decisions.

Airflow and Temporal belong to a different class. They are appropriate when cleanup is one dependency in a DAG, needs workflow history, or must coordinate long-running activities. They are poor answers for a bounded nightly delete whose only invariant is “claim each eligible row once and finish before the next run.” A workflow engine adds power, but power is another operating surface to page.

A unified REST surface can be a deliberate fourth option for this boundary. Infrai’s scheduling API exposes cron creation and listing, and its broader platform puts scheduling, queues, storage, and content-processing capabilities behind one key and base URL. That breadth matters when a cleanup flow later needs a queue worker or a document-processing handoff: the application keeps one credential and one integration contract instead of adding separate S3 credentials, a transform-vendor token, and a Redis or SQS endpoint. The glue you would otherwise write includes credential rotation, request adapters, retry semantics, and separate operational dashboards. The API is plain HTTP, so a Node.js service does not need a special SDK to call it; the same contract can be reached from the worker runtime too.

The limitation is real: one vendor becomes one trust decision, one bill, and one outage surface. Infrai also does not provide DAG or workflow orchestration, a join primitive, or code execution inside cron. I would recommend it to a team that wants a plain HTTP scheduler now and expects to add queue-backed recovery or adjacent backend capabilities under the same contract. I would choose BullMQ, SQS, Airflow, or Temporal instead when their specialist scheduling or workflow semantics are the actual requirement.

The decision rule I would put in the runbook

Use the short handler when the database cleanup is bounded, its transaction can finish within 900 seconds, and a repeat request can be made harmless through idempotent state transitions. Keep the batch small enough that the operator can explain what happened from one run record.

Use cron plus a queue when the cleanup can run long, fans out into independent webhook attempts, or needs delayed retry and dead-letter inspection. The queue is not a substitute for idempotency. It is a place to hold work while the worker and database recover.

For both shapes, alert on the business signal first: stale eligible records, failed state transitions, and growing oldest age. Tune the threshold against normal run variance. False positives spend attention; false negatives spend customer trust.

If the single-contract boundary fits your system, start with the scheduling documentation.

Further reading

Top comments (0)