Drawing 1 · Interactive
Can four workers keep up with a broadcast burst?
40× slow motionService time S = 50 ms per job on average · 4 workers
Queue diagram: 12 sends waiting, 4 of 4 workers busy, 7 done, 1 dead-letter queue.
- Backlog
- ∞
- grows without bound
- Throughput
- 80/s
- 78.4/s done · 1.6/s dead-letter queue
- Average wait
- ∞
- keeps growing
- Utilisation ρ
- 1.25
- ≈ 4 of 4 busy
ρ = λ × S ÷ c = 100/s × 50 ms ÷ 4 = 1.25
Utilisation is at or above 1: the backlog grows without bound.
What is a message queue?
A message queue is a durable list of jobs that one part of a system writes and another reads later. Producers (your order service, campaign tool or chatbot) add a job such as "send template order_dispatched to this customer" and return immediately. Consumers, called workers, take jobs off the queue, call the Cloud API and acknowledge each job once it is done. If a worker crashes in the middle of a job, the unacknowledged job becomes visible again and another worker picks it up.
In a WhatsApp stack this buffer does three jobs. It absorbs spikes: a campaign that queues 200,000 messages in a few seconds does not need 200,000 simultaneous API calls. It paces: workers send at the number's Throughput ceiling (80 messages per second by default) instead of tripping error 130429. And it decouples: a slow CRM or a Graph API incident delays sends rather than failing the request that created them. The same pattern sits behind webhook receivers, which acknowledge Meta quickly and queue each event for processing, and it is the backbone of an Event-Driven Architecture.
A few mechanics matter. The visibility timeout (or lease) is how long a taken job stays hidden before it is redelivered, so it must be longer than your slowest send including retries. Acknowledging after the send, not before, gives at-least-once delivery, which means duplicates are possible and an Idempotency Key is needed. Jobs that keep failing move to a Dead-Letter Queue instead of blocking the line, and Consumer Lag (queue depth and the age of the oldest job) tells you whether you are keeping up. When it keeps growing, Backpressure slows the producers instead of letting the queue grow without limit.
Key takeaways
- A message queue is a durable list of jobs: producers add work and return at once, and workers process it at a controlled pace.
- For WhatsApp it absorbs bursts, paces sends to the number's throughput (80 per second by default) and survives worker restarts.
- Acknowledge a job only after the Cloud API has returned a wamid and you have stored it.
- Queues deliver at least once, so every send needs an idempotency key to make duplicates harmless.
- Drain time is simply jobs divided by send rate: 120,000 messages at 80 per second take 25 minutes.
Design intent
Why does a message queue matter?
Without a queue, a send is only as reliable as the request that triggered it: a deploy, a Graph API timeout or a burst above 80 messages per second turns into lost or failed messages that nobody retries. With one, a Broadcast becomes a predictable stream that finishes at a known rate, a Meta incident becomes a backlog that drains afterwards, and the business can answer "when will everyone have it?" from queue depth and send rate alone.
Implementation
A message queue in code
import { Queue, Worker, UnrecoverableError } from "bullmq";const connection = { host: "redis", port: 6379 };const MESSAGES_URL ="https://graph.facebook.com/v26.0/" + process.env.PHONE_NUMBER_ID + "/messages";// Throttling and unknown errors are worth retrying; most other 4xx errors are not.const RETRYABLE = new Set([4, 80007, 130429, 131000, 131056]);// Producer: queue the send and return immediately.export const sends = new Queue("whatsapp-sends", { connection });await sends.add("template",{ to: "919812345678", template: "exam_result", params: ["Riya", "R-4821"] },{jobId: "result-2026-r4821", // ignored if re-added while this job is still storedattempts: 5,backoff: { type: "exponential", delay: 2_000 },removeOnComplete: 10_000,removeOnFail: false, // keep failures for inspection and redrive},);// Consumer: paced to one number's default throughput.new Worker("whatsapp-sends",async (job) => {const res = await fetch(MESSAGES_URL, {method: "POST",headers: {Authorization: "Bearer " + process.env.ACCESS_TOKEN,"Content-Type": "application/json",},body: JSON.stringify({messaging_product: "whatsapp",to: job.data.to,type: "template",template: {name: job.data.template,language: { code: "en" },components: [{ type: "body", parameters: job.data.params.map((text) => ({ type: "text", text })) },],},}),signal: AbortSignal.timeout(15_000),});const data = await res.json();const code = data.error?.code;if (!res.ok && res.status < 500 && !RETRYABLE.has(code)) {throw new UnrecoverableError("Permanent error " + code); // e.g. invalid recipient: no retries}if (!res.ok) throw new Error("Retryable error " + code); // backoff, then retryreturn { wamid: data.messages[0].id }; // the job is acknowledged when this resolves},{ connection, concurrency: 20, limiter: { max: 80, duration: 1_000 } },);
jobId makes repeat adds harmless, the limiter caps sends at 80 per second, and permanent errors skip straight to the failed set instead of burning retries.
Operating sequence
How does a message queue work?
The producer queues the job
Write the recipient, template, parameters, an idempotency key and a priority. Store references rather than large payloads, so each job stays small and self-contained.
A worker leases the job
A worker takes the next job, which stays invisible to other workers for the visibility timeout. Concurrency settings and a rate limiter keep the pool within the number's messages-per-second limit.
The worker sends, then acknowledges
Call the Messages Endpoint, store the
wamidit returns, then acknowledge. If the process dies before acknowledging, the job reappears and is retried.Failures retry with backoff
Transient errors (5xx, timeouts, 130429) are retried with exponential backoff; permanent ones (an invalid number, a paused template) are not. After the maximum number of attempts, the job moves to the dead-letter queue.
Monitor depth and age
Track how many jobs are waiting and how old the oldest one is. Rising age with a steady arrival rate means workers or throughput, not traffic, are the limit.
Specifications
A message queue: key facts and limits
- Pacing target
- 80 messages per second per number (default)
- Delivery guarantee
- At least once: design for duplicates
- When to acknowledge
- After the wamid is stored, never before the send
- Visibility timeout
- Longer than the slowest send plus its retries
- Health metrics
- Queue depth and age of the oldest job
- Drain time
- Jobs ÷ send rate (120,000 ÷ 80 = 25 min)
Example: Exam results for 120,000 students
A Vadodara university queues semester-result notices for 120,000 students at 6:00 pm. Its business portfolio already has an unlimited messaging limit, so throughput is the only ceiling: its single number sends at the default 80 messages per second, so the queue drains in about 25 minutes (120,000 ÷ 80 = 1,500 seconds), an estimate the operations team shares with students in advance. Midway through, a worker node restarts: its 40 leased jobs reappear after the 60-second visibility timeout and other workers send them, with idempotency keys preventing duplicates. Fourteen jobs fail with an invalid-recipient error and land in the dead-letter queue for the data team to clean up.
Failure modes
Common mistakes with a message queue
Acknowledging before sending
Acknowledging on receipt means a crash between the ack and the send loses the message silently. Acknowledge only after the Cloud API returned a
wamidand you stored it.A visibility timeout shorter than a send
If a slow Graph API call outlives the lease, the job is redelivered while the first attempt is still running, and the customer may get two messages. Set the timeout above your request timeout plus retries.
One queue for everything
OTPs stuck behind a 200,000-message broadcast arrive after they expire. Use separate queues or priorities for authentication, transactional and marketing traffic.
Retrying permanent errors
An invalid recipient or a paused template fails every time, so retrying it five times wastes throughput. Classify errors and send permanent failures straight to the dead-letter queue.
Ignoring per-recipient order
Parallel workers can deliver "Order shipped" before "Order confirmed". When Outbound Message Ordering matters for one customer, partition or group jobs by recipient.
Recommended practice
Best practices for a message queue
- Alert on the age of the oldest job, not only on depth: age is what customers feel.
- Let the producer generate the idempotency key once and store it in the job.
- Set rate limits per phone number, because throughput is a per-number ceiling.
- Remember that a queue paces speed, not reach: the portfolio's messaging limit still caps how many unique users you can message outside a service window in 24 hours.
- Publish the expected drain time for big broadcasts so support and marketing know when sending will finish.