Blocking Detection and Safe Offloading
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:
- Detect: Install BlockHound in every test suite. Run it in development.
- Offload: Use
publishOn(Schedulers.boundedElastic())around the blocking call. NotsubscribeOn. Not at the top of the chain. - Propagate context: Use Reactor
Context, notThreadLocal. Attach tenant context in aWebFilterwithcontextWrite. Read it withdeferContextual.
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().