Diagnosing and Fixing Vert.x Event‑Loop Starvation
When a Vert.x event‑loop stalls, latency spikes, timeouts, and "Thread blocked for … ms" logs appear. This guide walks you through spotting the symptoms, running a step‑by‑step diagnosis, fixing common blocking patterns, and knowing when to raise the issue.
16 Jul 2025, 13:42 UTC

Recognizing Event‑Loop Starvation
In a Vert.x application you’ll notice a sudden, step‑wise increase in request latency, timeouts, or a spike in the vert.x-eventloop-thread-* stack traces. The log line Thread blocked for … ms is a clear indicator that the event‑loop is being held up. The thread pool may be busy while CPU usage is low, and throughput collapses.
Symptom Checklist
- Latency p99 climbs by 200–300 ms in a single step.
- Requests start timing out on a previously healthy endpoint.
- Logs contain
Thread blocked for … msfrom the Vert.x checker. - CPU usage is < 10 % while request rate is high.
- Throughput drops dramatically; error rates rise.
Diagnostic Workflow
- Enable the Blocked‑Thread Checker
ConfigureVertxOptionsin yourmainorvertx.conf:
This forces a warning if any event‑loop thread runs longer than 500 ms.VertxOptions options = new VertxOptions() .setBlockedThreadCheckInterval(1000) // ms .setEventLoopExecuteTime(500); // ms Vertx vertx = Vertx.vertx(options); - Collect Metrics
During a load test that mimics production traffic, record:- p99/p999 latency
- blocked thread count (via
vertx.metricsor JMX) - CPU and I/O wait from
jstatorsar
- Take Thread Dumps
When a stall is observed, runjstack -l <pid> > dump.txtseveral times. Search forvert.x-eventloop-thread-*frames. The frames should point to your blocking library (e.g.,java.sql.DriverManager.getConnectionorjava.net.SocketInputStream.read). - Inspect GC and CPU
Check the GC logs for long pauses. If GC pauses dominate the event‑loop time, the issue may be memory pressure rather than code. Use-XX:+PrintGCDetailsand-XX:+PrintGCDateStamps. - Trace the Blocking Call Path
Map the request flow from the HTTP handler to the library call that appears in the thread dump. Verify that the call is synchronous and that the library has no async API.
Common Causes & Fixes
| Cause | Diagnostic Indicator | Fix |
|---|---|---|
| Synchronous JDBC/HTTP/HTTP‑DNS calls | Thread dump shows java.sql or java.net frames | Use Vert.x AsyncHttpClient or vertx-jdbc-client; move to executeBlocking or a worker verticle. |
| Large JSON or payload parsing | Frames in com.fasterxml.jackson or org.json | Stream the body with HttpServerRequest.handler() or use JsonObject streaming; cap body size with maxBodySize. |
| Cache load on first access | Frames in java.util.concurrent.ConcurrentHashMap or custom loader | Pre‑warm caches; load asynchronously with a timeout; use a bounded thread pool. |
| Long GC pauses | GC logs show > 200 ms pauses; CPU low but event‑loop blocked | Increase heap, tune GC options, or reduce object churn. |
| Saturated event‑loop pool (all threads busy) | All vert.x-eventloop-thread-* show blocked frames | Remove blocking calls first; only then adjust eventLoopPoolSize if needed. |
Example: Moving a JDBC Call to a Worker Verticle
// Handler that was blocking
router.route("/data").handler(routingContext -> {
Connection conn = DriverManager.getConnection(url, user, pass); // blocks
ResultSet rs = conn.prepareStatement("SELECT * FROM table").executeQuery();
// ... build response
});
// Refactored using a worker verticle
router.route("/data").handler(routingContext -> {
vertx.executeBlocking(future -> {
try (Connection conn = DriverManager.getConnection(url, user, pass)) {
ResultSet rs = conn.prepareStatement("SELECT * FROM table").executeQuery();
// build result
future.complete(result);
} catch (SQLException e) {
future.fail(e);
}
}, res -> {
if (res.succeeded()) {
routingContext.response().end((String) res.result());
} else {
routingContext.fail(res.cause());
}
});
});
Example: Using Vert.x Async HTTP Client
HttpClient client = vertx.createHttpClient();
client.getAbs("https://api.example.com/data").send(ar -> {
if (ar.succeeded()) {
HttpClientResponse resp = ar.result();
resp.bodyHandler(body -> routingContext.response().end(body.toString()));
} else {
routingContext.fail(ar.cause());
}
});
When to Escalate
- Blocked‑thread warnings persist after all blocking calls have been removed.
- Stalls correlate with GC or OS metrics rather than application frames.
- Clustered event bus or HA failover is implicated (e.g.,
vertx.clusteredClientstalls). - Fix requires a Vert.x or library upgrade that cannot be tested locally.
Verification Checklist
- Re‑run the load test with the checker enabled.
- Confirm that p99 latency returns to baseline and no blocked‑thread warnings appear.
- Validate that the event‑loop thread dump no longer shows the offending frames.
- Check that GC pauses are within acceptable limits (< 100 ms).
- Ensure that the worker pool usage stays below 80 % to avoid exhaustion.
Version Sensitivity
Defaults for blockedThreadCheckInterval, eventLoopExecuteTime, and worker semantics differ between Vert.x 4.x and 5.x. Verify the VertxOptions values for your pinned version and adjust thresholds accordingly. Virtual threads in Vert.x 5.x offer a different trade‑off; they still block the event‑loop if used directly, so keep the checker enabled.
Limitations & Caveats
- Disabling the checker or raising thresholds in production hides the root cause and can mask regressions.
- Wrapping every handler in
executeBlockingis a quick fix but can exhaust the worker pool and break ordering guarantees. - Thread dumps and profilers can alter timing; take multiple samples and compare to a baseline.
- Increasing
eventLoopPoolSizerarely resolves starvation; it often reduces throughput due to context switching.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.