Building Reliable Kafka Consumers: Backpressure, Idempotency, Retries, and Failure Recovery

Posted by Martín Peveri on October 24, 2026 · 23 mins read

Building Reliable Kafka Consumers: Backpressure, Idempotency, Retries, and Failure Recovery

I've been working with Kafka lately, exploring its concepts and going deeper into the parts that came up while dealing with some production cases.

On paper, it sounds simple: producers write events, consumers read them, everyone is happy. Then you connect a consumer to PostgreSQL, add a third-party API, deploy a few instances, and start running into lag, retries, duplicate deliveries, and offsets that don't quite line up with your business transactions.

Some of those situations showed up in real systems. Others are areas where I’m keen to keep learning and improving. This article is the result of that process: not a definitive guide, but a collection of patterns and trade-offs that helped me understand what was happening and why.

What happens when consumers can't keep up? What if a downstream API starts timing out? What happens when a consumer updates PostgreSQL successfully but crashes before committing its Kafka offset?

Those aren't rare edge cases.

In this article, we'll go through the patterns I've found most useful: backpressure, idempotency, retries, failure recovery, etc. More importantly, we'll look at when each one makes sense and what trade-offs it brings.

1. Understanding Consumer Lag

Consumer lag is the difference between the end of a partition's log and the consumer group's committed offset. It indicates how far behind the group is, but it does not necessarily reflect how much application processing is still pending.

When producers publish events faster than consumers can process them, the backlog grows. Simple in theory, stressful in practice.

flowchart LR
    P[Producers] --> K[Kafka]
    K --> C[Consumers]
    C --> D[(PostgreSQL)]
    C --> A[Third-party API]
    style K fill:#dcecff,stroke:#3973ac,color:#172b4d
    style D fill:#dff4e4,stroke:#42855b,color:#183d25
    style A fill:#fff0d5,stroke:#c28b27,color:#49330d

Consider a concrete example:

  • Producers publish 10,000 events per second.
  • Consumers can process 8,000 events per second.
  • The difference accumulates as lag.

The first reaction is to add more consumers. It might help, but only if the downstream dependencies have enough capacity to handle the additional load.

If PostgreSQL is already saturated or a third-party API is responding slowly, adding more consumers can increase contention, connection usage, and request failures.

The first step is to identify the bottleneck before changing the architecture.

Before touching anything, I try to look at a few numbers:

  • Consumer lag by consumer group and partition.
  • Event processing latency, including p50, p95, and p99.
  • Database query latency and connection pool saturation.
  • Downstream API latency, error rates, and rate-limit responses.
  • Consumer rebalance frequency and processing failures.
  • Incoming event rate compared with successful processing throughput.

Consumer lag is a symptom, not a root cause. Basically it tells you that something is behind, not why.

2. Backpressure: Controlling the Flow of Work

Backpressure is the mechanism by which a system prevents incoming work from overwhelming its processing capacity. The name sounds fancy, but the idea is simple: if you can't process more, don't accept more.

It's easy to assume that backpressure simply means reducing the number of consumers. In practice, it involves several complementary strategies.

2.1 Limit concurrency and control message intake

A consumer can receive messages faster than it can process them. If hundreds of messages trigger database operations simultaneously, they may exhaust the connection pool, increase latency, and cause timeouts.

There are two complementary ways to control this workload:

  • Limit message intake: Control how many records the consumer receives in each batch. For example, Kafka's consumer provides max.poll.records to limit the number of records returned by each poll() call. The configuration depends on the Kafka client library.
  • Limit concurrency: Control how many messages the application processes simultaneously, using a fixed number of workers or another concurrency control mechanism.

For example, a consumer might receive up to 100 records per poll and process only 10 at a time. The remaining records wait until workers become available.

However, limiting the batch size does not prevent pending work from accumulating if the application continues receiving records faster than it can process them. Keep pending work bounded and use Kafka's partition pause/resume mechanism when processing capacity is exhausted.

Start with conservative limits and adjust them based on throughput, processing latency, and downstream resource usage.

2.2 Pause and resume Kafka partitions

Limiting concurrency prevents too much work from running simultaneously, but a consumer can still accumulate pending messages while its workers are busy.

Kafka supports pausing and resuming assigned partitions, allowing the application to stop receiving more records temporarily when processing capacity is exhausted.

For example, a consumer can pause its partitions when its processing queue reaches a predefined limit and resume them when enough capacity becomes available.

The exact API depends on the Kafka client library. The general flow is:

// Pseudocode: adapt to your Kafka client library.
if (processingQueueIsFull()) {
  pauseAssignedPartitions();
}

if (processingQueueHasCapacity()) {
  resumeAssignedPartitions();
}

Pausing does not stop Kafka from retaining messages in the topic. It temporarily stops delivery from the paused partitions to that consumer.

The consumer must still poll or run its event loop as required by the client library and manage offset commits correctly. Otherwise, it may lose its group membership or trigger a rebalance.

2.3 Apply rate limiting

Concurrency limits control how many operations run simultaneously. Rate limiting controls how many operations can start within a given period.

This matters when a consumer calls an external service with a strict request quota. For example, an API may allow only 500 requests per second. A consumer processing 2,000 messages per second could exceed that quota, even if its concurrency is limited.

A rate limiter can regulate outgoing requests to respect the provider's limit. Algorithms such as token bucket and leaky bucket are commonly used for this purpose.

When multiple consumer instances share the same API quota, the rate limit must account for their combined traffic. Otherwise, each instance may respect its individual limit while the application exceeds the provider's global limit.

2.4 Use batching to improve throughput

So far, the focus has been on controlling how much work the consumer processes and how quickly it sends requests. Another way to improve throughput is to reduce the overhead of repeated operations.

For example, instead of inserting 100 events using 100 separate database round trips, the consumer can collect them and insert them using a single batch operation.

INSERT INTO processed_events (consumer_name, event_id)
VALUES
    ('order-projection', '00000000-0000-0000-0000-000000000001'),
    ('order-projection', '00000000-0000-0000-0000-000000000002'),
    ('order-projection', '00000000-0000-0000-0000-000000000003')
ON CONFLICT DO NOTHING;

This reduces the number of database round trips, although the actual benefit depends on the database, driver, and workload.

Larger batches can improve throughput, but they may also increase processing latency, memory usage, and the amount of work affected by a failure.

Batch size should therefore be tuned against both throughput and latency requirements.

Summary

Each backpressure strategy controls a different aspect of the workload:

Strategy What it controls When to use it
Limit concurrency How many messages are processed simultaneously. When the database or another dependency cannot handle too many concurrent operations.
Control message intake How many messages are returned by each poll() call. When you want to limit how many messages the consumer receives at once.
Pause/resume partitions When to temporarily stop receiving messages. When pending work accumulates or a downstream system becomes overloaded.
Rate limiting How many operations can start within a given period. When an external API enforces a request quota.
Batching How many operations are grouped into a single request. When you want to reduce database round trips and improve throughput.

Not every consumer needs all these strategies. Choose the ones that address your actual bottlenecks and resource constraints.

3. Scaling Consumers Without Overloading Dependencies

Backpressure helps up to a point. When lag keeps growing, the next question is whether you can simply add more consumers.

Scaling Kafka consumers looks easy on paper: add more instances. In practice, you're usually moving the bottleneck somewhere else.

Kafka consumers can scale horizontally, but scaling is constrained by partition count and downstream capacity.

Adding more consumer instances can increase parallelism up to the number of partitions assigned to the group. Beyond that, extra instances may remain idle.

Even before reaching that limit, the database or external API may become the bottleneck.

A practical scaling strategy should:

  1. Measure processing throughput and latency.
  2. Identify the saturated dependency.
  3. Apply concurrency and rate limits where appropriate.
  4. Optimize expensive database operations or external calls.
  5. Increase partitions or consumer instances only when additional parallelism is useful.
  6. Monitor lag and recovery time after traffic peaks.

A temporary increase in consumer lag is not necessarily a problem. If incoming traffic returns to normal and the consumer can process messages faster than new ones arrive, the backlog will eventually decrease.

What matters is how long it takes to catch up and whether messages are processed within the expected time limits.

4. Protecting Downstream Dependencies

Of course, scaling is only half the story. Adding consumers is risky if they all keep hammering a dependency that is already struggling.

Sooner or later, every consumer depends on something that can fail: a database, an HTTP API, a payment provider, another microservice.

When a dependency becomes slow or unavailable, the consumer needs to avoid amplifying the failure.

4.1 Circuit breaker

A circuit breaker monitors failures and temporarily prevents calls to an unhealthy dependency.

It commonly has three states:

  • Closed: requests flow normally.
  • Open: requests are rejected locally without calling the dependency.
  • Half-open: a limited number of requests test whether the dependency has recovered.
stateDiagram-v2
    [*] --> Closed
    Closed --> Open: Failure threshold reached
    Open --> HalfOpen: Recovery timeout
    HalfOpen --> Closed: Probe succeeds
    HalfOpen --> Open: Probe fails

For example, if an external API starts returning repeated 503 Service Unavailable responses, the circuit breaker can open and prevent every Kafka consumer from continuing to send requests.

But a circuit breaker does not decide what happens to the messages that cannot be processed.

The application still needs a policy:

  • Pause consumption and leave events in Kafka.
  • Route eligible failures to a retry mechanism.
  • Persist work for later processing.
  • Send permanently invalid messages to a dead-letter topic.

The right choice depends on whether the event can safely wait, whether ordering matters, and how much retry delay the business can tolerate.

4.2 Respect rate limits and timeouts

A provider may return 429 Too Many Requests and include a Retry-After header.

When available, that information should inform the retry policy. Repeatedly retrying immediately can worsen throttling.

Requests should also have explicit timeouts. Without them, slow calls can occupy threads, connections, and in-flight capacity indefinitely.

4.3 Exponential backoff and jitter

Exponential backoff increases the delay between retry attempts.

A common formula is:

delay = min(maxDelay, baseDelay × 2^attempt)

Jitter introduces randomness into the delay to avoid synchronized retry bursts.

With full jitter:

delay = random(0, min(maxDelay, baseDelay × 2^attempt))

Imagine 1,000 consumers retrying an unavailable API. Without jitter, many of them may retry at nearly the same time. With jitter, retry attempts are spread across a time window.

Backoff reduces retry frequency; jitter reduces synchronization.

Retries should be bounded or governed by an explicit retry policy. They should distinguish transient failures from permanent errors, and they must not bypass idempotency safeguards (we'll get to that in section 6).

5. Delivery Guarantees: At-Most-Once, At-Least-Once, and Exactly-Once

Before we talk about idempotency, it helps to be precise about delivery guarantees.

At-most-once

A message is processed zero or one times. A failure can cause work to be lost.

At-least-once

A message may be processed more than once. To avoid losing work, the consumer must only commit its offset after the required processing has succeeded.

Exactly-once

The intended effect occurs once within a clearly defined scope.

Kafka supports idempotent producers to prevent duplicates caused by producer retries. Kafka transactions can atomically coordinate consumed offsets and records produced to Kafka topics.

However, these guarantees do not automatically extend to arbitrary PostgreSQL transactions, HTTP requests, or external payment providers.

For most consumers that update an external database, at-least-once delivery combined with idempotent processing is a practical reliability strategy.

The objective is not necessarily to prevent every duplicate delivery. It is to make duplicate deliveries safe.

6. Idempotency: Making Duplicate Processing Safe

An operation is idempotent when repeating it produces the same intended result as performing it once.

In distributed systems, duplicate delivery can occur for several reasons:

  • A consumer crashes after updating the database but before committing the Kafka offset.
  • A consumer group rebalance causes previously processed records to be delivered again.
  • A producer retries after an ambiguous network failure.
  • An operator replays historical events.
  • A retry mechanism republishes a message.

The key principle is:

Idempotency must be enforced at the business-effect boundary, not merely at the message transport boundary.

6.1 Use a stable event ID

Every logical event should have a stable identifier, such as event_id.

That identifier must remain the same across retries of the same logical event. Otherwise, duplicate deliveries will look like new events.

A Kafka offset identifies a record within a particular topic partition, but a domain event ID is generally more useful when the same event may be republished or transported through different systems.

A stable event ID can also support application-specific idempotency rules, deduplication, auditing, and tracing an event across services.

6.2 Use a database-enforced uniqueness constraint

When a consumer needs to prevent duplicate database updates, the database should enforce uniqueness rather than relying on an application-level check.

Two concurrent executions could both check whether an event exists, find nothing, and proceed. A unique constraint prevents both from successfully claiming the same event.

For PostgreSQL:

CREATE TABLE processed_events (
    consumer_name TEXT NOT NULL,
    event_id UUID NOT NULL,
    PRIMARY KEY (consumer_name, event_id)
);

Including the consumer name allows different logical consumers to process the same event independently.

The consumer can claim the event using:

INSERT INTO processed_events (consumer_name, event_id)
VALUES ('order-projection', :event_id)
ON CONFLICT DO NOTHING
RETURNING event_id;

If the statement returns an ID, the event was newly claimed. If it returns no row, that consumer has already processed the event.

The claim must be part of the same database transaction as the business update.

6.3 Choose an idempotency strategy

The right approach depends on the business effect being protected and where that effect occurs.

Common strategies include:

  • Database updates: Record the event ID and apply the business update in the same transaction. If the event is a duplicate, skip the update.
  • State transitions: Use conditional updates or business rules to prevent invalid or repeated transitions, such as shipping an order that has already been shipped.
  • External API calls: Use an idempotency key supported by the provider to prevent duplicate operations, such as charging a payment twice.

For database updates, commit the transaction before committing the Kafka offset. If the consumer crashes after the database transaction succeeds but before the offset is committed, Kafka may deliver the event again. The idempotency record allows the consumer to skip the business update.

A database transaction cannot make an external API call atomic with a database update. External side effects require their own safeguards.

The goal is not simply to detect duplicate messages, but to ensure that processing the same logical event multiple times does not repeat its business effects.

7. Failure Recovery: Retries, Poison Messages, and Dead-Letter Queues

Not every failure should be handled in the same way. Some errors are temporary and may disappear on retry. Others are permanent and will never succeed, no matter how many times the consumer tries.

A robust recovery strategy starts by classifying the failure:

  • Transient failures: Temporary network errors or service outages. Retry with backoff and jitter.
  • Throttling: The downstream service is limiting requests. Respect its retry guidance and rate limits.
  • Permanent failures: Invalid payloads, incompatible schemas, or unrecoverable validation errors. Stop retrying and route the message to a dead-letter topic or another explicit failure workflow.
  • Ambiguous outcomes: The downstream operation may have succeeded, but the consumer did not receive confirmation. Verify the outcome or use the provider's idempotency mechanism before retrying.

7.1 Separate retries from normal processing

Retry topics can separate failed messages from the main processing path, allowing them to be retried later without blocking other messages. For example, if a downstream service is temporarily unavailable, a message can be routed to a retry topic and processed again after a delay. The retry mechanism must control when the message becomes eligible for another attempt, since Kafka topics do not provide delayed delivery by themselves.

However, this approach can change event ordering. If a message fails and is moved to a retry topic, later messages may be processed first. If events for the same entity must be processed in order, the retry strategy must preserve that ordering or explicitly handle out-of-order events.

Retries should also be bounded. After a configured number of attempts, a message that continues to fail should be routed to a defined failure path, such as a dead-letter topic, rather than being retried indefinitely.

7.2 Handle poison messages explicitly

A poison message is one that repeatedly fails processing because of its content or another persistent problem.

A dead-letter topic provides a place to store messages that cannot be processed successfully. Include enough context to investigate the failure, such as the original topic, partition, offset, error details, and retry history, while avoiding sensitive data.

A dead-letter topic is not a complete recovery strategy on its own. You also need monitoring, alerting, an investigation process, and a safe way to replay messages after the underlying problem has been fixed.

7.3 Make recovery safe

Before replaying a failed message, determine whether its business effect has already occurred. This is especially important when a downstream API times out: the operation may have succeeded even though the consumer never received the response.

Use the provider's idempotency mechanism or another reliable way to verify the operation's status. If the outcome remains uncertain, keep the operation pending for reconciliation rather than blindly repeating an irreversible action.

Idempotent consumers make retries and replays safer because processing the same logical event again does not repeat its business effects.

7.4 Plan for backlog recovery

After an incident, consumers may return to a large backlog. Restoring the service does not guarantee that the backlog will clear quickly.

Check how much data remains, how quickly consumers are processing it, and whether Kafka retention gives you enough time to catch up. Temporary scaling can help if the downstream systems can handle the extra load.

Measure recovery using consumer lag and the time required to return to normal. If messages need to be replayed, do so deliberately and monitor the operation.

The goal is not to retry every failure. It is to recover from temporary problems, isolate messages that cannot be processed, and make every retry or replay a controlled and observable operation.

8. Final Thoughts

These are some of the things I've learned recently while exploring how to build more reliable Kafka consumers. I hope you find them as useful and interesting as I did.

Building reliable consumers isn't just about processing messages faster. It's about handling backpressure, scaling responsibly, making processing idempotent, and recovering from failures safely.

There's always more to learn, but understanding these fundamentals is a great place to start.


Further Reading