Skip to main content

Command Palette

Search for a command to run...

The One Blocking Call That Broke Our "Non-Blocking" API

Updated
•7 min read•View as Markdown
A
Software Engineer @ Target | Building reliable, high-scale systems with Kotlin, Java, Spring Boot & Kafka

(Part 2 of 2 — I/O Architecture Series)

Quick recap from Part 1: Tomcat gives every request its own thread and blocks it during I/O — simple, but expensive under high concurrency. Netty multiplexes thousands of connections over a small, fixed pool of event-loop threads — scalable, but only if nothing running on those threads ever blocks.

That last condition is where things go wrong in practice. You migrate to WebFlux because you needed better throughput under load. The event-loop model, the non-blocking I/O, the marketing — all of it promised your API would handle far more concurrent requests with far fewer threads.

Six months later, your API is still slow under load. Sometimes slower than before.

The problem usually isn't WebFlux. It's a single blocking JDBC call sitting inside your reactive pipeline, quietly stalling the very event loop that was supposed to save you. This post covers exactly how that happens, plus how Spring MVC, WebFlux, and Micronaut actually put the Tomcat/Netty models from Part 1 to work.

Table of contents

  1. Spring MVC request lifecycle
  2. Spring WebFlux + Netty
  3. Micronaut + Netty + compile-time DI
  4. Why blocking DB/API calls can defeat non-blocking architecture
  5. Performance & when to choose each
  6. TL;DR

Spring MVC request lifecycle

Spring MVC is built on top of a servlet container — Tomcat by default — so it inherits the thread-per-request model directly. A request flows:

Filter chain → DispatcherServlet → HandlerMapping → your @Controller method → ViewResolver/response serialization → back through filters → response

The DispatcherServlet is the front controller that routes each request to the right handler. Your controller method runs synchronously, on the thread Tomcat assigned it, top to bottom. If your service layer calls a repository that blocks on a DB query, that's completely natural here — the thread is already dedicated to this one request, so blocking doesn't create any new problem. This is the important point: Spring MVC's architecture and blocking calls are not in conflict. Blocking is simply how the model works.

Spring WebFlux + Netty

WebFlux is Spring's reactive stack, typically running on Netty (though it can run on Servlet 3.1+ containers in non-blocking mode too). Instead of returning a value directly, your handlers return Mono<T> (0 or 1 result) or Flux<T> (0 to N results) from Project Reactor — declarative, composable pipelines that only execute when subscribed to.

Why does WebFlux exist? Because under high-concurrency, I/O-bound workloads — lots of simultaneous requests, most of them waiting on network calls rather than doing CPU work — the thread-per-request model wastes resources holding threads hostage during I/O waits. WebFlux, on Netty's event loop, can handle that same concurrency with a fraction of the threads.

Here's the catch, and it's the single most important sentence in this series: the benefit only materializes if your entire call chain stays non-blocking. Reactive types, non-blocking HTTP clients (WebClient, not RestTemplate), reactive database drivers — the whole path, end to end. One synchronous, blocking call anywhere in that chain, and you've reintroduced the exact problem WebFlux was meant to solve, except now it's harder to spot. Full breakdown below.

Micronaut + Netty + compile-time DI

Micronaut also runs on Netty, so it shares the event-loop non-blocking foundation with WebFlux. Where it differentiates is dependency injection: Spring's DI is largely reflection-based and resolved at runtime — the framework scans, builds a bean graph, and wires dependencies during application startup. Micronaut instead does compile-time DI, generating the wiring code via annotation processing during the build, ahead of time.

The practical payoff:

  • Faster startup — no runtime classpath scanning or reflection-heavy bean graph resolution
  • Lower memory footprint — no need to hold reflection metadata in memory
  • Better suited to serverless / containerized environments where cold-start time and memory directly cost money

If you're already running Kotlin/Java services on Micronaut with Kafka and reactive patterns, this combination — Netty's event loop plus AOT-compiled DI — is precisely why Micronaut gets chosen over Spring for latency- and resource-sensitive backend services, without giving up the ecosystem familiarity of annotation-driven development.

Why blocking DB/API calls can defeat non-blocking architecture

This is the trap from the intro, in full.

Picture a WebFlux (or Micronaut) handler running on a Netty event loop thread. That thread is responsible for multiplexing, say, a few thousand active connections. Your handler calls a traditional JDBC repository — synchronous, blocking by nature — to fetch a row from Postgres.

That call blocks the thread. Not just for this request — for every other connection multiplexed on that same event loop thread, for the duration of the query. In Tomcat's model, a blocked thread costs you one request's worth of capacity out of a pool of hundreds. In Netty's model, a blocked event-loop thread costs you a slice of everything happening on that thread — potentially dozens or hundreds of in-flight requests, all stalled waiting for one JDBC call to return.

This is why the failure mode is worse, not better, in a reactive stack when this mistake happens. The symptom in production is distinctive: latency percentiles look fine at low load, then fall off a cliff as concurrency rises — not gracefully, but sharply, because you've quietly converted your event loop threads into a much smaller, much more fragile thread pool than Tomcat's ever was.

The blocking-call trap inside an event loop

The fix isn't complicated, but it requires discipline:

  • Use reactive database drivers (R2DBC instead of JDBC) so DB calls themselves are non-blocking
  • Use non-blocking HTTP clients (WebClient, not RestTemplate) for downstream service calls
  • If you genuinely can't avoid a blocking call (a legacy driver, a blocking SDK), explicitly offload it to a bounded, dedicated thread pool (Schedulers.boundedElastic() in Reactor) — never let it run inline on an event loop thread
  • Audit your dependency chain — one blocking library buried three layers deep in a "reactive" call stack is enough to cause this

Performance & when to choose each

There's no universal winner here — only fit for your workload.

Choose Tomcat + Spring MVC when:

  • Your workload is moderate throughput, not extreme concurrency
  • Your team values simplicity and easy debugging (linear stack traces, no reactive operator chains)
  • You're doing typical CRUD services where blocking calls are the norm anyway
  • This describes the large majority of backend services — don't reach for reactive just because it's newer

Choose Netty-based (WebFlux or Micronaut) when:

  • You have genuinely I/O-bound, high-concurrency workloads — many simultaneous connections, most time spent waiting on network I/O, not CPU
  • You can commit to keeping the entire call chain non-blocking — drivers, clients, everything
  • Startup time and memory footprint matter (serverless, high-density containers) — this tilts further toward Micronaut specifically, given compile-time DI

A blunt rule of thumb: if you're not sure whether your workload is I/O-bound enough to need this, it probably isn't. The complexity cost of reactive programming — harder debugging, steeper learning curve, the blocking-call trap above — is real, and it should be justified by an actual concurrency problem, not adopted preemptively.

TL;DR

Stack Thread model Best fit
Tomcat + Spring MVC Thread-per-request Moderate throughput, simplicity, typical CRUD services
WebFlux + Netty Event loop (non-blocking) High-concurrency, I/O-bound workloads — if the whole stack stays non-blocking
Micronaut + Netty Event loop + compile-time DI Same as WebFlux, plus fast startup / low memory (serverless, containers)

The single most important takeaway across both parts of this series: switching to a non-blocking framework doesn't make your code non-blocking. The architecture only pays off if every I/O call in your request path honors it — one blocking JDBC call is enough to quietly undo everything you migrated for.

Missed Part 1? Tomcat vs Netty: The Thread Model Decision Most Engineers Get Backwards covers the I/O fundamentals this post builds on.

Backend I/O Internals

Part 2 of 2

A practical, two-part deep-dive into how Java backend frameworks actually handle I/O under the hood — from raw thread models in Tomcat and Netty, to where Spring MVC, WebFlux, and Micronaut fit, to the one mistake that silently breaks "non-blocking" architectures in production.

Start from the beginning

Tomcat vs Netty: The Thread Model Decision Most Engineers Get Backwards

(Part 1 of 2 - Backend I/O Internals Series) Every Spring Boot service you've ever written sits on top of a decision you probably never made consciously: does your server hand each request its own thr