Drawing 1 · Interactive
Walk through a worker that quarantines poison messages
const NEVER_RETRY = new Set([100, 131047, 132000, 132001]);const MAX_ATTEMPTS = 5; export async function processJob(job) { let payload; try { payload = JSON.parse(job.data.raw); } catch (err) { return quarantine(job, "unparseable_json", { message: err.message }); } if (job.attemptsMade >= MAX_ATTEMPTS) { return quarantine(job, "attempt_limit_reached"); } const res = await sendMessage(payload); // POST /{PHONE_NUMBER_ID}/messages if (res.ok) return; const error = res.body.error ?? {}; if (NEVER_RETRY.has(error.code)) { return quarantine(job, "meta_error_" + error.code, error); } throw new Error("transient " + (error.code ?? res.status)); // retry with backoff} async function quarantine(job, reason, detail = {}) { await deadLetters.add("quarantined", { original: job.data, reason, detail, attempts: job.attemptsMade, fbtrace_id: detail.fbtrace_id, }); log.warn({ event: "poison_message", reason, job_id: job.id });}Step 6 of 6 · lines 26–32
Quarantine keeps the original payload, the reason and the attempt count, and logs a structured event that alerting can match.
worker.js, step 6 of 6, lines 26–32: Quarantine keeps the original payload, the reason and the attempt count, and logs a structured event that alerting can match.
What is a poison message?
A poison message (also called a poison pill) is a message on a message queue or stream that its consumer can never process successfully. Every time a worker picks it up the same thing happens — a parse error, a validation failure, an exception in the handler — and the message goes back on the queue to be tried again. Without a limit it loops forever, burning worker time and filling the logs with the same stack trace.
In a WhatsApp integration the usual suspects are concrete: a webhook payload your parser does not expect (a new message type, a missing field), a send job whose values no longer line up with each message template variable of the approved template, so the Cloud API returns error 132000 every time, a job that points at a media ID which has already expired, or a text body longer than the API accepts. None of these will succeed on attempt two, ten or fifty, because nothing about the input changes between attempts.
The cure has two halves. Detection: count deliveries (ApproximateReceiveCount on SQS, the x-delivery-count header on RabbitMQ quorum queues, an attempts counter in most job libraries) and classify errors in your retry policy so that a permanent failure is recognised immediately. Quarantine: move the message aside, usually to a dead-letter queue, with the reason and the last error attached. The poison message is the problem; the dead-letter queue is where it waits for a human.
Key takeaways
- A poison message is a payload that fails its consumer on every attempt, so retrying it only wastes capacity and blocks other work.
- Detect it two ways: a delivery-count cap catches anything stuck, and error classification catches permanent failures on the first try.
- A WhatsApp send that fails with error 132000 (parameter count mismatch) fails identically on every retry, so quarantine it instead.
- The poison message is the root cause; the dead-letter queue is only where it gets parked for inspection and replay after a fix.
- In an ordered stream such as a Kafka partition, one poison record stalls everything behind it until it is moved aside.
Operating sequence
How does a poison message work?
Cap deliveries per message
Configure a maximum delivery count on the queue (for example a redrive policy with
maxReceiveCount: 5) or check the attempt counter in the worker. Anything past the cap is treated as poison, whatever the error.Classify errors on the first failure
Some failures are permanent by nature: JSON that does not parse, a schema violation, or Cloud API errors such as 100 (invalid parameter), 132000 (parameter count mismatch) and 132001 (template does not exist). Quarantine these at once instead of spending the remaining attempts.
Quarantine with context
Move the message to a dead-letter queue together with the reason, the error code and message, the Graph API
fbtrace_id, the attempt count and the consumer version, then acknowledge the original so it leaves the main queue.Alert on arrivals
Alert when the first message lands in quarantine and when the arrival rate rises. One poison message often signals a deploy or template change that is about to poison many more.
Fix, then replay
Correct the cause — the parameter mapping, the parser, the data — and replay quarantined messages through the normal path, each carrying its idempotency key, so anything that did succeed earlier is not sent twice.
Implementation
A poison message in code
WhatsAppSendQueue:Type: AWS::SQS::QueueProperties:VisibilityTimeout: 60RedrivePolicy:deadLetterTargetArn: !GetAtt WhatsAppSendDLQ.ArnmaxReceiveCount: 5 # the 6th receive moves it to the DLQWhatsAppSendDLQ:Type: AWS::SQS::QueueProperties:MessageRetentionPeriod: 1209600 # 14 days, the SQS maximum
A delivery cap at the queue level catches anything the worker fails to classify. Other brokers have equivalents: a delivery limit on RabbitMQ quorum queues, an attempts option in job libraries.
Example: One unknown event that stalled a Kafka partition
A Bengaluru electronics retailer streams inbound WhatsApp webhooks into Kafka, partitioned by the customer's wa_id so each conversation stays in order. One afternoon a customer sends a message type the parser was never written for, and the consumer throws on it every time. Because the consumer retried in place and never committed past the record, partition 7 stopped: about 1,900 messages from other customers on that partition waited 40 minutes, while dashboards showed the other partitions healthy. The fix has two parts. The consumer now validates each event against a schema and, after three failures, publishes the record with its error to a side topic, commits past it and alerts. The parser was updated the next morning and the quarantined event replayed, with no other conversation blocked.
Failure modes
Common mistakes with a poison message
Retrying everything the same way
A blanket 'retry five times' wastes four attempts on errors that cannot succeed and delays the alert. Classify first: retry transient errors, quarantine permanent ones on sight.
Dropping instead of quarantining
Deleting a failing message stops the loop but loses the evidence and the customer notification. Park it with its payload and error so it can be fixed and replayed.
Losing the reason
A dead-letter queue full of bare payloads forces someone to reproduce every failure. Attach the error code and message,
fbtrace_id, attempt count and the code version that failed.Blocking an ordered stream
On a Kafka partition or a FIFO queue grouped by customer, a consumer that retries in place halts every later message. After the cap, move the record to a side topic and commit past it.
Replaying without idempotency
Replaying a batch after a fix can resend messages that partly succeeded earlier. Key each send by a business ID and check it before calling the API.
Specifications
A poison message: key facts and limits
- Common delivery caps
- 3–10 deliveries, set per queue
- Never-retry Cloud API errors
- 100, 132000, 132001 (input or template problems)
- SQS delivery counter
- ApproximateReceiveCount; redrive via maxReceiveCount
- RabbitMQ quorum queues
- x-delivery-count header and a delivery limit
- Kafka
- No built-in redelivery count; a failing record stalls its partition
- SQS dead-letter retention
- Up to 14 days (1,209,600 seconds)
Design intent
Why does a poison message matter?
One poison message can do outsized damage. On a shared queue it takes a worker slot on every redelivery and floods alerts with identical errors, hiding real incidents. On an ordered stream it blocks every message behind it, so a single malformed webhook can stall all inbound chats on that partition. If each retry calls the Cloud API, the bad job also spends throughput on requests that are certain to fail. Detecting and quarantining poison messages keeps healthy traffic flowing while the broken few wait for a fix.
Recommended practice
Best practices for a poison message
- Watch quarantine arrivals closely after every deploy and template edit — poison messages cluster around releases.
- Validate webhook payloads against a schema at the edge, so malformed events are recognised before they reach a queue.
- Keep the raw payload of quarantined events with phone numbers masked; it is usually enough to reproduce the bug.
- Build a one-click replay for the dead-letter queue and rehearse it before you need it.
- Record every quarantine with structured logging — reason, error code, template name — so dashboards can count poison messages by cause.