What is a Servlet Container — and Why Does it Exist?
The previous page described a servlet container from the outside — what
it accepts, routes, and executes. This page goes one level deeper: a
Servlet Container is, specifically, the runtime that
implements the Jakarta Servlet specification — a
contract defining exactly what a Servlet is, how the
container must call its lifecycle methods, and what objects
(HttpServletRequest, HttpServletResponse,
HttpSession) it must hand your code. Every Java web
framework you'll ever use — Spring MVC, Spring Boot, JAX-RS
implementations — is, underneath its annotations, a layer of code that
itself runs as a Servlet and delegates down to your
handler methods. Understanding this contract is what lets you reason
about what a framework is actually doing when its abstractions leak.
// BEFORE — implementing the Servlet contract directly
public class OrderStatusServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
String orderId = req.getParameter("orderId");
Order order = orderService.findById(Long.valueOf(orderId));
resp.setContentType("application/json");
resp.getWriter().write(objectMapper.writeValueAsString(order));
// You are directly responsible for parsing the parameter, choosing the
// content type, and serializing the response body by hand.
}
}
// AFTER — Spring MVC's DispatcherServlet is ITSELF a Servlet that implements
// this exact contract, and delegates down to your annotated method
@GetMapping("/orders/{orderId}")
public Order getOrderStatus(@PathVariable Long orderId) {
return orderService.findById(orderId);
// Same servlet lifecycle underneath — init/service/destroy still happen,
// parameter binding and JSON serialization are handled by the framework
// layer sitting on top of that same contract, not by a different mechanism.
}
The Servlet Lifecycle — One Instance, Three Lifecycle Methods
| Phase | Method | Called | Typical use |
|---|---|---|---|
| 1 | init() | Once, when the servlet is first loaded | Acquire resources — database connections, load configuration |
| 2 | service() → doGet()/doPost() | Once per request, on whichever thread the container assigns | Handle the actual request — this is where your logic runs |
| 3 | destroy() | Once, when the servlet is being unloaded | Release resources — close connections, stop background work |
The container creates exactly one instance of each
servlet class and calls its service() method from
multiple threads simultaneously as concurrent
requests arrive. An instance field on a servlet is shared mutable
state across every concurrent request being handled — this is the
single most common source of a bug that only appears under real
concurrent load, not in local single-request testing.
// BAD — instance field shared across every concurrent request
public class UnsafeOrderServlet extends HttpServlet {
private String currentCustomer; // ONE field, shared by every thread calling doGet concurrently
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
currentCustomer = req.getParameter("customer"); // Thread A sets "alice"...
// ...Thread B overwrites it with "bob" before Thread A reads it back below...
resp.getWriter().println("Orders for: " + currentCustomer); // could print the WRONG customer
}
}
// GOOD — local variable, scoped to this single request/thread invocation
public class SafeOrderServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
String currentCustomer = req.getParameter("customer"); // local — each thread gets its own
resp.getWriter().println("Orders for: " + currentCustomer);
}
}
A Spring @RestController is, by default, a
singleton — the same single instance handles every
concurrent request, precisely mirroring the servlet model above. Any
field you inject or assign on a controller is shared the same way an
instance field on a raw servlet would be. This is the underlying
mechanical reason controllers must stay stateless and pass everything
request-specific as method parameters rather than fields.
Filters and Listeners — Cross-Cutting Concerns at the Container Level
Filters — intercept before and after
@WebFilter("/*")
public class RequestTimingFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
long start = System.currentTimeMillis();
try {
chain.doFilter(request, response); // pass control down the chain — every filter must call this
} finally {
long duration = System.currentTimeMillis() - start;
log.info("Request completed in {}ms", duration); // finally — runs even if the chain throws
}
}
}
Common uses: authentication checks before a protected resource is reached, response compression, character-encoding normalization, and CORS headers — all covered in dedicated pages elsewhere in this Bible (Authentication vs Authorization, CORS).
Listeners — reacting to container lifecycle events
@WebListener
public class InventorySyncListener implements ServletContextListener {
@Override
public void contextInitialized(ServletContextEvent event) {
// Runs once when the application starts — start a background scheduler,
// warm a cache, verify required configuration is present
}
@Override
public void contextDestroyed(ServletContextEvent event) {
// Runs once during shutdown — stop background threads cleanly before
// the container finishes tearing down, so nothing is left running
// against resources that are about to disappear
}
}
Async Servlets — the Problem Virtual Threads Now Solve Differently
Before Java 21, a servlet handling a long-running operation — waiting on a slow downstream call, holding a connection open for Server-Sent Events — tied up one of the container's limited platform threads for the entire duration, exactly the scaling problem described on the previous page. Servlet 3.0 introduced asynchronous processing specifically to work around this: the servlet thread hands the request off and returns immediately, freeing itself to serve other requests, while the actual work completes elsewhere and finishes the response later.
@WebServlet(urlPatterns = "/orders/*", asyncSupported = true)
public class SlowReportServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
AsyncContext asyncContext = req.startAsync(); // releases the container thread NOW
reportingExecutor.submit(() -> {
// Runs on a SEPARATE thread pool, not the container's request thread
Report report = reportService.generateSlowReport(); // takes several seconds
try {
asyncContext.getResponse().getWriter().write(report.toJson());
} catch (IOException e) {
// handle appropriately
} finally {
asyncContext.complete(); // signals the container the response is finished
}
});
// doGet() returns here — the request thread is immediately free for other work
}
}
Async servlets solve the platform-thread scarcity problem by manually
moving work off the container's request thread onto a separate
executor — at the cost of real complexity: callback-style code,
careful exception handling across thread boundaries, and losing the
straightforward request-scoped context that synchronous code gets for
free. Virtual threads (covered on the previous page) solve the same
underlying scarcity problem differently: since a virtual thread
blocked on I/O doesn't tie up a scarce platform thread at all, plain
synchronous, blocking code — doGet() that just calls a
slow service directly — no longer needs manual async restructuring to
scale. Async servlets remain genuinely useful for specific patterns
that are asynchronous by nature regardless of thread cost — long-held
Server-Sent Events connections, WebSocket-adjacent patterns — but
"my synchronous code blocks a thread pool" is no longer, by itself, a
reason to reach for this API on Java 21+.
Popular Servlet Container Implementations
| Container | Type | Best for | Notable characteristic |
|---|---|---|---|
| Apache Tomcat | Servlet container | Most Java web apps — Spring Boot's default | Reference-grade, extensively documented, covered in depth next |
| Eclipse Jetty | Servlet container | Embedded scenarios, fast startup | Small footprint, popular for embedding in tooling |
| Undertow | Servlet container | High-concurrency, non-blocking workloads | Built on NIO from the ground up; powers WildFly and Spring Boot's Undertow starter |
| GlassFish | Full application server | Jakarta EE reference implementation | The specification's own reference platform |
| WildFly | Full application server | Enterprise Jakarta EE applications | Modular, Undertow-based, Jakarta EE certified |
The javax to jakarta Package Migration
Oracle transferred Java EE governance to the Eclipse Foundation in 2017,
and the project was renamed Jakarta EE. The package rename that trips
people up, however, didn't happen at that moment — it came later, with
Jakarta EE 9 in December 2020, when a trademark
restriction on the javax/"Java" naming forced every
specification package to move from javax.servlet to
jakarta.servlet. Any code, tutorial, or dependency written
before that point uses the old namespace; anything targeting Tomcat 10+,
Spring Boot 3+, or Jakarta EE 9+ uses the new one — the two are
source-incompatible, not just a version bump.
| Servlet spec version | Package | Typical runtime |
|---|---|---|
| 4.0 and earlier | javax.servlet | Tomcat 9 and earlier, Spring Boot 2.x |
| 5.0 and later | jakarta.servlet | Tomcat 10+, Spring Boot 3.x+ |
Every dependency in the classpath — not just your own code — needs a
jakarta.*-compatible version. A single lingering
javax.servlet-based third-party library alongside a
Tomcat 10+/Spring Boot 3+ upgrade fails at runtime with
ClassNotFoundException or silent servlet
registration failures, not a compile error you'd catch early —
verify the entire dependency tree, not only your own imports, before
attempting this upgrade.
Best Practices and Common Pitfalls
✅ Do
- Treat every servlet (and every default-scoped Spring controller) as a singleton shared across concurrent requests — keep request-specific data in local variables or method parameters, never instance fields
- Always call
chain.doFilter()in a filter, in afinallyblock if you need code to run regardless of downstream exceptions - Release resources acquired in
init()inside the matchingdestroy(), or in aServletContextListener'scontextDestroyed() - Prefer plain synchronous blocking code on Java 21+ with Virtual Threads over manual async servlet restructuring, unless the pattern is asynchronous by nature (SSE, long polling)
- Audit every dependency's package namespace (
javax.*vsjakarta.*) before a Tomcat 10+/Spring Boot 3+ upgrade — not just your own code
❌ Don't
- Don't store request- or user-specific state in a servlet's (or a singleton controller's) instance fields — it's shared, mutable state across every concurrent request
- Don't reach for
AsyncContextpurely to "avoid blocking a thread" on Java 21+ — Virtual Threads solve that specific problem with far less code complexity - Don't assume a
javax-to-jakartaupgrade is a mechanical import rename — verify every third-party dependency supports the new namespace first - Don't forget to call
asyncContext.complete()in every code path of an async servlet — a missed call leaves the request hanging until it times out
Interview Questions
Q: How many instances of a servlet class does the container create, and what does that mean for thread safety?
Exactly one. The container calls that single instance's
service() method from multiple threads concurrently as
requests arrive, which means any instance field is shared, mutable state
across every concurrent request. Request-specific data must be kept in
local variables inside the handling method, never in instance fields.
Q: What are the three lifecycle methods of a Servlet, and when is each called?
init() is called once, when the servlet is first loaded —
the place to acquire resources. service() (dispatching to
doGet/doPost/etc.) is called once per request,
potentially from many threads at once. destroy() is called
once, when the servlet is being unloaded — the place to release
resources acquired in init().
Q: What is a Filter, and how is it different from a Servlet?
A Filter intercepts a request before it reaches a servlet and the
response after the servlet generates it, and is meant for cross-cutting
concerns — logging, authentication checks, compression — that apply
across many URLs rather than one specific handler. Unlike a servlet, a
filter doesn't generate the actual response content; it calls
chain.doFilter() to pass control onward (or short-circuits
the chain deliberately, e.g. to reject an unauthenticated request).
Q: A team migrating from Spring Boot 2.x to 3.x reports a third-party library throwing ClassNotFoundException at runtime, with no compile errors. What's the most likely cause?
The library almost certainly still targets the javax.servlet
namespace, while Spring Boot 3.x runs on Tomcat 10+, which implements
jakarta.servlet. Since javax.* and
jakarta.* are different packages entirely — not different
versions of the same package — a dependency compiled against the old
namespace can still compile successfully against your own code (since
your code doesn't reference that library's servlet-related classes
directly) but fails at runtime the moment the container tries to load a
class that depends on a `javax.servlet` type that no longer exists on the
Jakarta EE 9+ classpath. The fix is to find a `jakarta`-compatible
version of that specific dependency, not to look for a bug in your own
migrated code.
Q: Why did async servlets exist before Java 21, and why does enabling Virtual Threads reduce, but not eliminate, the need for that API?
Before Virtual Threads, every in-flight request — including one merely
waiting on a slow downstream call — occupied one of the container's
bounded platform threads for the call's full duration. Async servlets
(AsyncContext) worked around this by having the request
thread hand off the actual work to a separate executor and return
immediately, at the cost of callback-style code and manual lifecycle
management (complete() must be called on every path).
Virtual threads remove the scarcity that motivated this pattern in the
first place — a virtual thread blocked on I/O releases its underlying
platform thread automatically, so plain synchronous blocking code scales
the same way async code used to, without the added complexity. What
Virtual Threads don't replace is genuinely asynchronous-by-nature
patterns — a Server-Sent Events connection held open indefinitely, or a
callback triggered by an external event rather than a blocking call —
where the async model isn't a workaround for thread scarcity, it's the
correct shape of the problem itself.
Q: A Spring @RestController has a mutable instance field used to cache the "last processed order" across requests, and under load the wrong order occasionally appears in a response. Explain the root cause using the servlet model.
A default-scoped Spring bean, including a controller, is a singleton —
exactly one instance handles every concurrent HTTP request, mirroring
precisely how the servlet container itself instantiates exactly one
instance of each servlet class. An instance field on that singleton is
therefore shared, mutable state across every thread the container is
currently using to handle different concurrent requests — one request's
thread can overwrite that field before another request's thread reads it
back, producing exactly the interleaved, wrong-data symptom described.
The fix is identical to the raw-servlet case: move the data into a local
variable scoped to the single request being handled, or, if state
genuinely needs to persist beyond one method call, into an explicitly
request-scoped or session-scoped bean rather than a singleton's instance
field.