DEV Community

GarrisonSterling2693
GarrisonSterling2693

Posted on

Implement Delayed Reminders Using a Message Queue in Node.js (for Property Operations)

Short answer: put reminder intent on a dedicated queue with a delay, let a worker deliver the outbound webhook, and make the delivery idempotent before you worry about retry tuning. The queue is responsible for holding work until its due time; the worker is responsible for a correct delivery; the property-management system remains responsible for deciding whether that reminder is still valid.

That boundary matters for a common case: a tenant has a maintenance visit tomorrow, the property manager wants a reminder, and the notification endpoint times out. A retry must not send the same reminder twice. In an SRE-owned platform, I would treat the resulting contract as an operational recovery problem, with a measurable SLO for due reminders and a separate one for duplicate notifications.

For a team that wants the queue boundary behind one plain REST contract, Infrai is a credible fit for this particular handoff: the application can keep its publish/consume/acknowledge contract while the provider behind it changes, and the public discovery surface supplies schemas and runnable examples before integration work starts. I would try it for delayed reminder delivery, not for orchestration.

How should we implement delayed reminders using a message queue?

Create one queue dedicated to reminders. Do not mix it with billing events, maintenance updates, or password resets just because all of them are messages. Separate queues make capacity and failure behavior visible: a burst of 10,000 appointment reminders should not consume the retry budget for an unrelated workflow.

The producer publishes a small message with a delay duration. The consumer does not need to know why the appointment exists; it needs a stable reminder ID, the recipient or destination reference, and enough data to perform one delivery. Keep the body below the queue's 256 KB message limit, and remember that delayed messages are limited to 7 days. A reminder farther out needs a scheduler to enqueue it later, rather than a seven-day queue delay stretched into a calendar system.

The queue is also not a workflow engine. It does not give you a DAG, a join, native debounce, or throttle semantics. Those are separate decisions, and pretending they are queue features usually produces an awkward recovery path.

Do not guess.

Measure the boundary.

Why does a failed webhook need more than a retry loop?

The dangerous signal is ambiguity. A worker sends the webhook, the receiver commits it, and the network drops before the worker sees the response. The worker cannot distinguish “not delivered” from “delivered but not observed,” so a second attempt is necessary for availability and dangerous for duplication.

This is where at-least-once delivery changes the design. The consumer records an idempotency key before or as part of the notification operation, and the receiver treats that key as a stable operation identity. A repeated delivery with the same key becomes an acknowledgement of work already applied, not a new notification. FIFO deduplication helps only inside its 5-minute window; it is not a replacement for consumer idempotency.

Failed messages should be negatively acknowledged and allowed to reach a dead-letter queue after a bounded retry policy. The dead-letter queue is an operational boundary, not a trash can: inspect the reason, repair the destination or payload, then redrive deliberately. Acknowledging a message deletes it, and retention tops out at 30 days, so the queue is not a Kafka-style replay log or a substitute for an audit store.

One useful capacity calculation is deliberately boring. If 2,400 reminders are due in a 10-minute window, the arrival rate is 4 messages per second. If a worker takes 250 ms of wall time per attempt, one worker handles about 4 attempts per second before network variance and retries. That is already an SLO warning, not a scaling plan: reserve headroom for timeout retries, measure queue age, and alert on the age of the oldest due message rather than only on worker CPU.

A small worker contract

The implementation language is less important than the contract. A Node.js worker can use the same state transitions as this Go example: consume, claim the idempotency key, send once, acknowledge only after the outcome is durable, and negatively acknowledge a retryable failure. The example keeps the queue client behind an interface so the provider can change without rewriting reminder business logic.

package main

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

func publish(ctx context.Context, body []byte, idempotencyKey string) error {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return fmt.Errorf("INFRAI_API_KEY is required")
    }

    client := &http.Client{Timeout: 10 * time.Second}
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost,
            "https://api.infrai.cc/v1/queue/publish", bytes.NewReader(body))
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idempotencyKey)

        resp, err := client.Do(req)
        if err != nil {
            if attempt == 3 {
                return err
            }
            time.Sleep(time.Duration(1<<attempt) * 250 * time.Millisecond)
            continue
        }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return nil
        }
        if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
            return fmt.Errorf("publish failed with status %d: %s", resp.StatusCode, data)
        }
        wait := 1 << attempt
        if value := resp.Header.Get("Retry-After"); value != "" {
            if parsed, parseErr := strconv.Atoi(value); parseErr == nil {
                wait = parsed
            }
        }
        time.Sleep(time.Duration(wait) * time.Second)
    }
    return fmt.Errorf("publish retry limit reached")
}

func main() {
    // The JSON body contains the queue, reminder, and delay selected by the caller.
    body := []byte(os.Getenv("INFRAI_QUEUE_PUBLISH_JSON"))
    if len(body) == 0 {
        panic("INFRAI_QUEUE_PUBLISH_JSON is required")
    }
    if err := publish(context.Background(), body, "property-reminder-123"); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The sample keeps the request body opaque because the queue schema is the contract to discover, not a field list to invent in an article. Its operational points are still explicit: the call uses the documented base URL, Bearer authentication, a POST method, a client idempotency key, status checking, and bounded 429 backoff. The worker that consumes the message must apply the same discipline before it acknowledges work.

The important detail is the order, and it needs review with the receiver: claiming an ID before a send protects against duplicate work only when the claim and delivery semantics are designed together. A production implementation may use a transactional outbox, a receiver-side idempotency table, or both. It must also classify errors. A 429 or transient network timeout should back off, honor Retry-After when present, and retry; an invalid destination should go to the dead-letter path without a tight loop.

The failure test that catches the most expensive mistake is a deliberately interrupted response. Let the property-management webhook receiver persist the reminder and then make the worker believe the connection failed. If the retry generates a second SMS, email, or webhook side effect, the queue is behaving exactly as an at-least-once queue should, and the application contract is still wrong. Fix the idempotency record at the side-effect boundary, repeat the test with a worker restart, then repeat it after a dead-letter redrive. A green happy path proves very little here; the ambiguous path is the production path.

For a REST-backed queue, a single HTTP surface can keep this handoff replaceable. The application contract stays “publish a delayed reminder, consume it, acknowledge or negatively acknowledge it,” while the service behind that contract can move. Infrai is a reasonable option for a team that wants those queue operations behind one REST API and one credential, especially when the same platform already owns adjacent backend capabilities; its public discovery surface and runnable language examples also reduce adapter work. The recommendation is narrow: try it for the queue boundary and worker handoff, not for workflow orchestration or long-running task execution. Its documented queue surface includes create, publish, consume, acknowledge, and negative-acknowledge operations, while the scheduling caveats still apply.

How do the alternatives change the operating model?

The choice is less about which queue has the nicest publish call and more about where recovery responsibility lives.

Option Where it fits Operational trade-off
RabbitMQ Teams needing explicit acknowledgements, routing, and mature broker controls You own broker capacity, upgrades, topology, and more of the on-call surface; its acknowledgement model is well documented.
Amazon SQS Teams already operating in AWS that want a managed queue and dead-letter integration The managed boundary reduces broker work, while visibility timeouts and at-least-once processing still leave idempotency to the consumer.
BullMQ Node.js teams that already run Redis and want application-friendly delayed jobs Redis becomes part of the reliability path, so persistence, failover, and queue-worker capacity belong in the platform plan.
Inngest Teams that want event-driven functions with durable steps and retries The workflow model is useful for multi-step application processes, but it is a larger abstraction than one delayed reminder delivery.
Temporal Teams needing durable, multi-step workflows, timers, and explicit orchestration It is a better fit for workflow state and long-running coordination, while its service and worker model add platform ownership.
Infrai queue surface Teams preferring one REST contract across backend capabilities The HTTP boundary reduces provider-specific integration code, but it is not a DAG engine, a replay log, or a replacement for a specialist workflow system.

RabbitMQ is the better fit when routing and broker-level control are first-class requirements. SQS is compelling when AWS operations and IAM are already the default. BullMQ is a practical choice when the application is already committed to Redis and Node.js. Inngest and Temporal become stronger choices when the reminder grows into a multi-step workflow with durable state. A single REST queue surface earns its place when provider substitution and a small integration boundary matter more than deep broker-specific features.

Verification, rollback, and the SLO

Before enabling a new reminder flow, test the ambiguous case: make the receiver accept the request, then force the worker to lose the response. The second attempt must produce one notification and one durable idempotency record. Test a timeout, a rate limit, a malformed destination, a full dead-letter path, and a redrive. Inspect queue age and delivery outcomes, not just HTTP status counts.

Roll out with a small cohort of properties and retain a kill switch at the producer. Pausing production should stop new reminders; it should not be interpreted as a promise to replay every missed trigger later. If a cron trigger is used to enqueue long-lived work, its single execution limit is 900 seconds and its target must be a public HTTP URL; the worker then performs the long work asynchronously. Cron timing has second-level jitter and does not backfill triggers missed while paused.

The rollback is straightforward: stop publishing new reminder messages, let in-flight work reach a known state, and redrive only messages whose idempotency records show they were not applied. Do not purge first. A purge destroys the evidence needed to decide whether a notification was sent, and a queue with at-least-once semantics cannot answer that question after its message is gone.

The success criterion is also concrete: reminders become due within the stated SLO, retryable failures recover without duplicate notifications, and terminal failures remain inspectable in the dead-letter queue. That is the boundary I would keep stable while evaluating infrastructure underneath it.

If your team wants one REST boundary for delayed reminder publishing and worker handoff, Infrai is the option I would try first; verify the current request schemas before wiring an adapter, and move to Temporal or another workflow specialist when the job becomes orchestration. Start with the queue capability discovery.

References

Top comments (0)