On This Page
The 500 that blames a thread I've never heard of
I spent a good twenty minutes convinced this was a Netty bug, because the thread name in the trace wasn't one I'd written anywhere in my own code. It isn't a bug. It's Project Reactor doing exactly what it was built to do. By the time you're reading the stack trace it's already too late to be surprised: the check that produced it runs before your Mono has even had a chance to finish.
Here's what actually shows up in the logs, in full:
java.lang.IllegalStateException: block()/blockFirst()/blockLast() are blocking, which is not supported in thread reactor-http-nio-2
at reactor.core.publisher.BlockingSingleSubscriber.blockingGet(BlockingSingleSubscriber.java:87)
at reactor.core.publisher.Mono.block(Mono.java:1773)
at com.example.repro.GreetingController.greet(GreetingController.java:20)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:568)
... 48 common frames omittedThis happened on Spring Boot 4.1.1, which pins reactor-core 3.8.7 and reactor-netty 1.3.7, running on Java 21. It'll look identical on Boot 3.5.16 (reactor-core 3.7.19). There's no APPLICATION FAILED TO START banner anywhere near this, and there won't be, because nothing failed at startup. The app came up clean and served this exact 500 on the very first request that touched the broken code path. Every other article in this series so far has been a startup-time problem with a FailureAnalyzer banner attached. This is the first one that only shows up once a real request lands, and there's no banner because Boot's diagnostics machinery only runs when SpringApplication itself fails to come up.
I checked the message text back to the release that introduced this check, in 2018. It hasn't changed a single character since. Whatever version you're reading this on, the wording above is what you'll see.
Reactor threw this, not Spring
Look at the top two frames: reactor.core.publisher.BlockingSingleSubscriber and reactor.core.publisher.Mono. That's Project Reactor's own package, several layers below anything Spring WebFlux owns. DispatcherHandler never gets a chance to translate this into something friendlier, and there's no dedicated @ExceptionHandler support for it anywhere in spring-webflux. As far as the framework is concerned, it's a plain IllegalStateException, the same as if you'd written throw new IllegalStateException("nope") by hand. If you've got a broad @ControllerAdvice catching Exception.class, it'll catch this one too, indistinguishable from anything else.
Since nothing failed at startup, --debug and /actuator/conditions won't tell you anything useful here. Those are for a SpringApplication that never got off the ground. The diagnostic that actually matters is sitting right there in the message: the thread name. reactor-http-nio-2 is one of Reactor Netty's own I/O threads, not something you named. If you ever see this exception with a thread name you do recognize (your own @Async executor, a @Scheduled task's pool, plain pool-1-thread-3), you're looking at a different problem, because those threads were never going to trip this guard in the first place.
One thing worth knowing before you go digging: this exception does not mean a thread is stuck. It means the opposite. Reactor refused to let the thread get stuck, and by the time the stack trace prints, that thread is already free and back in the event loop's rotation. Keep that in mind for section 8, because the failure mode that actually does leave a thread stuck looks nothing like this.
It took me longer than I expected, chasing the throw site down through reactor-netty's thread factory and then back out across eight years of tags, to be sure I could say that with a straight face for whatever version you happen to be running. Consider this the fast version of that afternoon.
Get the same trace in about two minutes
What you need: Java 21, Maven 3.9+ (I used the wrapper), and nothing else. No database, no Docker, no message broker.
start.spring.io has been unreachable from where I do this research for eleven runs straight now, so here's the pom.xml by hand, checked against the real starter dependencies at tag v4.1.1:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
</parent>
<groupId>com.example</groupId>
<artifactId>repro</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>spring-boot-starter-webflux-test is new in Boot 4.0. It's what actually pulls in WebTestClient now, separate from the plain spring-boot-starter-test you'd use for an MVC app.
Three classes:
// GreetingService.java
package com.example.repro;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
@Service
public class GreetingService {
public Mono<String> greet(String name) {
return Mono.just("Hello, " + name + "!");
}
}// GreetingController.java
package com.example.repro;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class GreetingController {
private final GreetingService greetingService;
@Autowired
public GreetingController(GreetingService greetingService) {
this.greetingService = greetingService;
}
@GetMapping("/greet")
public String greet(@RequestParam String name) {
return greetingService.greet(name).block(); // this is the whole bug
}
}// ReproApplication.java
package com.example.repro;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ReproApplication {
public static void main(String[] args) {
SpringApplication.run(ReproApplication.class, args);
}
}Step 1: start it
$ ./mvnw spring-boot:run
Expected: starts clean, no banner, no warnings, listening on 8080.
Step 2: hit it
$ curl -i "http://localhost:8080/greet?name=Nick"
Expected: HTTP/1.1 500 Internal Server Error, an empty-ish JSON body,
and the exact trace from section 1 in the server console. It does not
hang or time out. It fails in well under a millisecond.Near-miss discriminator: if you get a plain Hello, Nick! back instead, check the controller's return type. It's probably Mono<String> already, which is the fixed version further down, not this one. If the whole thing hangs and never returns, you've built something that blocks the event loop silently, which is a different (and worse) bug covered in section 8.
Now the part that actually got me the first time. Add this test.
// GreetingServiceTests.java
package com.example.repro;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class GreetingServiceTests {
@Test
void greetBlocksJustFineInAPlainUnitTest() {
GreetingService service = new GreetingService();
String result = service.greet("Nick").block();
assertThat(result).isEqualTo("Hello, Nick!");
}
}Run it. It passes. Green, zero warnings, .block() and everything. A plain JUnit test thread was never marked as one of Reactor's non-blocking threads, so the guard never fires. The exact same line of code, on a different thread, behaves completely differently. The only test that actually reproduces the bug is one that goes through the real server:
// GreetingControllerIT.java
package com.example.repro;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient;
import org.springframework.test.web.reactive.server.WebTestClient;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient
class GreetingControllerIT {
@Autowired
WebTestClient webTestClient;
@Test
void greetEndpointFailsThroughTheRealServer() {
webTestClient.get().uri("/greet?name=Nick")
.exchange()
.expectStatus().is5xxServerError();
}
}@AutoConfigureWebTestClient moved to org.springframework.boot.webtestclient.autoconfigure in Boot 4. Same relocation pattern I ran into with TestRestTemplate a few weeks back, different module.
Teardown: Ctrl-C the process. Nothing was written to disk, no volumes, no leftover ports.
The short version
Somewhere in the call chain for that request, code called .block(), .blockFirst(), .blockLast(), or iterated a .toIterable() / .toStream() while running on one of Reactor Netty's own I/O threads. Reactor refuses, every time, no matter how fast the underlying source would have resolved. It doesn't matter that Mono.just(...) already had its value sitting right there. The refusal happens before that's even checked.
Why Mono.just(...).block() throws too
Mono.block() builds a BlockingMonoSubscriber (a thin wrapper around a CountDownLatch) and calls its blockingGet() method. Here's that method's first move, copied straight from reactor-core 3.8.7:
final T blockingGet() {
if (Schedulers.isInNonBlockingThread()) {
throw new IllegalStateException("block()/blockFirst()/blockLast() are blocking, which is not supported in thread " + Thread.currentThread().getName());
}
if (getCount() != 0) {
// ... actually wait on the latch
}
...
}Read that ordering again. The non-blocking check runs before the method even looks at whether the latch has already counted down. There's no fast path for "the value's already here, so blocking would cost nothing." The thread identity check happens first and unconditionally, so Mono.just("hi").block() throws exactly as reliably as one that waits on a slow network call would.
isInNonBlockingThread() reduces to one line: Thread.currentThread() instanceof NonBlocking. NonBlocking is a public, empty marker interface in reactor.core.scheduler. Its only job is to exist so a Thread subclass can implement it. Reactor Netty's own event-loop thread class does exactly that:
static final class EventLoop extends FastThreadLocalThread implements NonBlocking {
EventLoop(Runnable target) {
super(target);
}
}That's DefaultLoopResources.EventLoop, the class actually backing every reactor-http-nio-N thread you'll ever see. Think of it as a badge sewn into the thread's jacket at creation time, not a guard checking what the thread happens to be doing at the moment you ask. WebFlux's whole performance case rests on never hopping off that thread unless you explicitly ask to. DispatcherHandler invokes your handler method directly on it, no thread pool handoff, which is also exactly why your .block() call runs there in the first place.
Go looking for this and you'll find a real issue filed against Spring Framework asking almost this exact question: someone's WebClient.block() throwing this from inside a reactive chain. It was closed as invalid. Not a bug Reactor intends to fix, because it isn't one.
Here's the part that took me longer to sit with than I expected. The loud exception you're looking at right now is the safe outcome. It's obnoxious, and it costs you a debugging session. But it fails immediately and tells you exactly what happened, with nothing left damaged. Reactor even exposes a public escape hatch on this mechanism, Schedulers.registerNonBlockingThreadPredicate(Predicate<Thread>), so you can extend the same protection to threads Reactor doesn't know about (virtual threads, a custom pool) if you want the same guarantee elsewhere. That's a mechanism actively being made more aggressive, not relaxed.
What has no guard at all is calling ordinary blocking code (a JDBC driver, Thread.sleep(), a blocking third-party client, blocking file I/O) directly inside a .map() or .flatMap() lambda that happens to run on that same event-loop thread. Reactor's check only covers Reactor's own blocking terminal operators. A blocking JDBC call inside a reactive pipeline throws nothing at all. It quietly occupies one of a small, fixed pool of threads (by default, one per CPU core) that every other in-flight request on that server also needs. No stack trace, no 500. Only latency, climbing steadily, until something times out three services downstream and nobody's first instinct is to look at your event loop.
.toIterable() and .toStream() have their own, separately worded version of this same guard, in a different class (BlockingIterable): "Iterating over a toIterable() / toStream() is blocking, which is not supported in thread ...". Different message, identical reasoning, worth recognizing as the same family the first time you see it.
What actually changes
The fix here costs nothing and changes one line, because the entire problem was demanding a value right now on a thread that exists specifically so nothing ever has to wait:
@GetMapping("/greet")
-public String greet(@RequestParam String name) {
- return greetingService.greet(name).block();
+public Mono<String> greet(@RequestParam String name) {
+ return greetingService.greet(name);
}Return the Mono and let the framework subscribe to it when the value's ready. Nothing about the endpoint's behavior changes from the caller's side. The response looks identical over the wire.
That's the fix when you're the one adding .block() gratuitously. It's a genuinely different situation when you have real blocking work (a legacy client with no reactive equivalent) that has to be called from somewhere inside a reactive pipeline. There, .block() still isn't the answer, but neither is pretending the blocking call doesn't exist. Offload it:
@Service
public class RateService {
private final LegacyRateClient legacyRateClient;
public RateService(LegacyRateClient legacyRateClient) {
this.legacyRateClient = legacyRateClient;
}
public Mono<Double> rateFor(String currency) {
return Mono.fromCallable(() -> legacyRateClient.lookup(currency))
.subscribeOn(Schedulers.boundedElastic());
}
}subscribeOn(Schedulers.boundedElastic()) moves the callable's execution onto Reactor's bounded elastic pool (sized around ten times the CPU core count by default, and built to absorb exactly this kind of occasional blocking call), instead of the small, fixed event-loop pool that also has to keep handling every other request on the server. The blocking call still blocks a thread. It no longer competes with the threads your whole server depends on.
Two non-fixes I've watched people reach for that make this worse.
Widening the Netty worker count, hoping more event-loop threads dilutes the problem. It doesn't touch the mechanism. The check is based on thread identity, whether this thread is an instance of NonBlocking, not on how many such threads exist. You'll spend more memory achieving nothing.
Swapping .block() for .toFuture().get() specifically to make the exception go away. It works, in the narrow sense that it compiles and doesn't throw: CompletableFuture.get() is a plain JDK call with no Reactor guard on it. This is the most dangerous non-fix in the article. You've traded a loud, harmless failure for the silent event-loop-starvation risk described above, on purpose, to make a warning sign disappear.
Stop calling .block() and mean it
The rule that actually holds up: nothing invoked by DispatcherHandler (controller methods, WebFilters, exception handlers) ever calls a Reactor terminal blocking operator. The reactive type goes all the way from the repository or client call out to the framework, and the framework does the subscribing. GreetingController above, fixed, plus RateService for the one spot that genuinely needs a blocking bridge, is the whole pattern.
The flip side is worth saying plainly, because I've seen people over-correct into avoiding .block() everywhere out of habit. If you're in a plain Spring MVC app, a @Scheduled batch job, or a CommandLineRunner, none of those run on a Reactor Netty event-loop thread, and .block() there is completely fine. It won't throw this exception, because the guard only cares about which thread you're on. This is a WebFlux-request-path rule, not a universal ban on ever calling .block() in a codebase that happens to use Reactor somewhere.
The bug reactor can't catch for you
This is where BlockHound earns its place in a WebFlux project's test dependencies. It's a Java agent, separate from reactor-core, that instruments known-blocking JDK methods (socket reads, Thread.sleep, file I/O) so calling them from a non-blocking thread throws, the same way .block() already does. Reactor's own integration (ReactorBlockHoundIntegration) tells BlockHound about the same NonBlocking marker used throughout this article, plus a short allow-list of Reactor's own known-safe internal spots. Everything outside that list, your JDBC call, your blocking SDK, gets flagged.
Wire it into a test with:
@BeforeAll
static void installBlockHound() {
BlockHound.install();
}The single most useful test to have here isn't a unit test of the service in isolation. Section 3 already showed that one passing while hiding the actual bug. What you want is an integration test that goes through the real DispatcherHandler, like GreetingControllerIT above, asserting on the response status. That's the only kind of test that runs your handler on an actual event-loop thread, which is the only place this bug exists.
On monitoring: there's no metric called "starved event loop." What you actually watch is p99 latency per route next to CPU utilization on the small, fixed event-loop thread count. If CPU is low but p99 is climbing, something's occupying those threads without doing CPU work, which is the signature of a blocking call hiding in a reactive chain.
On versions: I checked the tag history back to where this guard was introduced. NonBlocking and the blockingGet() check first appear in reactor-core 3.2.0, tagged 2018-09-19 (eight years to the day before I sat down to write this, as it happens). The exact wording of the exception message hasn't changed in a single character since. Boot 3.5.16 pins reactor-core 3.7.19. Boot 4.0 and 4.1 pin the 3.8.x line, and 4.1.1 specifically ships 3.8.7. Both carry the identical check, byte for byte. There's no Boot version in current or recent use where this doesn't apply, and nothing suggests Reactor plans to change it. This isn't a bug waiting on a fix release. It's closer to a fixed rule of how the framework works.
Five things to keep straight
.block(), .blockFirst(), .blockLast(), .toIterable(), and .toStream() all refuse outright on a Reactor Netty event-loop thread, regardless of whether the source has already emitted.
The check runs on thread identity, an instanceof NonBlocking test, before anything about the actual subscription happens, which is why even an already-resolved Mono.just(...) throws.
A plain unit test that constructs your service directly and calls .block() will pass even when the real server-backed request fails. The difference is entirely which thread runs the code, so test through WebTestClient against a running server, not only the service in isolation.
The exception itself is the safe case. Ordinary blocking calls made directly inside a reactive operator have no such guard and fail silently, starving the same small thread pool every request on that server depends on. That's what BlockHound exists to catch.
If you have to bridge real blocking work into a reactive chain, Schedulers.boundedElastic() is where it goes. Never .block(), and never a swap to .toFuture().get() to dodge the warning.
Sources
- reactor/reactor-core:
BlockingSingleSubscriber.java,NonBlocking.java,Schedulers.java,BlockingIterable.java,ReactorBlockHoundIntegration.java, read at tags v3.2.0.RELEASE through v3.8.7. - reactor/reactor-netty:
DefaultLoopResources.java, read at tag v1.3.7. - spring-projects/spring-boot:
platform/spring-boot-dependencies/build.gradle,spring-boot-starter-webflux, and thespring-boot-webtestclientmodule, read at tags v4.1.1 and v3.5.16. - spring-projects/spring-framework issue #22919: closed as invalid, the real-world version of this exact question.
- reactor/BlockHound
- Track A (the Stack Overflow Hot page and the Stack Exchange API) was unreachable again this run, the eleventh consecutive block from this environment's egress policy.
start.spring.ioand Maven Central were also unreachable, so the pom.xml above is hand-written and checked against the real starterbuild.gradlefiles rather than generated live.