Engineering for Reliability · Reliability of Event-Driven & Streaming Systems

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.

☺ Explain it like I'm 10

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.

🐢Your host for this topic: Timmy the Turtle — the same reliability guardrail who never trusts a service without a circuit breaker and a timeout doesn't trust a queue without a bound on how far behind it's allowed to fall, either. Every pattern on this page is Timmy's usual instinct — assume failure, verify the guardrail — applied to producers, brokers, and consumers instead of synchronous calls.

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).

◆ The whole idea

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."

When consumer rate falls behind producer rate, backlog grows without bound Producer rate λ msgs/s λ Broker / Queue t1 t2 t3 t4 backlog depth, over time → μ Consumer rate μ msgs/s λ > μ ⇒ backlog grows by (λ − μ) every second — until autoscaling, backpressure, or the broker's retention window catches up with it

What "something runs out" means depends heavily on the broker, and treating them as interchangeable is a common source of surprise incidents:

BrokerWhat happens when the consumer falls behindThe 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).
KafkaNothing 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 SQSNothing 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:

⚠ Watch out

"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.

SemanticsAck/commit timingFailure modeUse it when
At-most-onceCommitted 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-onceCommitted after processing succeeds; on failure, redeliveredCrash 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 unitHolds 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.

◆ Key idea

"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:

-- 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 delays

Kafka 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 attached

A 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.

🐢 Timmy's field exercise · 20 min

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.

⚠ Watch out

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.

⚠ Watch out

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.

Same failure, two views — only one of them warns you early Per-message processing latency (p99) — flat, well inside its SLO latency SLO Queue depth / consumer lag (messages) — rising the whole time lag SLO SLO breached here — no alert without this metric same moment: latency panel still looks completely calm

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:

SignalWhere to get itLeading or lagging?
Per-message processing latencyApplication-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 ageSQS'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.

◆ Key idea

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.

◆ Key idea

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.

🎬 At the Reliability Watch
🦫

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.

🐢 Timmy's checkpoint

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
  1. 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.
  2. 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.
  3. 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.
  4. Any two of: idempotency keys checked against a dedup store before applying an effect (e.g. Stripe's Idempotency-Key pattern); 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.
  5. 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.
  6. Raw queue depth/lag (a message count from the broker, e.g. Kafka's LAG column or SQS's ApproximateNumberOfMessagesVisible) and oldest-message age (SQS's built-in ApproximateAgeOfOldestMessage, 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.