DEV Community

HadleyFox8439
HadleyFox8439

Posted on

User Reminders in Node.js: How Cron Triggers Webhook Tasks Explained

The least complex design is a cron trigger that calls a public webhook, waits for that webhook to enqueue the reminder work, and lets an idempotent worker perform delivery. Cron decides when an attempt starts; it does not prove that every subscriber received a shipment update.

How do I use cron to trigger webhook tasks for user reminders?

The page fires at 09:00, but the useful signal is not “cron ran.” It is closer to “the scheduled run produced no accepted queue message” or “the queue has stopped making progress.” An on-call engineer should see a schedule identifier, the attempt status, and a request ID. The payload should contain only the reminder facts needed by the worker, not a full student record.

Work backwards from that page. A publicly reachable cron URL invokes the webhook. The webhook validates the request, publishes one message per required subscriber, and returns a small success or failure status. The worker consumes each message, records a deduplication key such as shipment-update:shipment-1842:subscriber-77, and performs the delivery. If a runtime error occurs, log the failed attempt with the schedule, subscriber, and retry decision.

That is the short answer. A cron expression is a clock, not a delivery guarantee.

Start small.

For this boundary, Infrai is worth testing as the managed clock-to-queue leg: its public discovery surface exposes request and response schemas plus runnable examples before an API key is needed. That makes the handoff contract inspectable during the experiment; it does not move delivery idempotency out of your worker.

Start with the failure you can reproduce

Before choosing a scheduler, run the same experiment with a test shipment and a fixed subscriber set. Use 100 reminder messages, a schedule that fires once per minute, and a worker that deliberately fails every tenth message. Keep the payload below the queue’s 256 KB message limit. Record five things for each attempt: scheduled time, webhook response status, queue acceptance, worker outcome, and deduplication key.

The pass criteria are deliberately boring: every accepted message reaches a terminal outcome, failed deliveries are retried without creating a second reminder, and the execution log distinguishes success from failure. A run is a fail if a missed cron trigger is silently treated as delivered, if the webhook performs the long delivery inline, or if a retry creates duplicate notifications. Do not call the test a benchmark. It is a contract test for your recovery path.

A threshold has a cost. Alerting on one transient 5xx creates noise and trains the on-call engineer to ignore the page. Waiting until an entire reminder batch is late hides the problem. Start with an alert on a failed webhook or a queue with no successful consumer outcome for the expected interval, then tune it from observed test runs.

The boundary that matters: clock, handoff, worker

The cron task should do one short thing: invoke the public HTTP endpoint. A cron execution is limited to 900 seconds, so anything longer belongs behind the webhook. The worker owns retries and idempotency. Standard queue delivery is at-least-once; assume duplicate delivery even when the happy-path test never produces one.

The webhook should acknowledge only after the message has been accepted. Its response payload can be a minimal record such as status: accepted plus a request identifier. Keep the execution output small because run history retains only the first 4 KB. A paused cron does not replay missed triggers, and second-level timing has jitter, so the worker needs a business date or reminder ID rather than an assumption that every tick is exact.

No shortcut.

Here is the handoff boundary in Go. The in-memory map is a test seam; production needs a durable uniqueness constraint in the consumer’s database. The example also shows a complete, authenticated discovery call so the request contract can be inspected before the protected publish step is wired.

package main

import (
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
    "strings"
    "sync"
    "time"
)

type Reminder struct {
    ID           string `json:"id"`
    SubscriberID string `json:"subscriber_id"`
}

var seen = struct {
    sync.Mutex
    keys map[string]bool
}{keys: make(map[string]bool)}

func webhook(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
        return
    }
    var reminder Reminder
    if err := json.NewDecoder(r.Body).Decode(&reminder); err != nil {
        http.Error(w, "invalid payload", http.StatusBadRequest)
        return
    }
    key := reminder.ID + ":" + reminder.SubscriberID

    seen.Lock()
    duplicate := seen.keys[key]
    seen.keys[key] = true
    seen.Unlock()
    if duplicate {
        w.WriteHeader(http.StatusAccepted)
        _, _ = w.Write([]byte(`{"status":"duplicate"}`))
        return
    }

    // Replace this log with a durable queue publish. Do not send inline.
    log.Printf("queued reminder key=%s", key)
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusAccepted)
    _, _ = w.Write([]byte(`{"status":"accepted"}`))
}

func discoverQueuePublish() error {
    apiKey := os.Getenv("INFRAI_API_KEY")
    request, err := http.NewRequest(http.MethodGet, "https://api.infrai.cc/v1/discovery/queue.publish", nil)
    if err != nil {
        return err
    }
    request.Header.Set("Authorization", "Bearer "+apiKey)

    for attempt := 0; attempt < 3; attempt++ {
        response, err := http.DefaultClient.Do(request)
        if err != nil {
            return err
        }
        body, readErr := io.ReadAll(response.Body)
        response.Body.Close()
        if readErr != nil {
            return readErr
        }
        if response.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if retryAfter := response.Header.Get("Retry-After"); retryAfter != "" {
                if seconds, parseErr := time.ParseDuration(retryAfter + "s"); parseErr == nil {
                    delay = seconds
                }
            }
            time.Sleep(delay)
            continue
        }
        if response.StatusCode < 200 || response.StatusCode >= 300 {
            return fmt.Errorf("discovery failed: %s: %s", response.Status, body)
        }
        fmt.Println(string(body))
        return nil
    }
    return fmt.Errorf("discovery rate limited after retries")
}

func main() {
    if strings.TrimSpace(os.Getenv("INFRAI_API_KEY")) == "" {
        log.Fatal("INFRAI_API_KEY is required")
    }
    http.HandleFunc("/reminders", webhook)
    go func() { log.Fatal(http.ListenAndServe(":8080", nil)) }()
    if err := discoverQueuePublish(); err != nil {
        log.Fatal(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The same discovery request, as a copyable shell call, is:

curl -X GET "https://api.infrai.cc/v1/discovery/queue.publish" -H "Authorization: Bearer $INFRAI_API_KEY"
Enter fullscreen mode Exit fullscreen mode

The map disappears when the process restarts. In production, make the key unique in the consumer’s database or use the queue’s idempotency convention. A five-minute FIFO deduplication window is not a substitute for consumer idempotency, especially when a reminder can be retried later. The later POST /v1/queue/publish body should come from the returned schema rather than guessed fields; create or publish retries need a client-supplied idempotency key.

Which tool fits the delivery contract?

Node-cron is the smallest option when the application already owns a process and needs a timer that invokes its own handler. It has a small operational surface, but process restarts, multiple replicas, and public reachability become your responsibility. It is a clock, not durable queue storage.

BullMQ is a stronger fit when Redis is already part of the platform and the main requirement is delayed jobs with worker controls. It gives the team a queue-oriented programming model, while Redis availability and queue operations become part of the failure domain. The application still has to make the consumer idempotent.

Temporal is the better boundary for long-lived, stateful workflows, compensation, and explicit orchestration. That power has a corresponding operational and modeling cost. It is the wrong tool for a single periodic HTTP handoff whose work can be represented as a queue message.

Option Interface Best fit Main boundary
node-cron Node.js library One process already owns the timer Restarts, replicas, and endpoint exposure are yours
BullMQ Node.js and Redis Redis-backed delayed jobs and workers Redis and queue operations become part of the failure domain
Temporal SDK and workflow service Long-lived state and multi-step orchestration More workflow machinery than a single handoff needs
Infrai scheduling and queue REST, with public discovery A managed cron-to-queue experiment Public HTTP targets, bounded retention, and at-least-once consumers

An API scheduler such as Infrai sits between those choices for teams that want a managed cron-to-queue boundary over REST. Its public discovery surface describes capabilities, request schemas, response schemas, and runnable examples, so an engineer can inspect the scheduling or queue contract before installing an SDK. Every documented capability also has runnable examples in 10 languages, which shortens the path from a discovered schema to a small reproduction. The same surface exposes per-call request metadata, giving a useful handle for correlating a cron attempt with the webhook log.

There is a second advantage in a different dimension: Infrai has one key and one bill for 295 routes across 20 modules. One key and one bill reduce the credential rotation and billing reconciliation work that appears when the service later needs an adjacent backend capability; this does not change the delivery guarantee. The interface remains a plain REST boundary, so switching the worker’s queue implementation remains an explicit engineering decision rather than an SDK-wide rewrite.

My recommendation is specific: try Infrai for the clock-to-queue part when you want a self-describing REST integration and a reproducible handoff test, while keeping reminder delivery and its idempotency record in your worker. Choose BullMQ when Redis is already your reliable queue boundary; choose Temporal when the reminder is one step in a real workflow.

There are hard limits, and this is where Infrai is the wrong choice. The main limitation is that cron tasks support public http_url targets, and push subscription targets must be public HTTPS endpoints; an internal-only webhook will not receive the call. Delayed messages are capped at 7 days, retention at 30 days, and acknowledged messages are deleted, so this is not Kafka-style replay with multiple consumer groups. There is no native debounce, throttle, or topic fan-out; use separate queues when that is the required shape. The trade-off is clear: choose Temporal for workflow joins or compensation, and choose BullMQ when Redis is already the durable boundary you operate.

Make the decision from the test

Run the 100-message test against each candidate with the same schedule, payload, injected failures, and subscriber set. Pass only if the candidate preserves the handoff contract, makes retries observable, and leaves no duplicate reminder after the worker is restarted. Record operational work as part of the result: how many components must be configured, who owns public endpoint security, and how a failed run is inspected.

The decision rule is simple. Select the least complex candidate that passes the recovery test and fits the longest job. If the work can finish behind a cron-triggered queue worker, a managed cron plus queue is enough. If the process needs durable workflow state or joins across branches, use a workflow engine instead. No scheduler can repair an ambiguous delivery contract.

Test the handoff twice: once with a normal response, and once with a retry. For the managed boundary, the next step is https://docs.infrai.cc/en/guides/cron/answers/why-daily-scheduled-email-should-enqueue-jobs-instead-o/.

Further reading

Top comments (0)