The Server
Everything usually worth tuning is a method on JettyServer, and anything else is reachable through customizers that run against the real Jetty objects just before startup:
new JettyServer(app)
.port(8443)
.host("127.0.0.1")
.contextPath("/app")
.sessions(false)
.threadPool(new QueuedThreadPool(200, 8))
.multipart(new MultipartConfigElement(tmp, 10_485_760L, 10_485_760L, 1_048_576))
.stopTimeout(Duration.ofSeconds(20)) // longer drain for slow requests
.shutdownHook(false) // something else owns the lifecycle
.customizeHttpConfiguration(http -> http.setSendServerVersion(false))
.customizeContext(context -> context.addFilter(MyFilter.class, "/*", null))
.customizeServer(server -> server.setDumpBeforeStop(true))
.start();
Virtual threads
Handlers can run on virtual threads. That is a thread pool setting, not a framework feature, so it stays two lines of Jetty’s own API: platform threads keep running the selectors, the handlers get the virtual ones:
QueuedThreadPool pool = new QueuedThreadPool();
pool.setVirtualThreadsExecutor(VirtualThreads.getDefaultVirtualThreadsExecutor());
app.server((a, port) -> new JettyServer(a).port(port).threadPool(pool))
.start(8080);
Worth it only if the handlers block: on I/O, on a database.
A synchronized block around that blocking call pins the carrier thread and takes the benefit back.
Shutdown
Shutdown is graceful out of the box: a JVM shutdown hook stops the server on Ctrl-C or SIGTERM, and stop() gives requests in flight five seconds to finish before dropping them.
Idle keep-alive connections do not hold that up; they are closed as soon as the drain starts, so a stop with nothing running returns immediately.
stopTimeout(Duration.ZERO) turns the drain off entirely.
Replacing the server
To keep app.start(port) as the entry point while still configuring the server, or to run a different server entirely, replace the factory:
app.server((a, port) -> new JettyServer(a).port(port).sessions(false))
.start(9000);
app.server((a, port) -> new TomcatServer(a).port(port)) // spider-silk-tomcat
.start(9000);
app.server((a, port) -> new UndertowServer(a).port(port)) // spider-silk-undertow
.start(9000);
app.server((a, port) -> new MyOwnServer(a, port)) // implements WebServer
.start(9000);
Choosing a server
Everything core provides works the same on all three — routing, filters, error handlers, JSON, templates, static files, SSE, multipart uploads, sessions — because all core ever needed was a servlet container.
WebTest starts no server at all, so tests do not change either.
What differs is the embedding: what the server needs from its surroundings, and how much of the lifecycle this project had to assemble by hand.
| Jetty (default) | Tomcat | Undertow | |
|---|---|---|---|
Module |
in |
|
|
Session handling |
|
always on |
always on |
Graceful shutdown |
Jetty’s own |
hand-rolled: pause the connector, drain the request threads |
Undertow’s |
Drain vs. virtual threads |
unaffected |
a no-op — |
unaffected |
Shutdown hook |
Jetty’s |
ours: registered on start, removed on stop |
ours: registered on start, removed on stop |
Keeping the JVM up |
Jetty’s threads are non-daemon |
a non-daemon thread parked in |
a non-daemon thread parked on a latch |
Working directory |
none |
a |
none |
Virtual threads |
|
|
|
Logging |
slf4j directly |
JULI, so |
JBoss Logging, which finds slf4j on the classpath |
Customizers |
server, context, HTTP configuration |
Tomcat, context, connector |
deployment, builder |
Adapter size |
286 lines |
430 lines |
381 lines |
None of the three is a better server; they cost different things.
Jetty stays the default because it costs nothing to keep. It needs no disk, its shutdown and lifecycle are its own rather than assembled here, and it logs through slf4j like the rest of an application. For a service deployed as a jar and never touched by an operator, that is the whole picture.
Tomcat earns its place where the surroundings already assume it. The operational knowledge in most organisations is Tomcat knowledge: the connector attributes, the access log valve, the thread-pool numbers, the JMX beans a dashboard is already scraping. It is also the server Spring Boot defaults to, so a service moving off Spring Boot keeps the same runtime behaviour and the same tuning while only the web tier changes. And it is the one with a security-advisory pipeline most enterprise processes already track.
Undertow is the one to reach for on the technical merits. It is the lightest of the three to embed — no working directory, no bridge for its logging — and it is the only one whose graceful shutdown is a first-class handler rather than something assembled from a thread pool, which is why it is also the only one where the drain survives switching to virtual threads. The catch is reach: it is a WildFly component rather than a standalone product, so the operational familiarity and the tooling around it are thinner than Tomcat’s.
None of this is load-bearing. Changing your mind is the same one line at the factory, in either direction.