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);

WebServer is four methods (start, stop, join, port), so another implementation is a small job. Two are written already: Tomcat and Undertow, each in a module named after the server it is tied to.

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 spider-silk-core

spider-silk-tomcat

spider-silk-undertow

Session handling

sessions(false) turns it off

always on

always on

Graceful shutdown

Jetty’s own stopTimeout

hand-rolled: a valve that answers 503 and counts requests

Undertow’s GracefulShutdownHandler, which counts requests

Drain vs. virtual threads

unaffected

unaffected

unaffected

Shutdown hook

Jetty’s setStopAtShutdown

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 Server.await()

a non-daemon thread parked on a latch

Working directory

none

a catalina.base on disk, temporary unless baseDir(…​)

none

Virtual threads

threadPool(…​) with a QueuedThreadPool

executor(…​)

executor(…​)

Logging

slf4j directly

JULI, so jul-to-slf4j plus a bridge handler

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.