Servlet Containers

The Servlet contract every Java web framework sits on top of — lifecycle, thread safety, filters, and how async servlets solved the problem Virtual Threads now solve differently

← Back to Index

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

PhaseMethodCalledTypical use
1init()Once, when the servlet is first loadedAcquire resources — database connections, load configuration
2service()doGet()/doPost()Once per request, on whichever thread the container assignsHandle the actual request — this is where your logic runs
3destroy()Once, when the servlet is being unloadedRelease resources — close connections, stop background work
Critical: one instance, many threads — servlets must be thread-safe

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);
    }
}
This is exactly why Spring's request-scoped injection matters

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
    }
}
Why this is less necessary — though not obsolete — since Virtual Threads

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

ContainerTypeBest forNotable characteristic
Apache TomcatServlet containerMost Java web apps — Spring Boot's defaultReference-grade, extensively documented, covered in depth next
Eclipse JettyServlet containerEmbedded scenarios, fast startupSmall footprint, popular for embedding in tooling
UndertowServlet containerHigh-concurrency, non-blocking workloadsBuilt on NIO from the ground up; powers WildFly and Spring Boot's Undertow starter
GlassFishFull application serverJakarta EE reference implementationThe specification's own reference platform
WildFlyFull application serverEnterprise Jakarta EE applicationsModular, 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 versionPackageTypical runtime
4.0 and earlierjavax.servletTomcat 9 and earlier, Spring Boot 2.x
5.0 and laterjakarta.servletTomcat 10+, Spring Boot 3.x+
This is a real migration, not just an import find-and-replace

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 a finally block if you need code to run regardless of downstream exceptions
  • Release resources acquired in init() inside the matching destroy(), or in a ServletContextListener's contextDestroyed()
  • 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.* vs jakarta.*) 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 AsyncContext purely 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-jakarta upgrade 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

🎓 Junior level

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

🔥 Senior level

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.