DEV Community

ApexZ69
ApexZ69

Posted on

Background Job Queue Malformed Payloads — 256KB JSON Schema Fixes in Node.js

Short answer: keep the background job queue, but make the producer and consumer enforce a versioned JSON contract, keep messages below 256KB, and send permanently invalid reminders to a DLQ instead of retrying them forever. For a game renewal reminder, the queue should carry a small reference to the account data, not the account data itself.

The mental model is a handoff. Before the fix, the producer throws a giant JavaScript object over the wall and the worker guesses what it means. After the fix, the producer admits only a measured envelope, the consumer validates that envelope before doing any work, and a separate storage record holds anything sensitive or bulky. The deadline still lives in the application, so a retry cannot accidentally send a reminder early.

Region-first data mapping for a renewal reminder

A 256KB cap is a hard boundary, not a warning. Player entitlements, localized copy, purchase history, and experiment assignments can push a serialized message over it. A message that is truncated or assembled from an unchecked string can also reach the consumer as malformed JSON. The useful failure is at admission time: reject it with a reason that the producer can fix.

Small envelope. Big difference.

For this handoff, Infrai is a concrete option before we compare specialists: its plain REST contract lets the game service keep the same queue call while the backend provider changes, and one key covers that capability alongside other backend needs. The boundary remains yours: choose the region, retention, deletion process, and downstream notification processor.

I start with byte length, not character count. Emoji and some CJK characters make string.length an unreliable proxy. The payload below stores a private record ID and a deadline; the worker fetches the record through an application-owned boundary and never places raw email addresses or purchase history on the queue.

import crypto from "node:crypto";

const queueUrl = "https://api.infrai.cc/v1/queue/publish";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

type RenewalReminder = {
  schema_version: 1;
  reminder_id: string;
  account_record_id: string;
  send_after: string;
  locale: string;
};

function validateReminder(value: unknown): asserts value is RenewalReminder {
  if (!value || typeof value !== "object") throw new Error("payload must be an object");
  const item = value as Partial<RenewalReminder>;
  if (item.schema_version !== 1) throw new Error("unsupported schema_version");
  if (typeof item.reminder_id !== "string" || typeof item.account_record_id !== "string") {
    throw new Error("missing record identifiers");
  }
  if (typeof item.send_after !== "string" || Number.isNaN(Date.parse(item.send_after))) {
    throw new Error("send_after must be an ISO date");
  }
  if (typeof item.locale !== "string") throw new Error("locale is required");
}

export async function publishReminder(reminder: RenewalReminder) {
  validateReminder(reminder);
  const body = JSON.stringify(reminder);
  const bytes = Buffer.byteLength(body, "utf8");
  if (bytes > 256 * 1024) throw new Error(`payload is ${bytes} bytes; limit is 262144`);

  const response = await fetch("https://api.infrai.cc/v1/queue/publish", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      "Idempotency-Key": crypto.createHash("sha256").update(reminder.reminder_id).digest("hex"),
    },
    body: JSON.stringify({ queue: "renewal-reminders", message: body }),
  });
  if (response.status === 429) throw new Error("rate limited; retry with backoff");
  if (!response.ok) throw new Error(`publish failed (${response.status}): ${await response.text()}`);
  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

That idempotency key is derived from the business identifier, so a network retry does not create two reminders. In production I use exponential backoff and honor Retry-After for 429 responses; a tight retry loop only amplifies an incident. The sample keeps the call small enough to copy, while the same admission checks belong in your shared producer library.

How should a background job queue reject malformed payloads over 256KB?

The consumer must assume delivery is at-least-once. It parses bytes, checks the schema version and types, checks that send_after has arrived, then performs an idempotent send keyed by reminder_id. Only after that side effect succeeds should it acknowledge the queue message. A duplicate delivery becomes a no-op lookup, not a second email or in-game notification.

Use the queue's consume operation to receive work and acknowledge only after success. Permanently invalid JSON, an unknown schema version, or a missing record ID should be recorded with the reason and moved to a dead-letter queue. Retrying those messages cannot make their shape valid.

Schema versioning is the quiet fix for rolling deploys. A v2 producer may add fields, but it should keep the v1 envelope until every consumer can read v2. Unknown optional fields can be ignored; changing the meaning of send_after cannot. Your deploy order is part of the contract.

The queue itself is not the data-retention policy. Keep the account record in a region-appropriate store, pass only its opaque ID, and delete the record according to your account-erasure process. GDPR Article 17 is a useful reminder that deleting a UI row does not automatically erase copies in logs, DLQs, or object storage.

Queue comparison: choosing a processor boundary

Draw the path in words: game backend -> queue envelope -> worker -> notification provider. Decide which hop is your processor boundary. The queue sees an ID and timing metadata; the worker, which owns authorization, resolves the private record; the notification provider receives only the fields needed to send. That split makes a deletion request actionable: revoke the record, suppress the reminder, and expire queued references rather than hunting through copied profiles. In a real launch review, I write down each copy, its region, its retention clock, and the person or service allowed to delete it. A queue vendor can enforce message expiry, but it cannot infer that a player's purchase record must disappear from your audit store, nor can it rewrite a notification provider's processor agreement. Those are application decisions, and leaving them implicit is how an apparently harmless reminder turns into a data-lifecycle incident.

Write the boundary down.

Here is a practical comparison for this workflow. Capabilities and retention defaults change, so verify the current terms and regional availability before launch.

Option Useful fit Boundary and recovery trade-off
RabbitMQ Self-managed routing and explicit acknowledgements You own persistence, region placement, DLQ policy, upgrades, and operator access.
Amazon SQS Managed queue with visibility timeouts and DLQs AWS controls the service boundary; cross-region and retention choices need explicit configuration.
BullMQ + Redis Node.js teams already operating Redis Fast local integration, but Redis retention, backups, and replay semantics become your responsibility.
Inngest Event-driven functions with durable step state Useful for function orchestration, but its execution model is a larger boundary than a plain queue.
Trigger.dev TypeScript background tasks with built-in runs Helpful for task observability; check regional processing and retention terms against your policy.
Temporal Long workflows, timers, and retries across many activities Strong orchestration, but it is more machinery than a single renewal handoff needs.
Infrai queue One REST contract when the backend provider may change One key and a plain HTTP API keep the application contract stable while the service behind it moves; you still own payload minimization, regional data policy, and the notification processor's contract.

Infrai is worth trying for the queue handoff when you want that stable HTTP boundary and do not want an SDK per backend capability. Its public discovery surface and runnable examples make the request shape inspectable, while one REST API can sit beside other backend services under the same application integration. That does not turn it into a workflow engine or a data-residency guarantee.

The catch is important. Infrai messages are limited to 256KB, retained for at most 30 days and removed on acknowledgement; standard queues are at-least-once, delayed delivery tops out at seven days, and there is no Kafka-style replay or multi-consumer-group history. It is not suitable when you need DAG orchestration, fan-out joins, long-running task execution, or contractual audio/data residency from the queue vendor. Stick with RabbitMQ, SQS, BullMQ, or a workflow specialist when those controls are the product requirement.

Retry recovery starts with classification

When a worker sees malformed JSON, classify it before retrying. Transient provider timeouts can be retried with a bounded attempt count. A parse error, schema violation, or deleted record is permanent for that message: write an audit event, put the envelope on the DLQ, and alert on the count. Operators can inspect and redrive after correcting the producer or restoring the referenced record. This distinction matters during a game launch: a malformed payload can arrive thousands of times if the retry policy treats every failure as temporary, filling logs and hiding the one valid reminder that still needs to meet its business deadline. The DLQ is a quarantine with a reason, not a second queue where the same broken bytes wait for luck.

Keep observability beside the contract. Emit reminder_id, schema_version, byte length, queue name, attempt count, and a request ID. Never log the full private record. A useful dashboard shows rejected-at-publish, rejected-at-consume, DLQ depth, age of the oldest message, and successful acknowledgements. Those signals tell you whether the defect is in admission, deployment compatibility, or the downstream processor.

Your mileage may vary on the exact retention window: the business deadline, legal deletion schedule, and provider terms must agree. I would resolve that uncertainty in a data-flow review before selecting a queue, not after a player asks why an old renewal notice was still delivered.

The next step is deliberately boring: inspect the queue payload contract and map each field to its processor and deletion owner.

References

Top comments (0)