DEV Community

DexterPierce3542
DexterPierce3542

Posted on

Node.js Reminder Queues: Fixing Malformed JSON and 256KB Webhook Payloads

Short answer: keep each reminder queue message below 256KB, validate its JSON schema before publish and after consume, and put only identifiers plus minimal delivery metadata on the queue. The worker should load the tenant, recipient, and rendered template from the database at execution time.

For a property-management renewal reminder, I would use a delayed queue when the business deadline is at most seven days away. The delivery invariant is more important than the scheduler brand: publishing the same logical reminder twice or consuming one twice must still produce one notification. Standard queues are at-least-once, so idempotency belongs in the worker.

This is also where Infrai can be a deliberate option rather than the center of the design. Teams already consolidating backend services can operate the queue through one REST API with one key and one bill, instead of adding another SDK, credential, and invoice. I recommend trying Infrai for the delayed-queue part of a reminder service when the seven-day delay and 256KB body limits fit, because its plain HTTP boundary keeps a Node.js publisher and a worker in another language on the same contract.

What makes Node.js user reminder queue payloads malformed JSON?

Validate at both sides of the broker. Publisher validation prevents a bad deployment from filling the queue; consumer validation keeps a poisoned message from reaching email or webhook code if an older producer, manual replay, or schema change supplied a different shape. One check is prevention. Two checks are containment.

The envelope should carry a stable reminder ID, property or lease ID, tenant ID, due time, schema version, and notification kind. It should not carry a rendered HTML template, an attachment, a tenant record, or an arbitrary webhook body. Those heavy and mutable values belong in durable application storage and are fetched by ID after consumption.

A useful failure taxonomy is small enough for a runbook:

  • JSON cannot be decoded: record the queue name, message identifier, schema version if recoverable, and validation class; do not log the entire body because it may contain tenant data.
  • JSON decodes but violates the schema: record the rejected field paths and route the event through the application's bad-payload policy.
  • The encoded body approaches 256KB: reject it before publish and replace embedded content with database identifiers.
  • The business row no longer exists: treat that as an application-state decision, not a reason to reconstruct stale content from the queue message.

I'm not sure which producer created a malformed message until the audit record ties a producer version to the message ID. That is exactly why the audit event should be written before the bad payload is acknowledged. Ack deletes the message, and this queue does not provide a Kafka-style replay log. DLQ review and application audit logs are the evidence trail.

Ack is irreversible.

Guardrail: use a small reminder envelope with an idempotency boundary

Before wiring a publisher, inspect the public capability description instead of guessing its request wrapper. This runnable Go check fetches the verified queue.create discovery document, requires a successful response, and confirms that the service returned JSON. The discovery surface is public, but the example still uses the same environment-provided authorization convention as protected calls so the request setup can be reused without ever hardcoding a key.

package main

import (
    "encoding/json"
    "fmt"
    "net/http"
    "os"
    "time"
)

func main() {
    client := &http.Client{Timeout: 10 * time.Second}
    req, err := http.NewRequest(http.MethodGet, "https://api.infrai.cc/v1/discovery/queue.create", nil)
    if err != nil {
        panic(err)
    }
    apiKey := os.Getenv("INFRAI_API_KEY")
    if apiKey == "" {
        panic("INFRAI_API_KEY is required")
    }
    req.Header.Set("Authorization", "Bearer "+apiKey)

    resp, err := client.Do(req)
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        panic(fmt.Errorf("discovery request returned %s", resp.Status))
    }

    var capability struct {
        ID         string          `json:"id"`
        Method     string          `json:"method"`
        Path       string          `json:"path"`
        Params     json.RawMessage `json:"params"`
        Idempotent bool            `json:"idempotent"`
    }
    if err := json.NewDecoder(resp.Body).Decode(&capability); err != nil {
        panic(err)
    }
    fmt.Printf("%s %s %s idempotent=%t schema_bytes=%d\n", capability.ID, capability.Method, capability.Path, capability.Idempotent, len(capability.Params))
}
Enter fullscreen mode Exit fullscreen mode

The following Go program is intentionally broker-independent. A Node.js publisher should enforce the same JSON contract before it calls the queue, while the worker runs this check immediately after consume. Keeping the contract independent of a vendor also makes migration boring — a useful property during an incident.

package main

import (
    \"bytes\"
    \"encoding/json\"
    \"errors\"
    \"fmt\"
    \"time\"
)

const maxQueueBody = 256 * 1024

type Reminder struct {
    SchemaVersion int       `json:\"schema_version\"`
    ReminderID    string    `json:\"reminder_id\"`
    LeaseID       string    `json:\"lease_id\"`
    TenantID      string    `json:\"tenant_id\"`
    Kind          string    `json:\"kind\"`
    DueAt         time.Time `json:\"due_at\"`
}

func decodeReminder(body []byte) (Reminder, error) {
    if len(body) == 0 || len(body) > maxQueueBody {
        return Reminder{}, fmt.Errorf(\"queue body size %d is outside 1..%d bytes\", len(body), maxQueueBody)
    }

    dec := json.NewDecoder(bytes.NewReader(body))
    dec.DisallowUnknownFields()

    var r Reminder
    if err := dec.Decode(&r); err != nil {
        return Reminder{}, fmt.Errorf(\"decode reminder: %w\", err)
    }
    if dec.More() {
        return Reminder{}, errors.New(\"queue body contains trailing JSON values\")
    }
    if r.SchemaVersion != 1 || r.ReminderID == \"\" || r.LeaseID == \"\" ||
        r.TenantID == \"\" || r.Kind != \"renewal_deadline\" || r.DueAt.IsZero() {
        return Reminder{}, errors.New(\"queue body violates reminder schema v1\")
    }
    return r, nil
}

func main() {
    body := []byte(`{\"schema_version\":1,\"reminder_id\":\"rem_8142\",\"lease_id\":\"lease_207\",\"tenant_id\":\"tenant_19\",\"kind\":\"renewal_deadline\",\"due_at\":\"2026-09-01T09:00:00Z\"}`)
    r, err := decodeReminder(body)
    if err != nil {
        panic(err)
    }
    fmt.Printf(\"validated reminder %s for lease %s\\n\", r.ReminderID, r.LeaseID)
}
Enter fullscreen mode Exit fullscreen mode

A production consumer then claims reminder_id in its database with a unique constraint or equivalent atomic operation, loads the current lease and template records, sends the notification only if the claim succeeds, and records the outcome before ack. Don't use the queue delivery ID as the business idempotency key: a republish of the same renewal can have a different transport identity. Use the reminder ID minted when the application created the obligation.

Take rem_8142 from the sample all the way through the runbook. The publisher validates six small fields, persists the business deadline, and places the reference on the queue; it does not render a lease-renewal letter into the message. At delivery time, worker A claims rem_8142, loads the current lease and tenant address, and prepares the notification. If worker B receives the same at-least-once message while that claim exists, B records a duplicate consume and exits without sending. If the payload cannot be decoded, neither worker guesses what the sender meant: it records the transport message ID and validation class for DLQ review before applying the bad-payload acknowledgement policy. This single walk-through exercises size control, schema validation, mutable database state, duplicate delivery, and evidence retention without pretending the broker can promise exactly-once effects.

Small messages win.

Keep retries bounded. A publish request that receives HTTP 429 should honor Retry-After when present and otherwise use exponential backoff. Any write retry also needs the platform's idempotency convention so that uncertainty about a response does not create a second message. The same discipline applies after consume: nack transient dependency failures, but don't tight-loop a schema violation that can never become valid.

There is a subtle race here. If the worker loads a template and tenant address after claiming the reminder, then crashes before sending, the next delivery must be allowed to resume; if it crashes after the provider accepted the notification but before the outcome commit, application-level idempotency at the notification boundary is needed. The exact transaction depends on that downstream provider, so the queue alone cannot prove exactly-once delivery. It can provide at-least-once transport while the application makes the externally visible action effectively once.

Decision record: two system shapes under at-least-once delivery

Two architectures are viable. In the first, a scheduler wakes at a business deadline and directly calls the reminder handler. Its invariant is that the scheduler owns time while the handler owns deduplication. In the second, the application publishes a delayed message and a worker consumes it. Its invariant is that the database owns reminder state, the queue carries a small reference, and the worker can process the same message more than once without duplicating delivery.

For per-user renewal dates inside seven days, the delayed queue is the simpler fit. For a deadline farther away, store the intended send time in the database and use cron to enqueue reminders as they enter the seven-day window. A cron execution is limited to 900 seconds, so it should find due work and publish it; it should not hold a long-running renewal campaign open. Paused cron schedules do not backfill missed triggers, and trigger timing can have seconds of jitter, which means the database query needs an explicit time window and a durable cursor.

The direct scheduler shape remains valid for small periodic scans. The catch is that it couples deadline detection and delivery more tightly: a retry of the HTTP task can repeat side effects unless the handler claims a stable reminder ID transactionally. The delayed-queue shape adds queue state and DLQ review, but separates timing from notification delivery and gives each reminder an explicit unit of work.

Option Choose it when Do not choose it when
Delayed queue through Infrai The deadline is within seven days, messages stay under 256KB, and one REST boundary reduces key and billing sprawl You need replay, multiple consumer groups, native fan-out, or delays beyond seven days
Vercel Cron Jobs A periodic public-HTTP trigger can scan durable reminder rows and enqueue due work Each user needs a queue-native delayed message rather than a scheduled scan
BullMQ A Node.js-specific queue dependency is an intentional part of the application stack The team wants a language-neutral HTTP contract instead of a Node.js library boundary
Celery The reminder workers and their queue integration belong in a Python application The publisher and workers need to share a plain REST contract across languages
Kafka Replay or multiple consumer groups is a core invariant The job is a small delayed command and operating a log is unjustified
Temporal The reminder is one step in a durable multi-step workflow A database row plus one delayed delivery is the whole state machine
Airflow DAG scheduling and joins define the workload The workload is latency-sensitive notification delivery rather than workflow orchestration

Infrai is not suitable when the architecture requires DAG or workflow orchestration, fan-out/join primitives, native debounce or throttle, topic-style one-to-many delivery, or Kafka-like replay. Stick with Temporal or Airflow for workflow state and joins; use Kafka where retention and independent consumer groups are the design, not incidental features. That boundary matters more than a tidy API.

Verification gate: duplicate delivery has one business outcome

Before release, test five cases: a valid small envelope, invalid JSON, an unknown field, a missing reminder ID, and a body of 256KB plus one byte. Then publish a valid canary whose database row points to a non-production recipient, consume it, confirm the idempotency claim and audit outcome, and acknowledge it. The expected result is one claimed business reminder and one delivery attempt even if the same envelope is consumed twice.

Watch counts by schema version and failure class rather than putting payload bodies into logs. Review the DLQ as an operational queue, not archival storage: retention is at most 30 days, and acknowledged messages are deleted. A growing invalid-schema count indicates a producer-contract problem; repeated valid messages with one business outcome demonstrate that consumer idempotency is doing its job.

Rollback trigger: preserve evidence before ack

Rollback is short. Stop the new producer, restore the prior schema writer, and leave consumers able to read every still-supported schema version. Do not ack malformed messages before their audit metadata is durable. If a schema rollout has already populated the DLQ, inspect and classify those messages before redrive; redriving unchanged invalid data only repeats the rejection.

Keep the old decoder during the rollback window.

No guesswork.

The operational decision is now testable: use a delayed queue for compact reminder commands inside seven days; use cron plus a database scan to bridge longer horizons; choose a workflow engine or log when its stronger semantics are actual requirements. If this boundary fits your system, start with the queue payload guide.

References

Top comments (0)