The Server
JettyServer has a method for each setting an application usually tunes.
Customizers reach every other setting, running 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. Running handlers on virtual threads is a thread-pool setting rather than a framework feature, so it stays two lines of Jetty’s own API. Platform threads keep running the selectors, and 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);
Virtual threads pay off only when the handlers block on I/O or on a database.
A synchronized block around that blocking call no longer pins the carrier thread, since JDK 24 (JEP 491).
A blocking call inside a native method still pins it 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.
The application’s own hook closes open SSE streams at the same time, so a stream does not hold the drain for its whole timeout.
Idle keep-alive connections do not delay the stop, so a stop with no request in flight returns immediately.
On Tomcat and Undertow, a request that arrives during the drain answers 503.
stopTimeout(Duration.ZERO) turns the drain off entirely.
Replacing the server
Replace the factory to configure the server while keeping app.start(port) as the entry point, or to run a different server entirely:
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, status pages, JSON, templates, static files, SSE, multipart uploads, and sessions.
All core ever needed was a servlet container.
WebTest starts the app with app.start(0), which builds whatever server the app names, so tests do not change either and run against the server the app runs on.
The servers differ in the embedding: what each one needs from its surroundings, and how much of its 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: a valve that answers 503 and counts requests |
Undertow’s |
Drain vs. virtual threads |
unaffected |
unaffected |
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, nothing else matters.
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. Its security advisories also come through a 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, with no working directory and no bridge for its logging. Its graceful shutdown is a first-class handler, where Tomcat’s is a valve assembled here to the same design. 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.
The choice is easy to reverse. Switching servers in either direction is the same one line at the factory.