Building a JSON REST Endpoint in Vert.x Web with Router and BodyHandler
Build a Vert.x Web POST endpoint that parses JSON with BodyHandler, validates payloads safely, returns structured errors, and keeps blocking work off the event loop.
19 Feb 2026, 06:56 UTC

The outcome you want
You need a POST endpoint that accepts a JSON body, validates it, and returns a JSON response — without blocking Vert.x's event loop. The pieces involved are small: a Router for routing, a BodyHandler to parse the request body, and JsonObject for payload handling. The catch is ordering and failure handling: mount things in the wrong sequence and getBodyAsJson() silently returns null, or an exception escapes as an empty 500 with no body.
This guide assumes Vert.x 4.x on Java 11+ with Maven or Gradle already set up (io.vertx:vertx-web on the classpath).
Why BodyHandler ordering matters
Vert.x routes match in the order they are registered. BodyHandler consumes the request stream and buffers the body so later handlers can read it. If your route handler runs before the body handler, the body is gone or unparsed. The safe pattern is to mount BodyHandler once, globally, before any routes that need bodies:
Router router = Router.router(vertx);
router.route().handler(BodyHandler.create().setBodyLimit(1024 * 1024)); // 1 MB cap
router.post("/api/items").handler(this::createItem);The default body limit is 10 MB. Lower it deliberately for JSON APIs — an unbounded or oversized limit invites memory pressure from a few large requests. This handler runs on the event loop; it only buffers bytes, which is fine.
The route handler: validate before you touch anything
routingContext.getBodyAsJson() returns a JsonObject, or null if the body was empty or not valid JSON. Treat null and missing fields as a 400, not a NullPointerException:
private void createItem(RoutingContext ctx) {
JsonObject body = ctx.getBodyAsJson();
if (body == null || body.getString("name") == null) {
ctx.fail(400, new IllegalArgumentException("JSON body with 'name' is required"));
return;
}
String name = body.getString("name");
int quantity = body.getInteger("quantity", 1); // default if absent
JsonObject response = new JsonObject()
.put("id", java.util.UUID.randomUUID().toString())
.put("name", name)
.put("quantity", quantity);
ctx.response()
.putHeader("Content-Type", "application/json")
.setStatusCode(201)
.end(response.encode());
}Two things to note. First, JsonObject.getInteger(key, default) avoids null checks for optional fields. Second, always set the Content-Type header explicitly — clients and test assertions depend on it.
Structured errors with a failure handler
Calling ctx.fail(status, throwable) routes the error to a failure handler instead of throwing it away. Mount one failure handler to produce consistent error JSON:
router.route().failureHandler(ctx -> {
int status = ctx.statusCode() > 0 ? ctx.statusCode() : 500;
String message = ctx.failure() != null ? ctx.failure().getMessage() : "Internal error";
ctx.response()
.putHeader("Content-Type", "application/json")
.setStatusCode(status)
.end(new JsonObject().put("error", message).encode());
});Now malformed input yields {"error":"JSON body with 'name' is required"} with a 400, and unexpected exceptions yield a 500 with the same shape. Note that malformed JSON is not automatically rejected by getBodyAsJson() in all versions — it can throw during parsing, which the failure handler also catches, but verify the behavior on your version.
Keeping the event loop free
Route handlers run on an event-loop thread. JDBC calls, blocking HTTP clients, or heavy computation there will stall every request on that loop — Vert.x logs "Thread has been blocked" warnings after a couple of seconds. Offload blocking work:
vertx.executeBlocking(() -> repository.save(name, quantity))
.onSuccess(id -> sendJson(ctx, 201, new JsonObject().put("id", id)))
.onFailure(ctx::fail);Also remember JsonObject is not thread-safe. Don't share one instance between handlers or pass it into worker code; copy with body.copy() or extract the values first.
Verifying it works
Start the verticle (e.g., vertx.createHttpServer().requestHandler(router).listen(8080) from a main or a test) and check with curl from any shell:
curl -i -X POST -H "Content-Type: application/json" \
-d '{"name":"widget","quantity":3}' http://localhost:8080/api/itemsExpect HTTP 201, Content-Type: application/json, and a body containing the generated id. Then exercise the failure paths: send -d 'not json' or omit name and confirm a 400 with the error JSON. For a repeatable check, a JUnit test with VertxTestContext and WebClient can POST and assert status and body fields. Under load (wrk or vegeta), watch logs for event-loop-blocked warnings — their absence confirms your blocking work is actually offloaded.
Limitations
BodyHandler buffers the whole body in memory (or temp files for large uploads), so it is the wrong tool for streaming very large payloads — use multipart handling or direct stream consumption instead. The validation shown here is manual; for larger APIs consider vertx-web-validation or an OpenAPI-driven router rather than hand-checked fields.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.