skip to content

The reactive gateway runs on a small Netty event-loop thread pool. What are the consequences of blocking inside a filter, and how do you handle work that must block?

level: seniorimportance: must knowfreq 45%

answer

  1. event loop = ~1 thread/core, must never block
  2. one blocking call parks a whole loop thread
  3. few blocks -> starve loop -> cliff-edge stall for all
  4. fix: WebClient/R2DBC; else subscribeOn(boundedElastic())
  5. never .block(); BlockHound catches it in tests

basics

~20 s

Netty has only a few event-loop threads (about one per core). If a filter blocks — a JDBC call, Thread.sleep, a blocking HTTP client — it parks one of those threads, so many requests stall at once. Offload blocking work to a bounded elastic scheduler, or avoid it.

solid answer

~50 s

The reactive gateway processes all requests on Netty's event loop — a fixed pool of roughly one thread per CPU core. These threads are meant to never block: they hop between many requests as I/O events fire. If a filter performs a blocking operation (blocking JDBC, `Thread.sleep`, a blocking `RestTemplate`/HTTP client, synchronous disk), it monopolizes one event-loop thread for the whole wait. With only a handful of threads, a few concurrent blocks can starve the loop and freeze throughput for *every* request on it — a cliff-edge failure, not gradual degradation. Fixes, in order of preference: use non-blocking clients (`WebClient`, reactor-netty, R2DBC); if a blocking library is unavoidable, offload it with `Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic())` so it runs on a separate elastic pool, then resume on the event loop. Never call `.block()` in a filter. Reactor's BlockHound can detect accidental blocking in tests.

code

java · 31 lines
java
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;

@Component
public class LegacyAuthFilter implements GlobalFilter {

    private final BlockingTokenSdk sdk; // third-party, blocks internally

    public LegacyAuthFilter(BlockingTokenSdk sdk) {
        this.sdk = sdk;
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String token = exchange.getRequest().getHeaders().getFirst("Authorization");

        // WRONG: sdk.validate(token) here would block an event-loop thread.
        // RIGHT: offload the blocking call to the bounded-elastic scheduler.
        return Mono.fromCallable(() -> sdk.validate(token))
            .subscribeOn(Schedulers.boundedElastic())
            .flatMap(valid -> valid
                ? chain.filter(exchange)
                : Mono.error(new SecurityException("invalid token")));
    }

    interface BlockingTokenSdk { boolean validate(String token); }
}

go deeper

for a junior

Should at least know you must not block the reactive threads.

for a middle

Should explain the small event-loop pool and that blocking one thread hurts many requests.

for a senior

Should give the cliff-edge starvation reasoning and the subscribeOn(boundedElastic) offload plus non-blocking-client preference.

for a principal

Should discuss BlockHound, hidden library blocking, CPU-bound work, context propagation, and when heavy blocking means choosing R2DBC or the MVC gateway instead.

## The event-loop model Netty (via reactor-netty) runs on an **event loop**: a small, fixed pool of threads, by default about `Runtime.getRuntime().availableProcessors()` (one per core, sometimes ×2). Each thread runs a loop that reacts to socket-readiness events for *many* connections. The entire premise is that a thread never sits idle waiting — it processes a chunk of work for request A, then request B, then C, cycling as events arrive. This is what lets a few threads carry huge concurrency. ## Why blocking is catastrophic here The model only works if **no task blocks**. A blocking call parks the *whole* thread until it returns: - Blocking JDBC (`DataSource`/JPA), `Thread.sleep`, `Object.wait`, blocking `RestTemplate`, blocking file I/O, `future.get()`, or `mono.block()`. With ~8 event-loop threads, 8 concurrent blocking calls occupy *all* of them. Every other in-flight request — hundreds or thousands — is now stalled with **zero** threads to advance it. Throughput doesn't degrade gracefully; it falls off a cliff. On the servlet stack a blocking call just holds one of hundreds/thousands of worker threads; on the event loop it can take down the whole server. This asymmetry is the single most important operational gotcha of the reactive gateway. ## How to handle work that must run **Preference 1 — use non-blocking equivalents:** - HTTP: `WebClient` / reactor-netty `HttpClient` instead of `RestTemplate`. - DB: **R2DBC** (reactive drivers) instead of JDBC. - Redis/messaging: reactive clients (Lettuce reactive, reactive Kafka). **Preference 2 — offload unavoidable blocking to a separate scheduler:** ```java return Mono.fromCallable(() -> legacyBlockingCall()) // the blocking work .subscribeOn(Schedulers.boundedElastic()) // runs on a bounded elastic pool, NOT the event loop .flatMap(result -> chain.filter(exchange)); // resumes on the reactive chain ``` `Schedulers.boundedElastic()` is Reactor's pool for blocking/long-running tasks: it creates worker threads on demand up to a cap and reuses them, isolating blocking work so it can't starve the event loop. `subscribeOn` moves the subscription (and thus the blocking `fromCallable`) onto that pool. **Never do `.block()`** in a filter — it blocks the calling thread (often an event-loop thread) and is exactly the failure above. It also throws in some reactor-netty contexts precisely to prevent this. ## Detecting accidental blocking - **Reactor BlockHound:** an agent that instruments the JVM to throw when a blocking call happens on a non-blocking thread. Wire it into tests to catch regressions early. - Watch for latency that spikes non-linearly under load, or thread dumps showing event-loop threads parked in JDBC/socket reads. ## Gotchas - **Hidden blocking in libraries:** many 'convenient' libraries block internally (some auth/JWT libs, DNS lookups, logging appenders with blocking I/O, metrics exporters). Audit anything called in the hot path. - **CPU-heavy work also hurts:** a filter doing heavy CPU (large crypto, big JSON transform) monopolizes the loop just like blocking; offload to `boundedElastic` or `parallel` scheduler if significant. - **boundedElastic isn't a free pass:** it's bounded — flooding it with blocking work still queues and adds latency; it just protects the event loop. Heavy blocking workloads may mean the reactive gateway is the wrong tool (or use the MVC gateway variant). - **Logging/tracing MDC:** context can be lost across scheduler hops; use Reactor context propagation. ## When to worry Any custom `GlobalFilter`/`GatewayFilter` that touches a database, calls a blocking client, does synchronous file/network I/O, or heavy CPU. Plain routing with predicates/header filters is safe.

  • Why is blocking on the event loop worse than blocking on the servlet stack?
    The servlet stack has a large worker pool (hundreds/thousands), so one blocked thread costs one request. The event loop has ~one thread per core, so a handful of blocking calls occupy every thread and stall all in-flight requests at once — a cliff-edge collapse rather than a linear cost.
  • You must call a legacy blocking SDK in a filter. What do you do?
    Wrap it in `Mono.fromCallable(() -> sdk.call())` and `.subscribeOn(Schedulers.boundedElastic())` so it runs on Reactor's bounded elastic pool instead of the event loop, then compose the result back into the chain. Prefer a non-blocking client if one exists, and never use `.block()`.

saying these in an interview costs you the question

  • Saying a blocking JDBC call in a filter is fine because it's just one request
  • Using .block() inside a GlobalFilter to 'simplify' async code
  • Thinking Schedulers.boundedElastic() makes blocking free with no downside
  • Assuming only I/O blocks — ignoring heavy CPU work also monopolizing the loop

context