Field note / spring-webflux

publishedupdated 2026-05-01#spring-webflux#backpressure#reactor#overflow#slow-subscriber

Backpressure in Reactive Systems

How backpressure works end-to-end — from HTTP to database, overflow strategies (drop/buffer/error), slow subscriber patterns, and testing backpressure.

Overview

Backpressure is the mechanism by which consumers signal producers to slow down. Without it, a fast producer overwhelms a slow consumer — buffers fill, memory exhausts, or items are silently dropped. In Project Reactor, backpressure flows via the request(n) signal from Subscriber to Publisher. In practice, most pipelines don't manually manage backpressure — but understanding what happens when you violate it (system crashes, dropped events, OOM) is essential for production reactive systems.


Architecture

BACKPRESSURE IN A REQUEST PIPELINE
════════════════════════════════════════════════════════

  HTTP Client  →  WebFlux Handler  →  DB (R2DBC)  →  WebFlux Response
       │                │                  │               │
  sends data      requests 10 items   fetches 10 rows   writes 10 items
  at 1000 req/s   at a time           at a time          at 10/s
       │                │                  │               │
       │          ← request(10) ──────────────────────────┘
       │                │
  HTTP buffers     requests(10) to DB
  (TCP receive     processes 10
   window fills)   ← request(10) when done

  End-to-end: TCP flow control → Reactor request(n) → DB cursor fetch size

OVERFLOW STRATEGIES (when producer ignores backpressure)
════════════════════════════════════════════════════════

  onBackpressureBuffer(n)   → buffer up to n items in memory, error on overflow
  onBackpressureBuffer(∞)   → unbounded buffer (OOM risk!)
  onBackpressureDrop(fn)    → discard newest items when buffer full
  onBackpressureLatest()    → keep only latest item, drop intermediate
  onBackpressureError()     → emit OverflowException immediately (fast-fail)

  USE CASE MAPPING:
  Metrics / telemetry:  onBackpressureDrop  (ok to lose some data points)
  Event log:            onBackpressureBuffer(bounded)  (must not lose)
  Real-time price feed: onBackpressureLatest  (stale prices useless)
  Critical payments:    onBackpressureError  (fail fast, don't buffer money)

HOT SOURCE WITH SLOW SUBSCRIBER
════════════════════════════════════════════════════════

  Kafka consumer:
  Partition → Reactor Kafka (hot)
  ├── records arrive at broker rate
  ├── subscriber processes at business logic rate
  └── Flow: reactor-kafka applies backpressure to Kafka polling
             (stops calling consumer.poll() when downstream can't keep up)
             (Kafka offset not committed until item processed)

Technical Implementation

Overflow Strategies for Different Use Cases

@Service
public class MetricsProcessor {

  // Telemetry: ok to drop data under load — system self-heals
  public Flux<Metric> processMetrics(Flux<Metric> source) {
    return source
        .onBackpressureDrop(dropped ->
            log.warn("Dropping metric under backpressure: {}", dropped.getName())
        )
        .flatMap(
            metric -> enrichMetric(metric),
            16  // max 16 concurrent enrichments
        );
  }

  // Audit log: must not lose events — buffer bounded, fail on overflow
  public Flux<AuditEvent> processAuditEvents(Flux<AuditEvent> source) {
    return source
        .onBackpressureBuffer(
            1000,  // buffer up to 1000 events
            dropped -> {
              log.error("Audit event dropped! THIS IS A BUG: {}", dropped);
              alertingService.sendAlert("audit-event-dropped");
            },
            BufferOverflowStrategy.DROP_OLDEST  // or ERROR to fail fast
        )
        .flatMap(event -> auditRepo.save(event));
  }

  // Real-time dashboard: only care about latest state
  public Flux<PriceUpdate> streamLatestPrices(Flux<PriceUpdate> source) {
    return source
        .onBackpressureLatest()  // if slow, only process most recent price
        .distinctUntilChanged(PriceUpdate::price)  // skip if price unchanged
        .doOnNext(update -> webSocketBroadcast(update));
  }
}

Controlling Concurrency to Manage Backpressure

// Problem: flatMap with no concurrency limit overwhelms downstream
// Solution: use flatMap's concurrency parameter OR limitRate

public Flux<EnrichedOrder> processOrders(Flux<String> orderIds) {
  return orderIds
      .limitRate(50)              // request 50 items at a time from upstream
      .flatMap(
          id -> fetchAndEnrich(id),
          20,                     // max 20 concurrent downstream calls
          5                       // prefetch: pre-request 5 more when 75% done
      )
      .subscribeOn(Schedulers.boundedElastic());
}

// Reactor Kafka: backpressure from business logic → poll throttle
// Only 100 records in flight at any time
@Bean
public Flux<ReceiverRecord<String, Order>> kafkaOrderStream(ReactiveKafkaConsumerTemplate<String, Order> template) {
  return template.receive()
      .flatMap(
          record -> processRecord(record)
              .doOnSuccess(v -> record.receiverOffset().acknowledge()),
          100,   // max 100 concurrent records being processed
          1      // prefetch 1 batch
      );
}

Testing Backpressure with StepVerifier

@Test
void shouldHandleBackpressureFromSlowSubscriber() {
  // Publisher produces 10 items, subscriber requests only 1 at a time
  Flux<Integer> source = Flux.range(1, 10)
      .log("source");  // log() shows request(n) signals in output

  StepVerifier.create(source, 1)  // initial request = 1
      .expectNext(1)
      .thenRequest(2)              // request 2 more
      .expectNext(2)
      .expectNext(3)
      .thenRequest(Long.MAX_VALUE) // request everything remaining
      .expectNextCount(7)
      .verifyComplete();
}

@Test
void shouldDropWhenOverflowing() {
  AtomicInteger droppedCount = new AtomicInteger();

  Flux<Integer> source = Flux.range(1, 100)
      .onBackpressureDrop(d -> droppedCount.incrementAndGet());

  // Subscriber requests only 10 — 90 items should be dropped
  StepVerifier.create(source, 10)
      .expectNextCount(10)
      .thenCancel()
      .verify();

  assertThat(droppedCount.get()).isGreaterThan(0);
}

Interview Preparation

Q: What happens if you subscribe to a hot Flux without any backpressure handling?

The hot publisher emits regardless of how fast the subscriber processes. If the subscriber is slower, Project Reactor's default behavior is to throw reactor.core.Exceptions$OverflowException: Could not emit tick N due to lack of requests — this is the "fail fast on overflow" behavior. The subscription terminates with an error. To handle this gracefully, add an explicit overflow strategy before the slow subscriber: onBackpressureBuffer, onBackpressureDrop, or onBackpressureLatest depending on your use case. The choice reflects your data contract: can you lose data? Do you need the latest? Must you preserve all?

Q: How does backpressure work end-to-end from an HTTP client through to the database?

At the HTTP layer, Spring WebFlux/Netty reads request data into a buffer and triggers request(n) to the upstream handler only when the downstream is ready. The handler uses reactive repositories (R2DBC), which in turn request rows from the database cursor only as fast as the downstream can consume them. The R2DBC driver keeps a fetch size (similar to JDBC setFetchSize) and fetches the next batch from the database only when the reactive chain requests more items. For HTTP responses, Netty's write buffer signals when the client's TCP receive window is full — Spring WebFlux pauses emission until the network can absorb more data. This creates an end-to-end pressure signal from the HTTP client all the way back to the database cursor.

Q: When would you choose onBackpressureDrop over onBackpressureBuffer?

Use onBackpressureDrop when losing some data is acceptable and the data is time-sensitive (new data makes old data obsolete). Examples: metrics/telemetry (a missing data point doesn't break the system), real-time event streams where stale events have no value, or high-frequency sensor data where only recent readings matter. Use onBackpressureBuffer when data loss is unacceptable (audit logs, financial transactions, order events) and you need a cushion to absorb bursts. Size the buffer based on expected burst magnitude vs sustained processing rate. Always monitor buffer size in production — a continuously full buffer means you're consistently producing faster than consuming, which requires scaling.


Learning Resources