Skip to main content
spring internals

Blocking Detection and Safe Offloading

7 min read Chapter 48 of 78

The Abstraction

BlockHound.install();

One line. It rewrites bytecode at runtime to intercept every blocking call in the JDK. When a blocking call executes on a thread marked as non-blocking, BlockHound throws an error with the exact call site. This is the most important line of code in any WebFlux application.

The Mechanism: How BlockHound Works

BlockHound installs a Java agent (via ByteBuddy) that instruments blocking methods in the JDK: Thread.sleep(), InputStream.read(), Socket.connect(), Object.wait(), ReentrantLock.lock(), and dozens more. For each instrumented method, BlockHound injects a check:

Is the current thread a non-blocking thread?
  Yes → throw BlockingOperationError
  No  → proceed normally

Reactor marks its event loop threads (reactor-http-nio-*) and parallel scheduler threads (parallel-*) as non-blocking. Any blocking call on these threads is a bug.

Installation

For tests:

<dependency>
    <groupId>io.projectreactor.tools</groupId>
    <artifactId>blockhound</artifactId>
    <version>1.0.9.RELEASE</version>
    <scope>test</scope>
</dependency>
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class NotificationControllerBlockingTest {

    @BeforeAll
    static void setup() {
        BlockHound.install();
    }

    @Autowired
    WebTestClient webTestClient;

    @Test
    void notificationEndpoint_mustNotBlock() {
        webTestClient.get()
            .uri("/api/tenants/acme/notifications")
            .exchange()
            .expectStatus().isOk()
            .expectBodyList(Notification.class)
            .hasSize(50);
        // If any blocking call happens on reactor-http-nio-*,
        // BlockHound throws BlockingOperationError and this test fails.
    }
}

For development (not production, due to performance overhead):

public static void main(String[] args) {
    BlockHound.install();
    SpringApplication.run(SaasApplication.class, args);
}

Common Blocking Sources in the SaaS Backend

Every one of these blocks the event loop thread:

JDBC (HikariCP, JdbcTemplate):

// Blocks on java.net.SocketInputStream.read()
jdbcTemplate.query("SELECT ...", rowMapper);

File I/O:

// Blocks on java.io.FileInputStream.read()
byte[] content = Files.readAllBytes(Path.of("/tenant-uploads/" + fileId));

Synchronized blocks:

// Blocks on monitor enter if contended
synchronized (tenantCache) {
    tenantCache.put(tenantId, config);
}

Thread.sleep():

// Blocks the event loop thread for the full duration
Thread.sleep(1000); // retry delay

External HTTP with blocking clients:

// RestTemplate blocks on socket I/O
restTemplate.getForObject("https://payment-service/api/charge", PaymentResult.class);

Logging with synchronous appenders:

// Logback's default appender blocks on file I/O
log.info("Processing notification for tenant {}", tenantId);
// Fix: use AsyncAppender in logback-spring.xml

BlockHound catches all of these. The stack trace tells you the exact location.

The Debuggable Demonstration: Schedulers.boundedElastic()

The SaaS backend has a legacy reporting module that uses JDBC. Migrating to R2DBC is planned but not done. The report endpoint must work today.

// BROKEN: subscribeOn at the top of the pipeline
@GetMapping("/api/tenants/{tenantId}/report")
public Mono<Report> generateReport(@PathVariable String tenantId) {
    return Mono.defer(() -> {
            TenantContext.setCurrentTenant(tenantId);  // ThreadLocal
            Report report = reportService.generate(tenantId);  // JDBC inside
            return Mono.just(report);
        })
        .subscribeOn(Schedulers.boundedElastic());  // Moves EVERYTHING to elastic pool
}

This “works.” BlockHound does not complain because the blocking call runs on a boundedElastic thread, not the event loop. But there is a problem: subscribeOn affects the entire subscription. The subscription signal travels upstream, and all operators from the subscription point onward execute on the elastic thread. The response serialization, the header writing, the connection flush, everything runs on the elastic pool instead of the event loop.

You have recreated thread-per-request. With a pool of 10 * cores = 80 threads instead of Tomcat’s 200. That is worse.

publishOn vs subscribeOn

These two operators control which Scheduler (thread pool) executes operators, but they work in opposite directions:

subscribeOn(Scheduler): Affects where the subscription happens. The subscription signal flows upstream (from subscriber to publisher). subscribeOn placed anywhere in the chain moves the entire upstream execution to the specified scheduler. Placing it at the top or the bottom has the same effect.

// subscribeOn affects the ENTIRE chain
source
    .map(x -> transform(x))         // runs on boundedElastic
    .filter(x -> x.isValid())       // runs on boundedElastic
    .subscribeOn(Schedulers.boundedElastic())
    .map(x -> format(x));           // runs on boundedElastic

publishOn(Scheduler): Affects where downstream operators execute. It is a thread-switching operator. Everything above publishOn runs on the original thread. Everything below runs on the specified scheduler.

// publishOn affects only DOWNSTREAM operators
source                                          // runs on event loop
    .map(x -> transform(x))                     // runs on event loop
    .publishOn(Schedulers.boundedElastic())
    .map(x -> blockingCall(x))                   // runs on boundedElastic
    .publishOn(Schedulers.parallel())
    .map(x -> format(x));                        // runs on parallel scheduler

publishOn gives you surgical control. You switch to the elastic pool for exactly the blocking call, then switch back.

The Failure Mode

// BROKEN: subscribeOn at the top moves everything off the event loop
@GetMapping("/api/tenants/{tenantId}/dashboard")
public Mono<Dashboard> getDashboard(@PathVariable String tenantId) {
    return tenantService.getConfig(tenantId)               // reactive, R2DBC
        .flatMap(config -> {
            return notificationService.getRecent(tenantId) // reactive, R2DBC
                .collectList()
                .map(notifications -> buildDashboard(config, notifications));
        })
        .subscribeOn(Schedulers.boundedElastic());
    // ALL of the above runs on boundedElastic threads.
    // The R2DBC calls, which are non-blocking, now waste elastic threads.
    // The event loop threads sit idle.
    // Under load, the elastic pool (80 threads) saturates just like Tomcat.
}

The reactive R2DBC calls do not need the elastic pool. They are non-blocking. Putting them on boundedElastic wastes threads and reintroduces the concurrency ceiling.

The Correct Pattern

// CORRECT: publishOn only around the blocking call, then return to parallel
@GetMapping("/api/tenants/{tenantId}/report")
public Mono<Report> generateReport(@PathVariable String tenantId) {
    return tenantService.getConfig(tenantId)                // event loop thread (non-blocking)
        .publishOn(Schedulers.boundedElastic())             // switch to elastic
        .map(config -> {
            // This blocking call runs on boundedElastic-*
            return reportService.generateLegacyReport(config);
        })
        .publishOn(Schedulers.parallel())                   // switch back to non-blocking
        .map(report -> {
            // Response serialization runs on parallel-*, not elastic
            return enrichReport(report);
        });
}

The reactive getConfig call runs on the event loop. The blocking generateLegacyReport runs on the elastic pool. The response enrichment runs on the parallel scheduler. Each section runs on the appropriate thread type.

Reactor Context for Tenant Propagation

ThreadLocal does not work in reactive pipelines. A single request touches multiple threads as it crosses publishOn boundaries. The tenant ID stored in a ThreadLocal on the event loop thread is not visible on the boundedElastic thread.

Reactor provides Context, an immutable key-value store attached to the subscription, not the thread:

// BROKEN: ThreadLocal tenant context
@GetMapping("/api/tenants/{tenantId}/report")
public Mono<Report> generateReport(@PathVariable String tenantId) {
    TenantContext.setCurrentTenant(tenantId);  // ThreadLocal
    return reportService.generate()
        .publishOn(Schedulers.boundedElastic())
        .map(data -> {
            // TenantContext.getCurrentTenant() returns null here.
            // Different thread. ThreadLocal is empty.
            String tenant = TenantContext.getCurrentTenant();  // null
            return buildReport(tenant, data);
        });
}
// CORRECT: Reactor Context for tenant propagation
@GetMapping("/api/tenants/{tenantId}/report")
public Mono<Report> generateReport(@PathVariable String tenantId) {
    return reportService.generate()
        .publishOn(Schedulers.boundedElastic())
        .flatMap(data -> Mono.deferContextual(ctx -> {
            String tenant = ctx.get("tenantId");  // Available on any thread
            return Mono.just(buildReport(tenant, data));
        }))
        .contextWrite(Context.of("tenantId", tenantId));  // Attach at subscription time
}

contextWrite attaches data to the reactive subscription. deferContextual reads it. The context flows with the signal, not the thread. It is available regardless of which scheduler is executing the operator.

For the SaaS backend, wrap this in a WebFilter so every request has the tenant context:

@Component
public class TenantContextFilter implements WebFilter {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        String tenantId = extractTenantId(exchange);
        return chain.filter(exchange)
            .contextWrite(Context.of("tenantId", tenantId));
    }

    private String extractTenantId(ServerWebExchange exchange) {
        // Extract from path, header, or JWT claim
        return exchange.getRequest().getHeaders().getFirst("X-Tenant-ID");
    }
}

Every downstream operator in any controller, service, or repository can access the tenant ID through Mono.deferContextual() without ThreadLocal. This works across publishOn boundaries, across flatMap chains, across Schedulers.boundedElastic() offloading.

Three rules for blocking in reactive pipelines:

  1. Detect: Install BlockHound in every test suite. Run it in development.
  2. Offload: Use publishOn(Schedulers.boundedElastic()) around the blocking call. Not subscribeOn. Not at the top of the chain.
  3. Propagate context: Use Reactor Context, not ThreadLocal. Attach tenant context in a WebFilter with contextWrite. Read it with deferContextual.

The elastic pool is a bridge, not a destination. Every blocking call offloaded to it is technical debt. Track them. Replace JDBC with R2DBC. Replace RestTemplate with WebClient. Replace Files.readAllBytes() with DataBufferUtils.read(). The goal is zero calls to Schedulers.boundedElastic().