A team proposes replacing embedded Tomcat with Undertow or Jetty across a fleet of Spring Boot services, arguing it will improve throughput. How would you evaluate that proposal, and what does the swap actually cost?
answer
- the dependency edit is the easy part
- properties for an absent container are ignored, not rejected
- typed customizers stop matching silently
- dashboards go blank, not red
- measure before you migrate
basics
~20 sThe swap is mechanically trivial — exclude the Tomcat starter, add the Jetty or Undertow one — but the container is rarely the bottleneck. The real cost is that every server.tomcat.* property and Tomcat-typed customizer silently stops applying, taking tuning with it.
solid answer
~50 sI would ask for evidence first, because the container is almost never what limits a typical service: latency is usually dominated by downstream calls, and both containers and Tomcat sit far above that in cost. Mechanically the change is small — exclude `spring-boot-starter-tomcat` from `spring-boot-starter-web` and add `spring-boot-starter-jetty` or `spring-boot-starter-undertow`; servlet code is untouched. The costs are elsewhere. Every `server.tomcat.*` property becomes meaningless and is silently ignored, so carefully tuned thread and connection limits revert to another container's defaults with no error. Any `WebServerFactoryCustomizer` typed to the Tomcat factory stops matching and its customization vanishes. Container metric names change, so dashboards and alerts break. And you lose the operational familiarity of the ecosystem default. My position would be one supported container across the fleet, with a documented exception available to any team that brings a measurement showing the container is genuinely their constraint.
code
xml · 14 lines<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-undertow</artifactId>
</dependency>go deeper
Know that the embedded container is a swappable dependency — exclude the Tomcat starter, add another — and that servlet application code does not change.
Explain that each container has its own property namespace, so tuning does not carry over and unknown properties are ignored rather than rejected.
Lead the migration safely: translate every container property explicitly, find customizers bound to the old factory type, rebuild the metrics, and validate under production-shaped load rather than a smoke test.
Frame it as a fleet decision — demand a measurement that implicates the container, weigh permanent fragmentation cost against a one-off gain, and set one default with a documented, owned exception path.
## Start with the claim, not the change "It will improve throughput" is testable, and the test usually settles the discussion. For a typical request-serving service, time is spent in downstream calls, serialization, and application logic; the container's share of a request is small. Published benchmarks between the three servlet containers differ by workload, version, and tuning, and rarely reflect a real service's profile. The right first move is a profile of one representative service: if the container does not appear meaningfully in it, the proposal is optimizing something that is not the constraint, and the same effort spent on a downstream timeout or a connection-pool size will pay more. The legitimate versions of this proposal are narrower and usually not about raw throughput — a very large number of mostly-idle long-lived connections, an unusual memory-footprint target, or a specific protocol or integration requirement. Those are worth hearing. "Benchmarks say it is faster" is not, on its own. ## What the change looks like Mechanically it is a dependency edit: exclude `spring-boot-starter-tomcat` from `spring-boot-starter-web` and add `spring-boot-starter-jetty` or `spring-boot-starter-undertow`. Boot detects which container is on the classpath and configures it. Servlet code, filters, and controllers are untouched, which is exactly what makes the proposal look free. ## Where the cost actually is **Configuration silently evaporates.** This is the big one. Tuning lives in `server.tomcat.threads.max`, `server.tomcat.max-connections`, `server.tomcat.accept-count`. After the swap those properties match nothing. Unknown properties are not an error — the application starts cleanly and runs on the new container's defaults. The equivalents are `server.jetty.threads.max` or `server.undertow.threads.worker` and friends, with different names, different meanings, and different defaults. A service that was carefully sized is now sized by accident, and nothing in the build or the logs says so. **Code-level customization stops matching.** A `WebServerFactoryCustomizer<TomcatServletWebServerFactory>` is matched by generic type. After the swap that bean is simply never applied. Again: no error, no warning, the customization is gone. Anything relying on Tomcat-specific classes has to be rewritten against the new container's API or reworked into container-neutral form. **Observability breaks.** Container-level metrics are named per container, so dashboards, alerts and runbooks built around Tomcat's thread and connection gauges go blank. Blank panels are worse than missing ones because they read as healthy. **Operational familiarity.** Tomcat is the ecosystem default: the most documentation, the most Stack Overflow answers matching your exact stack trace, the most engineers who have debugged it at 3am. That is real value that does not show up in a benchmark, and it is the strongest argument for standardizing rather than mixing. **Fleet fragmentation.** Two containers means two sets of tuning conventions, two sets of dashboards, two upgrade and CVE-response paths, and every future platform change done twice. The cost is not per-swap; it is permanent and paid by whoever operates the fleet. ## How I would decide 1. **Require a measurement.** Show the container in a profile or a load test of a real service, not a synthetic benchmark from elsewhere. 2. **Pilot one service end to end.** Translate the tuning properties explicitly and review the translation, rebuild the dashboards, and run under production-shaped load — not just a smoke test that proves it starts. 3. **Compare on outcomes that matter.** Tail latency and error rate under overload, memory at steady state, and behaviour during a slow-dependency incident. Peak requests per second on a trivial endpoint is not the deciding number. 4. **Set a default and an exception path.** One supported container, one set of conventions, and a documented route for a team with evidence to run something else — with them owning the dashboards and the tuning translation. Most of the time the honest conclusion is that the container is not the problem, and the interesting outcome of the exercise is the profile that shows what actually is.
- What is the single most dangerous failure mode of this swap?Silent loss of tuning. `server.tomcat.*` properties and Tomcat-typed customizers simply stop applying — no error, no warning — so a carefully sized service quietly reverts to another container's defaults for threads and connections. It looks fine in a smoke test and shows up as an incident under real load. The mitigation is an explicit, reviewed translation of every container property plus a load test.
- What would count as a legitimate reason to run a different embedded container?A measured constraint the container actually causes: a very large population of mostly-idle long-lived connections, a hard memory-footprint target the profile ties to the container, or a specific protocol or integration requirement. Those are concrete and testable. A general benchmark claim, or a preference, is not — the container is rarely the bottleneck in a service dominated by downstream latency.
- How would you keep the fleet from fragmenting if you approve one exception?Make the default explicit and the exception documented and owned: the requesting team owns the tuning translation, the dashboards, and the upgrade path for their container, and records the measurement that justified it. Revisit at a fixed interval. Fragmentation costs are permanent and paid by whoever operates the fleet, so the exception must carry its own operational weight.
saying these in an interview costs you the question
- Treats the swap as free because application code is unchanged
- Assumes server.tomcat.* properties still apply after switching container
- Cites generic benchmarks instead of profiling the actual service
- Ignores that dashboards and alerts are container-specific
- Standardizes on a second container without an owner for its operations