Where to Run Blocking Work in Vert.x: executeBlocking vs Worker Verticles
Vert.x gives you two supported places to run blocking code: executeBlocking and worker verticles. How they differ, when each fits, and how to verify the thread handoff.
30 Oct 2025, 19:02 UTC

A blocked event loop is a shared failure
Vert.x assigns each event-loop context to one of a small number of event-loop threads. Every handler sharing that context runs on the same thread, one after another. A JDBC call that takes 200 ms does not just slow the request that made it — it stops every other handler queued behind it on that thread. That is the concrete reason the "don't block the event loop" rule exists: the cost is shared, and it surfaces as latency on unrelated endpoints.
The practical question is not whether to block. Legacy drivers, filesystem calls, and some vendor SDKs give you no non-blocking option. The question is where to put the blocking call so the event loop stays free — and Vert.x supports two different answers.
executeBlocking and worker verticles are not interchangeable
Vertx.executeBlocking hands a single task to the shared worker pool and returns a future. A worker verticle, deployed with DeploymentOptions.setWorker(true), gets its own worker-pool thread and processes events serially, so it can call blocking APIs directly.
| Vertx.executeBlocking | Worker verticle | |
|---|---|---|
| Thread | Borrowed from the shared worker pool per task | Own worker-pool thread for the verticle's lifetime |
| Ordering | Ordered by default for tasks from the same context; relaxable | Serial by construction — one event at a time |
| Fits | Occasional blocking calls inside otherwise async code | Sustained blocking workloads |
| Cost | Thread hop and queueing per call | Fixed thread per instance; less per-request flexibility |
Worked example: a legacy JDBC call inside a Vert.x Web handler
Assume Vert.x 4.x and a DAO wrapping a blocking JDBC driver. The route handler runs on an event-loop context; the DAO call must not.
// Vert.x 4.x. Runs inside a Vert.x Web route handler on an event-loop context.
router.get("/orders/:id").handler(ctx -> {
String orderId = ctx.pathParam("id");
vertx.executeBlocking(() -> legacyDao.findOrder(orderId)) // blocking JDBC
.onSuccess(order -> ctx.json(order)) // back on the event loop
.onFailure(err -> ctx.fail(500, err));
});
Three details matter. The blocking call is the only thing inside the lambda — no result assembly, no logging that touches a blocking sink. The completion handler runs back on the event loop, so writing the response there is safe. And the failure path is explicit: an exception from the DAO fails the future instead of escaping onto a worker thread.
Version assumptions are worth stating plainly. On Vert.x 3.x the shape differs: executeBlocking takes a Handler<AsyncResult<T>> and you call promise.complete / promise.fail yourself. The 4.x Callable overload and the 3.x handler signature are not interchangeable, so check the version in your build file before pasting either snippet. The ordered flag is available on the handler-based overload; whether a given Callable overload accepts it depends on the release, so confirm against your version's Javadoc.
For sustained blocking work, a worker verticle is the other supported route:
DeploymentOptions opts = new DeploymentOptions()
.setWorker(true)
.setInstances(4);
vertx.deployVerticle(new OrderEnrichmentVerticle(), opts);
A worker verticle can call the blocking DAO directly, because its events are delivered on a worker thread and processed serially. That serialisation is the point: it removes the ordering puzzle, but it also means one slow task delays the next event for that instance. Deploying several instances spreads load; it does not make any single instance concurrent.
Sizing the pool, and what offloading costs you
Worker pool size is set through VertxOptions.setWorkerPoolSize. The default is small and version-sensitive — read the current value from your version's documentation rather than trusting a number from a blog post. When the pool is too small, the failure mode is not high CPU; it is queueing. A burst of executeBlocking calls waits for a free worker thread, and you see a latency spike against an idle-looking CPU graph.
The costs are real. Every offload is a thread hop, which adds scheduling latency and makes cancellation harder — once a task is on a worker thread, interrupting it is your problem, not Vert.x's. Ordering guarantees hold within a context, so code that assumed sequential execution across contexts can break. Offloading can also mask backpressure: if the worker pool is the bottleneck, requests pile up in the pool queue instead of being rejected upstream.
Worker verticles trade differently. They are simpler to reason about for sustained blocking work, but they pin a thread per instance, reduce per-request flexibility, and make shared mutable state harder — one verticle is serial, two instances of it are not. Context and duplicated-context propagation rules for worker threads have changed across releases, so verify how tracing or MDC data behaves once work leaves the event loop.
Before either, check whether a non-blocking client exists. Vert.x SQL clients, async HTTP clients, and most modern drivers cover the common cases. Offloading is a containment strategy for unavoidable blocking, not a substitute for asynchronous I/O.
Verify the handoff instead of assuming it
Thread names make this observable. Vert.x names event-loop threads vert.x-eventloop-thread-N and worker threads vert.x-worker-thread-N, so one log line tells you which side of the boundary you are on.
- Log
Thread.currentThread().getName()in three places: inside an event-loop handler, inside anexecuteBlockingtask, and inside a worker verticle's handler. - Run it and confirm the first is an eventloop thread and the other two are worker threads. If a blocking call reports an eventloop thread, the offload is not happening where you think.
- In a throwaway test only, add a deliberate sleep on an event-loop thread and watch handlers on the same context stall. Remove it afterwards — never ship it.
- Put the real endpoint under a small concurrent load and take thread dumps or read pool metrics. Queue depth on the worker pool, not CPU, is what tells you whether the pool is sized correctly.
That last step is the one people skip. Sizing from observed queueing beats sizing from a guess, and it is the only way to know whether the thread hop you added is buying the responsiveness you wanted.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.