Eclipse Vert.x is a toolkit for building reactive applications on the JVM. It is not a framework that owns your application: there is no container, no annotation scanning and no dependency injection. You create a Vertx instance in a plain main method, deploy units of code called verticles, and get a small number of event-loop threads that handle very large numbers of connections. Underneath it sits Netty, and most of what Vert.x adds is a programming model that makes Netty's event loops safe and pleasant to use.
This article explains that model from first principles: why event loops, what the golden rule actually protects, how verticles, contexts and futures fit together, where blocking code is allowed to run, and how the event bus connects components inside one process and across a cluster. The examples use the Vert.x 5 API, which removed the callback-style methods and kept only futures; the version numbers and defaults quoted were checked against the current Vert.x documentation and VertxOptions javadoc. It finishes with a worked service, the failure modes you will meet in production and a checklist.
Why event loops
A classic servlet container gives each request a thread. That is easy to reason about, but a platform thread costs memory for its stack and time for every context switch, so a server with thousands of slow clients spends its resources on threads that are waiting. The alternative is to let a few threads multiplex many connections: register interest in socket events with the operating system, and run a short piece of code whenever a socket becomes readable or writable. That loop over ready events is an event loop, and it is what Java NIO and Netty provide.
Raw event loops are hard to program because every piece of code that runs on one must return quickly. Vert.x makes this manageable with three ideas. Code is grouped into verticles, each bound to one event loop. Asynchronous results are represented as futures that are always completed on the right thread. And blocking work has explicit places to go, so it never runs on an event loop by accident.
Event loops, contexts and the golden rule
Vert.x creates a pool of event-loop threads, by default twice the number of available cores, and assigns each deployed verticle instance to one of them. A classic reactor, as in Node.js, runs a single event loop; Vert.x calls its arrangement multi-reactor because there are several loops, each serving its own share of verticles and connections.
The binding is held in a context. When a verticle is deployed it gets an event-loop context, and every handler that verticle registers, for HTTP requests, timers, event-bus messages or future completions, is dispatched on that context's thread. That gives the most useful guarantee in Vert.x: code inside one verticle instance never runs concurrently with itself. You can keep mutable state in plain fields of a verticle without locks, as long as only that verticle touches it.
The price is the golden rule: never block an event loop. A blocked loop stops serving every verticle and connection assigned to it, not just the slow request. Vert.x enforces the rule with a blocked-thread checker that wakes every 1,000 ms by default and logs a warning when an event-loop thread has been running a single task for longer than 2 seconds, or a worker thread for longer than 60 seconds. Those thresholds are maxEventLoopExecuteTime and maxWorkerExecuteTime in VertxOptions. Treat any such warning in production as a bug, not as noise.
Verticles: the unit of deployment
A verticle is the unit of deployment and concurrency. In Vert.x 5 the recommended base class is VerticleBase, whose start method returns a Future<?> that completes when the verticle is ready. AbstractVerticle still exists and is not deprecated, but it is no longer the default choice.
import io.vertx.core.*;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.handler.BodyHandler;
public class HttpVerticle extends VerticleBase {
@Override
public Future<?> start() {
Router router = Router.router(vertx);
router.route().handler(BodyHandler.create().setBodyLimit(64 * 1024));
router.get("/health").handler(ctx -> ctx.response().end("ok"));
router.post("/quote").handler(this::quote); // defined in the worked example
return vertx.createHttpServer()
.requestHandler(router)
.listen(8080); // start() completes when the port is bound
}
}
public class Main {
public static void main(String[] args) {
Vertx vertx = Vertx.vertx();
vertx.deployVerticle(HttpVerticle::new, new DeploymentOptions().setInstances(4))
.onSuccess(id -> System.out.println("deployed " + id))
.onFailure(err -> { err.printStackTrace(); vertx.close(); });
}
}Deploying four instances gives four verticles, each with its own context, spread across event loops. All four call listen(8080). Vert.x notices that the servers share a port and distributes incoming connections among them, so one process scales across cores without any user-level load balancer and without shared mutable state. This is the standard way to use all cores: scale by instances, not by threads inside one verticle.
Futures in Vert.x 5
Every asynchronous operation in Vert.x 5 returns a Future<T>. The callback overloads that took a Handler<AsyncResult<T>> were removed, which makes code more uniform. A Vert.x future is not a CompletableFuture: its continuations run on the context that created the operation, so a handler attached inside a verticle runs on that verticle's event loop and the no-concurrency guarantee still holds.
| Operator | Use it for | Note |
|---|---|---|
map | Transform a successful value | Must be fast; runs on the event loop |
compose | Chain another asynchronous call | The function returns a Future |
recover | Turn a failure into a fallback future | Keep the cause in logs |
Future.all | Wait for several futures, fail if any fails | Use Future.join to wait for all regardless |
Future.any | First success wins | Useful for hedged requests |
onSuccess / onFailure | Terminal side effects | Do not leave failures unobserved |
The commonest bug in future-based code is a dropped failure: a chain that ends in onSuccess with nothing handling the error path, so a failed database call leaves an HTTP request hanging until the client times out. End every chain either by returning the future to the caller or with an explicit failure handler that completes the response.
Where blocking code goes
Real systems call blocking code: JDBC drivers, file systems, legacy SDKs, CPU-heavy work such as password hashing. Vert.x offers three places for it, selected per call or per deployment.
| Mechanism | Runs on | Ordering | When to use |
|---|---|---|---|
vertx.executeBlocking(callable) | Worker pool (default 20 threads) | Serial per context by default; pass false to run in parallel | Occasional blocking call inside an event-loop verticle |
ThreadingModel.WORKER | Worker pool threads | One handler at a time per instance | A whole verticle that wraps a blocking library |
ThreadingModel.VIRTUAL_THREAD | Virtual threads (Java 21+) | One handler at a time per instance | Blocking-style code, many concurrent waits |
The threading model is set with DeploymentOptions.setThreadingModel. A virtual-thread verticle may wait on a future in direct style, suspending only its virtual thread while the carrier thread serves other work; see Java virtual threads for what pinning and thread-locals do there. Virtual threads do not make an event loop safe to block, and they do not create database connections. A pool of ten JDBC connections serves ten concurrent queries whatever thread model calls it.
Size the worker pool deliberately. The default of 20 threads is shared by every executeBlocking call in the process, so a slow dependency can occupy all of them and stall unrelated work. Give each slow dependency its own named worker pool with vertx.createSharedWorkerExecutor, sized to the dependency's own concurrency limit, so that one slow system cannot starve the rest.
The event bus
The event bus is Vert.x's message backbone. Handlers register on string addresses, and senders address them without knowing where they live. Three patterns are supported: send delivers to one consumer, chosen round-robin when several are registered; publish delivers to all consumers; request delivers to one consumer and returns a future of its reply. A request with no reply fails after the default delivery timeout of 30 seconds unless you set a different timeout in DeliveryOptions.
Messages carry JSON objects, buffers, strings, primitives or any type with a registered codec. Inside one JVM, delivery is an in-memory hand-off to the consumer's context, so the bus is a cheap way to decouple verticles that run on different event loops without sharing state. Started in clustered mode with a cluster manager such as Hazelcast, Infinispan or Apache Ignite, the same addresses span every node, and the bus becomes a lightweight service mesh.
Two limits matter. The event bus is not a durable queue: messages in flight are lost if a node dies, and there is no persistence or replay. Use Kafka or a database for anything that must survive a crash. And clustered delivery moves serialised messages over TCP, so a chatty design that is free in one process becomes expensive across nodes.
Worked example: a pricing service
Here is a pricing service in which the HTTP verticle stays purely non-blocking, a pricing verticle owns an in-memory cache with no locks because only it touches it, and the slow catalogue database is isolated on its own worker pool.
// In HttpVerticle: translate HTTP to an event-bus request and back.
private void quote(RoutingContext ctx) {
JsonObject req = ctx.body().asJsonObject();
vertx.eventBus().<JsonObject>request("pricing.quote", req,
new DeliveryOptions().setSendTimeout(2_000)) // fail fast instead of the 30 s default
.onSuccess(msg -> ctx.json(msg.body()))
.onFailure(err -> ctx.response().setStatusCode(503).end("pricing unavailable"));
}
public class PricingVerticle extends VerticleBase {
private final Map<String, JsonObject> cache = new HashMap<>(); // safe: one context
private WorkerExecutor catalogue;
@Override
public Future<?> start() {
catalogue = vertx.createSharedWorkerExecutor("catalogue-db", 10); // = DB pool size
vertx.eventBus().<JsonObject>consumer("pricing.quote", msg -> {
String sku = msg.body().getString("sku");
JsonObject hit = cache.get(sku);
if (hit != null) { msg.reply(hit); return; }
catalogue.executeBlocking(() -> CatalogueDao.load(sku)) // JDBC, off the loop
.map(item -> price(item, msg.body().getInteger("qty", 1))) // back on our context
.onSuccess(q -> { cache.put(sku, q); msg.reply(q); })
.onFailure(err -> msg.fail(500, err.getMessage()));
});
return Future.succeededFuture();
}
}Follow a request. A connection lands on event loop 1, the router parses the body, and the handler sends a request on the bus. The pricing verticle's handler runs on its own event loop, checks the cache, and on a miss hands the JDBC call to the catalogue pool. When the query returns, the map and the cache write run back on the pricing verticle's context, so the HashMap is never touched by two threads. The reply returns to the HTTP verticle's context, which writes the response. No thread waited, apart from one catalogue worker for the duration of the query, and the explicit two-second timeout turns a stuck database into a fast 503 instead of piled-up requests. In production you would bound the cache's size too.
Failure modes
- Blocked event loop. A synchronous call such as a JDBC query,
Thread.sleep, a large JSON parse or a regular expression with catastrophic backtracking runs on the loop. Every connection on that loop stalls. The blocked-thread warning includes a stack trace; fix the call site, never raise the threshold. - Shared worker exhaustion. All 20 default workers wait on one slow dependency and unrelated
executeBlockingcalls queue behind them. Use named executors per dependency. - Unobserved failures. A future fails with no handler and the request hangs. Every chain needs a failure path that answers the caller.
- Unbounded buffering. Writing to a socket faster than the peer reads grows the write queue without limit. Use
pipeTobetween streams, or checkwriteQueueFulland resume on the drain handler. - Lost context. Thread-locals and MDC values set on one thread do not follow a future across contexts. Use Vert.x's context-local data or tracing integration rather than thread-locals.
- Treating the event bus as a queue. Messages vanish on node failure. Anything that must not be lost belongs in durable storage.
Trade-offs
Vert.x gives high connection density, low latency and very little framework overhead, and its explicit threading makes performance predictable. In return, developers must understand which thread runs their code, and asynchronous stack traces are harder to read than synchronous ones. With virtual threads now in the JDK, plain blocking code on a thread-per-request server handles many workloads that once needed an event loop. Vert.x still earns its place for proxies, gateways, streaming and WebSocket servers, protocol implementations and services with many long-lived connections, and its virtual-thread verticles let you write direct-style code where the async style costs more than it saves. Compare the operational footprint with a full framework such as Spring Boot before choosing.
What to do next
- Build the two-verticle example, deploy the HTTP verticle with as many instances as cores, and load-test it.
- Add a deliberate
Thread.sleep(3000)in a handler and read the blocked-thread warning, so you recognise it in production. - Audit every
executeBlockingcall and move slow dependencies onto named worker executors sized to their connection pools. - Make sure every future chain ends in a failure handler that answers the caller, and set explicit event-bus timeouts.
- Decide per verticle between event-loop, worker and virtual-thread models, and write the reason in the deployment code.
- Export Vert.x metrics for event-loop latency and worker-pool queueing, and alert on blocked-thread warnings.