> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# block()/blockFirst()/blockLast() Are Blocking — Here's Why
- URL: https://www.ggorantala.dev/spring-boot-webflux-block-are-blocking-not-supported/
- Published: 2026-10-02T05:29:43.000Z
- Updated: 2026-10-02T05:30:31.000Z
- Description: WebClient.block() throws 'are blocking, which is not supported' and it looks like a Netty bug. It isn't. Here's the real mechanism, and the failure it's hiding.
- Author: Gopi Gorantala
- Tags: spring-boot, spring-webflux, reactor-core, project-reactor, reactive-programming, IllegalStateException, blockhound

## 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:

```log
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 omitted
```

This 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
<?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:

```java
// 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 + "!");
    }
}
```

```java
// 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
    }
}
```

```java
// 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);
    }
}
```

```log
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.

```java
// 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:

```java
// 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:

```java
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:

```java
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](https://github.com/spring-projects/spring-framework/issues/22919) 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:

```diff
 @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:

```java
@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, `WebFilter`s, 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](https://github.com/reactor/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:

```java
@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](https://github.com/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](https://github.com/reactor/reactor-netty): `DefaultLoopResources.java`, read at tag v1.3.7.
- [spring-projects/spring-boot](https://github.com/spring-projects/spring-boot): `platform/spring-boot-dependencies/build.gradle`, `spring-boot-starter-webflux`, and the `spring-boot-webtestclient` module, read at tags v4.1.1 and v3.5.16.
- [spring-projects/spring-framework issue #22919](https://github.com/spring-projects/spring-framework/issues/22919): closed as invalid, the real-world version of this exact question.
- [reactor/BlockHound](https://github.com/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.io` and Maven Central were also unreachable, so the pom.xml above is hand-written and checked against the real starter `build.gradle` files rather than generated live.