Reliability of Event-Driven & Streaming Systems
A request/response service fails loudly and immediately: the caller gets a timeout or a 500, right now, and everyone downstream knows something is wrong. A queue- or event-based system can fail quietly for hours before anyone notices, because the whole point of a queue is to absorb the gap between how fast work arrives and how fast it gets done. That absorption is a feature — until the gap never closes, and the "buffer" turns out to have been debt the whole time. This page is about the reliability failure modes that are specific to producers, brokers, and consumers: what actually happens when a consumer can't keep up, how at-least-once and exactly-once delivery trade real guarantees for real costs, what a poison message does to a queue that has no dead-letter path, and why the metric most teams watch — downstream processing latency — is often the last thing to move when the system is already in trouble.
Imagine a mail slot in a door that leads to a big bin inside. Letters (messages) get pushed through the slot by a mail carrier (the producer) at whatever pace the mail arrives. Someone inside the house (the consumer) picks up letters from the bin and deals with them one at a time. If the person inside reads letters slower than the carrier delivers them, the bin doesn't overflow instantly — it just gets a little fuller every day. For weeks, nothing looks wrong: the house is standing, the door still works, the person inside still seems to be reading mail at a totally normal pace. Then one day you dig to the bottom of the bin and find a birthday invitation that's four months late, or the bin is so full letters start falling out the slot and get lost in the yard. The person inside never sped up or slowed down in any way you could see from outside — the bin depth is what was quietly telling the real story the whole time, and nobody was looking at the bin.
Why event-driven systems fail on a different axis
☺ Like you're 10: A queue doesn't remove the mismatch between "how fast work arrives" and "how fast it gets done" — it just hides that mismatch somewhere you have to know to look.
A synchronous call has one failure signal: it either returns in time or it doesn't, and backpressure is immediate and visible — the caller blocks, times out, or gets an explicit rejection. An event-driven pipeline — a producer publishing to a broker (Kafka, Amazon SQS, RabbitMQ, Google Pub/Sub, NATS JetStream), and one or more consumers reading from it — decouples the producer from the consumer in time, not just in space. The producer doesn't wait for the consumer to be ready; it writes to the broker and moves on. That decoupling is precisely why queues exist: it lets a slow or temporarily-down consumer fail without taking the producer down with it, and it smooths bursty traffic that would otherwise overwhelm a fixed-capacity consumer. The cost of that benefit is that the mismatch between arrival rate and processing rate doesn't disappear — it accumulates somewhere, silently, as lag: the growing gap between the last message produced and the last message a consumer has finished processing.
This page works through four reliability problems that are specific to this shape of system and mostly absent from request/response services: what actually happens, mechanically, when a consumer falls behind a producer (backpressure); what a message that can never be successfully processed does to a queue with no escape hatch for it (poison messages and dead-letter queues); the real difference between "the message was delivered" and "the message was delivered exactly once," and why the second one is far harder to get than the phrase implies (delivery semantics); and why the single most useful early-warning signal in most of these systems isn't the metric most dashboards default to (queue depth as a leading SLI).
A queue doesn't eliminate backpressure — it relocates it in time. The mismatch between producer rate and consumer rate has to go somewhere: onto the queue's depth, onto the broker's retention window, or eventually onto the humans who get paged once both of those run out. Everything below is really one question asked four ways: where is your system choosing to put that mismatch, and who finds out first when it does?
Backpressure: what happens when a consumer falls behind a producer
☺ Like you're 10: A bathtub with the tap running faster than the drain doesn't overflow the first second — the water level just climbs, quietly, until it does.
Backpressure is what a system does — deliberately or by accident — when downstream can't keep up with upstream. In a synchronous chain, backpressure is a first-class concept with a name at every layer: TCP's own flow-control window, a thread pool's bounded queue rejecting new work, a load balancer returning 503. In an event-driven pipeline, backpressure is easy to build in accidentally as an absence of a limit rather than a deliberate policy, because the broker sitting between producer and consumer is usually happy to keep accepting writes long after the consumer has stopped keeping up. Whether that's safe depends entirely on what the broker does with the messages piling up behind a slow consumer, and the honest answer is: it depends which broker, and it's rarely "nothing bad happens."
What "something runs out" means depends heavily on the broker, and treating them as interchangeable is a common source of surprise incidents:
| Broker | What happens when the consumer falls behind | The real ceiling |
|---|---|---|
| RabbitMQ (classic queues) | Unacked/ready messages accumulate in memory. Past the configured high memory watermark, RabbitMQ triggers a memory alarm and blocks publishing connections cluster-wide — the broker pushes backpressure onto producers directly. | Node memory (or disk, for lazy queues that page to disk deliberately). |
| Kafka | Nothing blocks the producer — Kafka is an append-only, disk-backed log, and consumer lag simply grows as an offset gap. Producers keep writing at full speed regardless of how far behind any consumer group has fallen. | log.retention.hours (default 168h/7 days) or log.retention.bytes. Once a segment ages out or the topic hits its byte cap, unread messages are deleted — permanently, silently, with no error to anyone. |
| Amazon SQS | Nothing blocks the producer — SQS has no practical size limit on a queue's backlog. Messages simply sit, invisible or visible, until a consumer claims and deletes them. | Message retention period: 4 days by default, configurable up to 14. Past that window, unconsumed messages vanish with no delivery attempt at all. |
Notice the pattern: only RabbitMQ's classic-queue behavior gives you anything resembling the synchronous world's instinct — a hard stop that protects the system by refusing new work. Kafka and SQS both choose availability for the producer over any signal that a consumer is drowning, which is usually the right default (a producer that can't write is often worse than a consumer that's behind) but means the "backpressure" isn't really backpressure at all — it's postponed data loss with a deadline attached, and the deadline is whatever the retention setting happens to be.
Building real backpressure back in
Because most managed brokers won't do it for you, a reliable event-driven system needs to choose deliberately how it responds when λ persistently exceeds μ, rather than discovering the answer at the retention boundary:
- Scale the consumer, not just watch it. Autoscale consumer replicas on a lag-based signal — KEDA's Kafka and SQS scalers, or a custom Horizontal Pod Autoscaler metric fed by consumer lag, are the standard mechanism on Kubernetes. This only helps up to the partition-count ceiling: a Kafka topic with 12 partitions cannot usefully run more than 12 consumers in one group, no matter how aggressively you scale, because a partition is only ever read by one consumer within a group at a time.
- Throttle the producer at the broker. Kafka supports first-class producer quotas (
kafka-configs.sh --entity-type clients --entity-name checkout-service --alter --add-config 'producer_byte_rate=1048576') that cap a client's write rate and return backpressure (throttled responses) instead of silently accepting unlimited load. - Shed load deliberately, by priority. If not every event is equally important, make that explicit — sample, drop, or downgrade low-priority event classes (verbose telemetry, best-effort analytics) before the queue depth threatens anything that actually matters, rather than treating every message as equally worth the retention budget.
- Alert on the backlog itself, before it becomes data loss. Section below is entirely about why this is the metric to watch, not consumer CPU or per-message latency.
"Our queue can absorb bursts" is true and also the exact sentence that precedes most retention-boundary data-loss incidents. Absorption without a bound is not resilience — it's deferred failure with a clock attached, and the clock is whatever retention window you configured (or accepted as a default) without thinking about it as a reliability parameter. Treat retention/TTL settings with the same seriousness as a timeout value: too short and you lose data during an ordinary blip; too long and you've quietly promised a recovery time you may not actually have engineered for.
Delivery semantics: at-least-once, at-most-once, and "exactly-once" with an asterisk
☺ Like you're 10: A mail carrier can promise "I'll never lose your letter" or "I'll never deliver it twice," but almost never both at once — and the version that sounds like it promises both usually has fine print.
The order of two operations — acknowledge the message and process the message — is the entire difference between the three delivery semantics a consumer can offer, and it's worth being precise about what each one actually guarantees rather than treating "exactly-once" as a checkbox to aim for by default.
| Semantics | Ack/commit timing | Failure mode | Use it when |
|---|---|---|---|
| At-most-once | Committed before processing (or never retried at all) | Crash between commit and finishing processing = the message is gone, and nothing will ever redeliver it. | Losing an event is genuinely cheaper than the cost of possibly duplicating it — best-effort telemetry pings, some metrics ingestion. |
| At-least-once | Committed after processing succeeds; on failure, redelivered | Crash between finishing processing and committing = the message is redelivered and processed again — the consumer sees it twice. | The default assumption for anything where losing an event is unacceptable: orders, payments, provisioning, state changes. Requires an idempotent consumer. |
| Exactly-once (scoped) | Idempotent producer + transactional read-process-write, bundling the output write and the offset commit into one atomic unit | Holds only for the boundary the transaction actually covers. Any effect outside it (a database write, an HTTP call, an email) is not part of the transaction and reverts to at-least-once. | Kafka Streams-style topologies that read from Kafka, transform, and write back to Kafka — entirely inside the log. |
That third row is the one worth dwelling on, because "exactly-once" is the phrase vendors reach for and the guarantee is almost always narrower than it sounds. Kafka's exactly-once semantics (EOS) rest on two real mechanisms: an idempotent producer (enable.idempotence=true), which tags each message with a producer ID and a per-partition sequence number so the broker can silently discard a duplicate caused by a producer-side retry after a network blip; and transactions (a transactional.id, with downstream consumers reading at isolation.level=read_committed), which let a consume-transform-produce step commit its output records and its input offset atomically — either both happen or neither does. That is a genuine, well-engineered guarantee, and it is real exactly-once processing — as long as every effect of processing a message stays inside Kafka. The instant your consumer's job is to write a row to Postgres, call a payments API, or send an email, that side effect sits outside the transaction boundary. A crash between "Kafka transaction committed" and "external write happened" (or the reverse order) can duplicate or lose that external effect regardless of how correctly Kafka's own internals behaved, because Kafka has no way to make an external system's write atomic with its own commit.
"Exactly-once delivery," for any pipeline that touches something outside the broker, is really "at-least-once delivery, plus an idempotent consumer that makes duplicates harmless." That's not a lesser guarantee — it's the actual guarantee almost every production system is running on, whether or not the broker's marketing page says "exactly-once" on it. Design every consumer to expect redelivery, and the distinction stops being a source of incidents.
Making at-least-once safe: idempotency in practice
Three concrete techniques turn "the consumer might see this message twice" from a hazard into a non-event:
- Idempotency keys. Attach a unique, stable ID to each logical operation (not just each message — a retried message and its original should carry the same key) and check a dedup store before applying the effect. Stripe's public API popularizes this exact pattern with its
Idempotency-Keyheader: the same key submitted twice returns the cached result of the first attempt instead of executing twice. - Naturally idempotent operations. Prefer
SET balance = 500overUPDATE balance = balance + 50; prefer an upsert keyed on a natural ID over a blind insert. An operation that produces the same end state no matter how many times it runs doesn't need a dedup store at all. - The transactional outbox pattern. The dual-write problem — "update the database AND publish an event, atomically" — has no clean answer if you literally do both as two separate writes, because a crash between them leaves one done and the other not. The outbox pattern instead writes the event to an
outboxtable in the same database transaction as the business write, then a separate process (commonly Debezium, reading the database's write-ahead log via change-data-capture) publishes rows from that table to Kafka asynchronously. The business write and the "I published this" fact are now atomic by construction, and publishing itself is free to be at-least-once, because the outbox rows make republishing idempotent to reason about.
-- Idempotent consumer, sketch:
BEGIN;
SELECT 1 FROM processed_messages WHERE message_id = :id FOR UPDATE;
-- if a row exists, COMMIT and skip — this message was already applied
INSERT INTO processed_messages (message_id, processed_at) VALUES (:id, now());
-- ... apply the actual business effect, in the SAME transaction ...
COMMIT;
-- A redelivered message with the same :id now short-circuits safely,
-- whether the redelivery was caused by a crash, a rebalance, or a retry.Poison messages and dead-letter queues
☺ Like you're 10: One letter with a return address a mail carrier can't read shouldn't be able to jam the whole mail truck so nothing else gets delivered — but without a separate "can't deliver" bin, that's exactly what happens.
A poison message is one that a consumer can never successfully process, no matter how many times it's redelivered — a malformed payload, a schema violation from an upstream producer that shipped a breaking change, or a message whose specific content deterministically triggers a bug (a null field the handler doesn't expect, a numeric overflow, a foreign key that no longer exists). The defining property isn't that it's rare; it's that retrying it changes nothing. Without a deliberate escape hatch, at-least-once delivery's own retry logic turns a poison message into an infinite loop: the consumer fails, the message gets redelivered (on nack, or once a visibility timeout expires), it fails again, forever.
How bad that infinite loop is depends heavily on whether the broker enforces ordering. In SQS or RabbitMQ, a poison message is one bad object among many independent ones — annoying, wasteful of retries, but not fatal to throughput. In Kafka, it's much worse: a consumer that throws on a message and doesn't advance its committed offset for that partition blocks every subsequent message in that partition behind it — this is head-of-line blocking, and because ordering is only guaranteed within a partition, one bad message can stall an entire key's worth of traffic indefinitely while every other partition keeps flowing normally, making the failure look partial and confusing until someone notices lag is climbing on exactly one partition.
Dead-letter queue mechanics, broker by broker
A dead-letter queue (DLQ) is the deliberate off-ramp: after N failed delivery attempts, move the message somewhere else entirely, so the main queue keeps flowing and the poison message becomes a triage item instead of a blockage.
// SQS redrive policy, attached to the source queue —
// after 5 failed receives, the message moves to the DLQ automatically:
{
"deadLetterTargetArn": "arn:aws:sqs:us-east-1:111122223333:orders-dlq",
"maxReceiveCount": "5"
}# RabbitMQ: declare the DLQ routing on the source queue's arguments —
# a message that's rejected (nack, requeue=false) or whose TTL expires
# gets republished through the dead-letter exchange automatically:
x-dead-letter-exchange: "orders.dlx"
x-dead-letter-routing-key: "orders.dead"
x-message-ttl: 30000 # also used to build tiered retry delaysKafka has no native DLQ concept — the log doesn't know what "failed" means, only what's been committed. The pattern is built by hand: catch the processing exception, produce the offending record to a separate <topic>.DLT topic (a convention popularized by Spring Kafka's DeadLetterPublishingRecoverer), then commit past it on the source topic so the partition keeps moving. Kafka Connect ships this as a configuration rather than code you write yourself:
errors.tolerance = all
errors.deadletterqueue.topic.name = connect-dlq
errors.deadletterqueue.topic.replication.factor = 3
errors.deadletterqueue.context.headers.enable = true # keeps the failure reason attachedA more forgiving middle ground than "fail once, go straight to the DLQ" is a tiered retry topic pattern — used in production at Uber and documented publicly by Confluent — where a failure republishes to orders-retry-5s, then orders-retry-1m, then orders-retry-1h, each with exponential backoff and jitter, before finally landing in the DLQ only once every tier is exhausted. This absorbs the ordinary case (a downstream dependency was down for ninety seconds) without immediately quarantining a message that would have succeeded on the second try, while still protecting the main topic from being blocked while the retries play out.
Bring up RabbitMQ locally (docker run -p 5672:5672 -p 15672:15672 rabbitmq:3-management), declare a queue with x-dead-letter-exchange pointing at a second queue, and publish a message your consumer deliberately rejects with basic.nack(requeue=false). Watch it land in the DLQ instead of looping forever, then check the management UI's messages_ready count on both queues. You've just built, by hand, the exact mechanism SQS gives you as a JSON policy — seeing it as explicit exchange routing once makes the managed version far less mysterious.
A DLQ without an owned triage process doesn't solve the poison-message problem — it relocates it from "blocking the queue, loudly" to "rotting silently, forgotten." Alert on DLQ depth as its own SLI (treat any non-zero, sustained depth as a page, not a dashboard tile), assign an owner to redrive or discard messages on a schedule, and build the redrive tooling — a script that replays DLQ messages back to the source topic after a fix ships — before you need it at 2 a.m., not during the incident that makes you need it.
Ordering, partitioning, and the rebalancing feedback loop
☺ Like you're 10: When one runner on a relay team stumbles and the whole team has to stop and re-hand off the baton, that pause is worse for everyone's time than the original stumble was.
Ordering guarantees in event-driven systems are almost always scoped, not global, and assuming otherwise causes quiet correctness bugs. Kafka guarantees order only within a partition — messages sharing a partition key (say, an order ID) arrive at their consumer in the order they were produced, but there is no ordering guarantee across partitions or across keys. A single RabbitMQ queue with exactly one consumer preserves strict order; the moment you add a second consumer for throughput, round-robin dispatch breaks that guarantee unless you deliberately use a consistent-hashing exchange or a single-active-consumer policy to keep it.
Consumer rebalancing — a Kafka consumer group reassigning partitions among its members — is where backpressure and poison messages compound each other into something worse than either alone. A rebalance triggers when a consumer joins, leaves, crashes, or fails to call poll() again within max.poll.interval.ms (the default is five minutes) — exactly the situation a consumer stuck on a poison message, or one buried under backpressure-induced processing delay, ends up in. Under the older "eager" rebalance protocol, every consumer in the group stops consuming for the duration of the rebalance — a stop-the-world pause that hits precisely when the group can least afford it, because lag was already climbing. Kafka's incremental cooperative rebalancing (CooperativeStickyAssignor, from KIP-429, and the newer client default) narrows this considerably: only the specific partitions actually being reassigned pause, and consumers that keep their existing assignment keep consuming through it.
The feedback loop reads: a poison message or a slow downstream dependency stalls one consumer past max.poll.interval.ms → the group rebalances → (on the eager protocol) every consumer pauses → lag spikes across every partition, not just the stuck one → the larger lag makes catching up slower, which risks tripping the interval again. Raising max.poll.interval.ms buys legitimately slow processing more room, but only combine that with a DLQ and backpressure controls — otherwise you've just made the death spiral take longer to notice, not prevented it.
Queue depth as a leading SLI
☺ Like you're 10: Timing how long it takes to answer one letter tells you nothing about whether the bin behind you is quietly filling up faster than you can empty it.
Here is the failure mode this whole page has been building toward, and the one most teams learn the hard way. Suppose a consumer processes every message it receives in a steady 50ms — its p99 processing latency SLI looks perfect, hour after hour, dashboard green throughout. Meanwhile the producer is emitting 120 messages/second and this consumer can only sustain 80/second. The backlog grows by 40 messages every single second, continuously, and nothing about the per-message latency metric moves at all, because that metric only measures how long it takes to handle a message once processing starts — it has no way to see how long a message waited before that, or how many more are piling up behind it. The first person to notice is usually a customer asking "why is this event three hours stale," or worse, nobody, until the broker's retention window quietly starts discarding the oldest unread messages.
Two better metrics, and how to read them
The fix isn't to abandon latency SLIs — it's to add a leading indicator that measures the backlog directly, and to prefer the version of it that's already normalized into time rather than a raw count:
| Signal | Where to get it | Leading or lagging? |
|---|---|---|
| Per-message processing latency | Application-emitted histogram (Prometheus, Datadog) | Lagging. Blind to backlog by construction — it only measures work already in progress. |
| Raw queue depth / lag (message count) | Kafka: kafka-consumer-groups.sh --describe (the LAG column), or Burrow / Kafka Lag Exporter feeding Prometheus. RabbitMQ: messages_ready via the management API. SQS: ApproximateNumberOfMessagesVisible. | Leading, but a bare count needs context — 10,000 messages is calm for a high-throughput topic and a five-alarm fire for a low-volume one. |
| Oldest-message age | SQS's ApproximateAgeOfOldestMessage CloudWatch metric is built in and already time-normalized; the Kafka/RabbitMQ equivalent is lag_time ≈ backlog ÷ consumption_rate, an application of Little's Law's reversed form from queueing theory for SRE. | Leading, and directly comparable to an SLO like "no unprocessed message sits for more than 5 minutes" — the same units the SLO is stated in. |
Oldest-message age deserves the emphasis: it's the one signal on this list you can put directly into an SLO statement without any unit conversion, and it degrades gracefully into an intuitive story — "the oldest thing we haven't gotten to yet is now four minutes old" is meaningful to anyone, where "lag is 9,400 messages" requires knowing the topic's normal throughput to interpret at all.
This is the exact same blind spot the queueing-theory-for-sre page describes for CPU utilization — an averaged, lagging metric staying calm while the thing that actually determines user-visible pain (the M/M/1 tail, or here, the backlog) has already turned the corner. Different axis, same shape of mistake: watching the number that's easy to instrument instead of the number that's actually leading.
Building it into an SLO and alerting policy
☺ Like you're 10: Six habits that turn everything above from "things to know about" into "things your pager actually catches before a customer does."
Everything above compresses into a short, concrete policy for any team operating a queue- or event-based pipeline in production.
1. Make oldest-message age (or lag-time) the primary SLI
State the SLO in the same units a human would use — "95% of the time, no unprocessed message is older than 5 minutes" — not "average lag stays under 10,000 messages." See SLIs, SLOs & error budgets for how to formalize this as a real, measured SLO with a compliance window, and keep the per-message latency SLI as a secondary signal rather than the primary one.
2. Alert on the growth rate, not just the absolute value
A backlog that's high but shrinking is a different situation from one that's lower but climbing. Apply the same multi-window, multi-burn-rate thinking used for error-budget alerting — a fast, short-window threshold for a sudden spike, a slower, longer-window threshold for a gradual leak — to lag growth instead of error rate; see multi-window, multi-burn-rate alerting for the underlying mechanism, which transfers directly.
3. Treat DLQ depth as its own zero-tolerance SLI
A DLQ receiving messages means something is actively broken right now — a schema mismatch, a downstream outage, a bug triggered by real production data. Alert on any sustained non-zero depth rather than waiting for a threshold, and route it to an owner, not a shared inbox.
4. Load-test consumer throughput headroom before production finds the ceiling
Verify sustained consumer throughput exceeds worst-case producer rate with real margin, using the same load-testing discipline as any other capacity plan — k6 or Locust driving synthetic load into a staging topic or queue, measured against the M/M/1 knee math in queueing theory for SRE and folded into the standard checklist in a production readiness review.
5. Chaos-test backpressure and poison-message handling deliberately
Don't wait for the first real poison message to find out whether your DLQ routing actually works. Deliberately publish a malformed message in staging, or pause a consumer group to force lag growth, using tools like Gremlin or Litmus, and confirm the whole chain — DLQ routing, autoscaling, lag alerts — fires the way this page describes it should. Chaos engineering covers the broader discipline this is one application of.
6. Design idempotent consumers by default, not as an incident follow-up
Build the dedup store or outbox infrastructure before you need it. Every consumer should be written assuming redelivery will happen — because, per the delivery semantics section above, it eventually will — rather than treating idempotency as a fix applied after the first duplicate-processing incident.
Every practice above is one instruction, applied six ways: stop measuring only how fast work gets done once it starts, and start measuring how far behind the system already is. The first metric lags the incident. The second one, done right, prevents it.
Benny the Beaver: My order-processing consumer's p99 latency has been a flat 50 milliseconds all week. I'd call that a job well done.
Pip the Hummingbird: Then why did I just page someone for a consumer-lag burn-rate alert on that exact same topic?
Foxy: That's what I don't follow. Flat latency and a growing backlog, at the same time, on the same consumer? Pick a story.
Ellie the Elephant: Both are true, and both are in the telemetry. Latency only measures the fifty milliseconds it takes once a message starts processing. It has no way to see the forty extra messages a second that never got a turn.
Benny the Beaver: ...I only ever built an alert on p99 latency. Nothing was watching the oldest-message age at all.
Timmy the Turtle: Which is exactly the gap. And while we're in there — is that consumer idempotent? Because at-least-once means this exact message may already have been redelivered once tonight.
Benny the Beaver: ...also no. I assumed each message arrives once.
Professor Owl: Then that's tonight's fix, in order: dedup keys on the consumer first, since duplicates are the more urgent risk — and an oldest-message-age alert right behind it, so the next backlog pages someone before a customer does.
1. What does "backpressure" actually mean in a queue-based system, and why do Kafka and SQS both fail to provide it the way RabbitMQ's memory alarm does? 2. Explain, precisely, why Kafka's exactly-once semantics guarantee doesn't extend to a consumer that writes to an external database. 3. What is head-of-line blocking, and why does one poison message do more damage in Kafka than in SQS or RabbitMQ? 4. Name two concrete ways to make an at-least-once consumer safe against duplicate processing. 5. Why can per-message processing latency stay perfectly flat while a consumer is falling further and further behind? 6. Name two metrics that fix that blind spot, and explain why one is more directly usable in an SLO statement than the other.
Check your answers
- Backpressure is downstream's inability to keep up producing a signal that constrains upstream. RabbitMQ's memory-alarm mechanism actually blocks producers once memory crosses a watermark. Kafka (disk-backed log) and SQS (managed, effectively unbounded storage) both keep accepting writes regardless of consumer lag — the backlog just grows until a retention window (Kafka's
log.retention.hours, SQS's message retention period) silently deletes the oldest unread messages, which is data loss with a deadline, not backpressure. - Kafka's exactly-once guarantee comes from an idempotent producer plus transactions that atomically bundle an output write with an offset commit — but that atomicity only covers effects inside Kafka. A database write triggered by processing a message is a separate system with no shared transaction, so a crash between the Kafka commit and the database write (or vice versa) can duplicate or lose that external effect regardless of Kafka's internal correctness.
- Head-of-line blocking is when one message a consumer can't process stalls every message behind it because the consumer can't advance past the stuck point. In Kafka, a partition is a strictly ordered log read by one consumer at a time, so a stuck offset blocks that entire partition's traffic. In SQS or RabbitMQ, messages are largely independent objects, so one poison message wastes retries but doesn't block delivery of unrelated messages.
- Any two of: idempotency keys checked against a dedup store before applying an effect (e.g. Stripe's
Idempotency-Keypattern); designing operations to be naturally idempotent (upserts/SET instead of increments); the transactional outbox pattern, which writes an event to an outbox table in the same transaction as the business write so publishing can safely be at-least-once. - Because per-message latency only measures the time to process a message once it starts — it has no visibility into how many messages are waiting behind it or how long they've been waiting. A consumer processing steadily at 80 msg/s against 120 msg/s of arrivals has perfectly flat, on-SLO latency the entire time the backlog grows by 40 messages every second.
- Raw queue depth/lag (a message count from the broker, e.g. Kafka's
LAGcolumn or SQS'sApproximateNumberOfMessagesVisible) and oldest-message age (SQS's built-inApproximateAgeOfOldestMessage, or lag-time computed as backlog ÷ consumption rate). Oldest-message age is more directly usable in an SLO because it's already expressed in time — "no message older than 5 minutes" — the same unit the SLO itself is stated in, whereas a raw count needs the topic's normal throughput as context to interpret.