What is an Application Server β and Why Does it Exist?
A Java method β public Product getProduct(Long id) β knows
nothing about TCP sockets, HTTP framing, or concurrent connections. Every
one of those concerns has to be handled by something before your
code ever gets called, and handled correctly for thousands of
simultaneous requests without your business logic knowing any of it
happened. An application server (or the lighter-weight
servlet container that most modern Java systems actually
run) is that something: it owns the network listener, the HTTP protocol
parsing, the thread that runs your code, and the lifecycle of your
application as a whole, so your controller can be exactly one line of
business logic and nothing else.
// BEFORE β handling HTTP without any container at all
try (ServerSocket serverSocket = new ServerSocket(8080)) {
while (true) {
Socket client = serverSocket.accept(); // blocks until a connection arrives
new Thread(() -> {
try (BufferedReader in = new BufferedReader(new InputStreamReader(client.getInputStream()))) {
String requestLine = in.readLine(); // "GET /products/123 HTTP/1.1" β parse this yourself
// ... parse headers, parse the path, extract "123", find the right handler,
// build a raw HTTP response by hand, manage this thread's lifetime,
// decide what happens past your configured thread limit, handle
// malformed requests, handle slow clients holding the socket open ...
} catch (IOException e) { /* now you're also writing your own error handling */ }
}).start(); // one raw OS thread per connection, no pooling, no limit
}
}
// AFTER β the container has already done all of the above by the time this runs
@RestController
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping("/products/{id}")
public Product getProduct(@PathVariable Long id) {
return productService.findById(id);
// The container already: accepted the connection, parsed the HTTP request,
// assigned a pooled thread, ran the filter chain, matched the route, bound
// "123" to id β and will serialize whatever this returns back to JSON and
// write the HTTP response. None of that is this method's problem.
}
}
The Taxonomy β Web Server, Servlet Container, Application Server
These three terms get used loosely, but they describe genuinely different scopes of responsibility, each a superset of the previous one.
Web Server β static content only
// Reads a file from disk, sends it over HTTP. No code execution involved.
// GET /index.html β reads /var/www/index.html β sends bytes back
// Examples: Nginx, Apache HTTP Server (httpd), IIS serving static files
// Extremely fast for exactly this job β and structurally unable to run your Java code
Servlet Container β adds the ability to run Java code
// Implements the Jakarta Servlet specification: parses the request into an
// HttpServletRequest, finds the matching Servlet, calls service(), takes the
// HttpServletResponse and writes it back.
// Examples: Apache Tomcat, Eclipse Jetty, Undertow
Full Application Server β adds the rest of Jakarta EE
// Everything a servlet container has, plus: EJB, JPA, JTA, JMS, CDI, JAX-RS,
// Jakarta Security β the full enterprise specification stack.
// Examples: WildFly, Payara, IBM WebSphere Liberty, Oracle WebLogic, Apache TomEE
| Capability | Web Server | Servlet Container | Application Server |
|---|---|---|---|
| Static content | Yes | Yes | Yes |
| Servlets / JSP | No | Yes | Yes |
| EJB, JTA, JMS | No | No | Yes |
| Typical footprint | Very low | Lowβmedium | High |
| Examples | Nginx, Apache httpd | Tomcat, Jetty, Undertow | WildFly, Payara, WebLogic |
The Life of a Request Inside the Container
Tracing what actually happens between "browser sends bytes" and "your controller method runs" makes the earlier before/after concrete:
| Stage | Component | Responsibility |
|---|---|---|
| 1 | Connector | Accepts the TCP connection on a configured port, reads raw bytes, parses HTTP framing. A server can run several connectors at once (HTTP on 8080, HTTPS on 8443) |
| 2 | Engine / Host | Determines which virtual host and which deployed application (context) should handle this request β relevant when one server hosts several apps or domains |
| 3 | Context | Your application, as deployed. Traditionally one WAR = one context; covered in depth on Context & Deployment |
| 4 | Filter chain | Cross-cutting concerns applied before your code runs β security, logging, CORS, compression |
| 5 | Servlet / Controller | Your actual code β a Servlet directly, or (in practice today) Spring's DispatcherServlet routing into your @RestController |
| 6 | Response | Travels back out through the filter chain and the connector to the client |
@RestController and @GetMapping don't
replace the servlet model β DispatcherServlet is
itself a Servlet registered with the container, and it
performs the routing to your annotated methods internally.
Understanding this layer matters the moment something needs
container-level configuration β a custom filter, a connector
timeout, or a thread-pool size that Spring Boot's defaults don't
cover for your traffic pattern.
Embedded vs External β Where Does the Server Actually Live?
If you've only ever used Spring Boot, the mental model above can feel backwards β you never install a server or copy a file into it, you just run a JAR. That's because Spring Boot packages the servlet container inside your application artifact rather than deploying your artifact into a separately-installed server. Both models run the exact same request lifecycle described above; they differ in where the container process lives and how it's provisioned.
| Aspect | External server (traditional WAR) | Embedded server (Spring Boot) |
|---|---|---|
| Deployment artifact | myapp.war, copied into /opt/tomcat/webapps/ | myapp.jar, self-contained, includes Tomcat/Netty inside |
| Run command | Server already running as its own process; deploying just drops in the WAR | java -jar myapp.jar β the server starts with the app |
| Server version | Whatever's installed on the shared server | Pinned in your build file, versioned with the app itself |
| Multiple apps per server | Common β several WARs share one Tomcat instance | One application per process, by design |
| Container-native fit | Requires more setup to containerize correctly | Natural fit for a single-process Docker image |
Embedded servers are the default starting point for new Java systems β cloud-native deployment, Kubernetes, and 12-factor principles all assume one process per container, which is exactly the embedded model. External application servers haven't disappeared, but their remaining use case has narrowed considerably: large enterprises with existing WildFly/WebLogic infrastructure, regulated environments with support-contract requirements, or genuine multi-tenant hosting where several applications deliberately share one runtime's resources. Starting a brand-new project with an external application server today is a decision that needs a specific reason, not a default.
Services the Container Provides Beyond Routing
Connection pooling
// Modern Spring Boot: HikariCP is the default pool, configured directly β
// no JNDI lookup involved at all, because there's no external server to
// register a resource with in the first place.
# application.yml
spring:
datasource:
url: jdbc:postgresql://db.internal:5432/shop
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 10000
// Traditional Java EE deployment to an EXTERNAL server: the DataSource is
// configured once in the server, and application code looks it up by name β
// this is what JNDI is actually for, and it's specific to that deployment
// model, not something a Spring Boot embedded app typically does.
@Resource(name = "jdbc/productionDB")
private DataSource dataSource;
// Same WAR, deployed to a dev server or a prod server, each configured with
// its OWN jdbc/productionDB binding pointing at a different database β the
// application code never changes, only the server-side JNDI configuration.
Thread management
Every incoming request needs a thread to execute on. The traditional model β one platform (OS) thread per request, drawn from a bounded pool β is covered here at a high level; the production consequences of that model, and how Virtual Threads change them, are covered next.
Session management & security
The container tracks session state via a cookie-keyed lookup and can enforce authentication/authorization declaratively before your code runs at all. Both topics have dedicated, far deeper coverage in Session Management and Authentication vs Authorization β this page only needs you to know that the container, not your application code, is where these concerns are enforced.
Production Reality β Virtual Threads and Graceful Shutdown
Virtual Threads (Java 21+) rewrite the oldest Tomcat tuning problem
/*
* The traditional model: Tomcat's default connector caps at maxThreads=200
* platform threads. Each platform thread costs real OS resources (megabytes
* of stack, kernel scheduling overhead), so 200 is a genuine ceiling β the
* 201st concurrent request queues and waits, even if every one of those 200
* threads is just IDLE, blocked waiting on a slow database call or a remote
* HTTP call to another service. This is the single most common capacity
* bottleneck in traditional blocking Java web applications: the app isn't
* CPU-bound at all, it's thread-pool-bound while doing nothing but waiting.
*
* Virtual threads (Project Loom, standard since Java 21) are cheap enough β
* kilobytes, not megabytes, and scheduled by the JVM rather than the OS β
* that you can run hundreds of thousands of them. A blocking call on a
* virtual thread doesn't tie up an OS thread while it waits; the JVM parks
* the virtual thread and frees the underlying "carrier" platform thread to
* run other work in the meantime.
*/
# application.properties β enable virtual threads for the embedded Tomcat's
# request-handling executor (Spring Boot 3.2+, requires Java 21+)
spring.threads.virtual.enabled=true
// No code changes required β @RestController methods run exactly as before.
// The capacity conversation changes from "how many platform threads can we
// afford" to "how many blocking I/O calls can our downstream dependencies
// (database, other services) actually sustain" β which was the real
// constraint all along.
If a request is slow because it's doing heavy in-JVM computation
(image processing, complex serialization, cryptographic hashing),
virtual threads don't help at all β that work still consumes a real
CPU core for its full duration regardless of which kind of thread
runs it. Virtual threads specifically address the case where a
thread is blocked waiting, not computing. There's also a
known caveat: a virtual thread that executes inside a
synchronized block gets "pinned" to its carrier platform
thread for that block's duration, temporarily losing the scalability
benefit β this matters if your code (or a library you depend on)
relies heavily on synchronized rather than
java.util.concurrent locks around blocking calls.
Graceful shutdown β the difference that decides whether a rolling deploy drops requests
// Kubernetes sends SIGTERM to a pod before killing it during a rolling
// deployment. Without graceful shutdown, the JVM exits immediately β
// in-flight requests get connection-reset errors, right in the middle of a
// routine deploy.
# application.properties
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=20s
// On SIGTERM: the connector stops accepting NEW requests immediately, but
// requests already in flight are allowed to finish (up to the configured
// timeout) before the process actually exits. Combined with a Kubernetes
// readiness probe that flips to "not ready" during this window, the load
// balancer stops routing new traffic to this pod while it finishes existing
// work β the standard shape of a zero-downtime rolling deploy.
Choosing the Right Server
Use a servlet container (Tomcat/Jetty/Undertow), typically embedded, when:
- Building with Spring Boot β this is the default and requires no extra decision
- You don't need EJB, JMS, or other full Jakarta EE services
- You're building microservices deployed as containers (Docker/Kubernetes)
Use a full application server when:
- Required by existing enterprise infrastructure or a support contract
- You genuinely need EJB, JTA, or JMS as specified, not a Spring equivalent
- You're migrating or maintaining a legacy Java EE application, not writing a new one
Best Practices and Common Pitfalls
β Do
- Default to an embedded server for new projects unless there's a specific reason not to
- Configure connection pool sizing (HikariCP) based on measured concurrent database load, not a copy-pasted default
- Enable Virtual Threads (
spring.threads.virtual.enabled=true) on Java 21+ for I/O-heavy, blocking workloads before reaching for a reactive rewrite - Configure graceful shutdown and a Kubernetes readiness probe together β one without the other still drops in-flight requests during deploys
- Size the thread pool (or evaluate virtual threads) based on what your actual downstream dependencies can sustain, not an arbitrary round number
β Don't
- Don't reach for a full Jakarta EE application server "just in case" β it's real operational overhead for services you may never use
- Don't assume Virtual Threads speed up CPU-bound work β they specifically address blocking I/O wait time
- Don't deploy to Kubernetes without graceful shutdown configured β routine rolling deploys will intermittently drop live requests
- Don't use JNDI resource lookups in a Spring Boot application that was never deployed to an external container β it's solving a problem that setup doesn't have
- Don't raise
maxThreadsas a first response to thread-pool exhaustion without checking whether the real bottleneck is a slow downstream dependency
Interview Questions
Q: What's the difference between a Web Server and a Servlet Container?
A Web Server serves static content β HTML, CSS, images β and cannot
execute Java code. A Servlet Container implements the Jakarta Servlet
specification and can run your Java code to generate dynamic responses.
Every Servlet Container also serves static content; a plain Web Server
cannot do what a Servlet Container does.
Q: What does "embedded server" mean in Spring Boot, and how is it different from traditional deployment?
Traditionally, a servlet container (Tomcat, for example) is installed
separately, and you deploy your application into it by copying a WAR
file. An embedded server is packaged inside your application's own JAR β
running java -jar myapp.jar starts both the server and your
application together as one process, with no separate installation
step.
Q: What is a Connector, and why might a server run more than one?
A Connector is the component that listens on a network port and handles
the raw TCP/HTTP protocol details before a request reaches your
application. A server commonly runs more than one to listen on multiple
ports or protocols at once β HTTP on 8080 and HTTPS on 8443, for
example.
Q: Your Spring Boot service is timing out under load. Monitoring shows CPU usage is low, but the thread pool is consistently maxed out. What's likely happening, and how does enabling Virtual Threads address it?
Low CPU with a maxed-out thread pool is the signature of a blocking
I/O bottleneck, not a compute bottleneck β requests are almost certainly
spending most of their time waiting on a slow database query or a call to
another service, and every waiting request occupies a full platform
thread from a bounded pool (200 by default) until that call returns. Once
all 200 are occupied waiting, additional requests queue regardless of how
idle the CPU actually is. Enabling
spring.threads.virtual.enabled=true on Java 21+ replaces
that bounded platform-thread pool with virtual threads, which are cheap
enough to allocate one per request without a practical ceiling; a
virtual thread blocked on I/O releases its carrier platform thread rather
than occupying a scarce pooled resource. This doesn't make the downstream
call any faster β it removes the artificial thread-count ceiling that was
turning a downstream latency problem into an application-wide outage.
Q: A team removes all JNDI-based resource lookups from a legacy application being migrated to Spring Boot, replacing them with HikariCP configuration in application.yml. Is anything lost in that migration, and what should replace it?
What's lost is the specific capability JNDI provided in the external-server
model: the same deployed artifact (WAR) running unmodified against
different environment-specific resource bindings configured entirely on
the server side, with zero difference in the deployed code between
environments. In the embedded model, that same goal β identical build
artifact, environment-specific configuration β is achieved differently:
externalized configuration via environment variables or a config server,
together with Spring profiles, so the JAR itself never changes between
dev and production, only the configuration injected into it at startup.
The capability isn't lost, it's relocated from server-side JNDI bindings
to environment-injected Spring configuration β which is the more
container/cloud-native-friendly mechanism for the exact same underlying
requirement.
Q: A rolling Kubernetes deployment of a Spring Boot service causes a brief spike in 5xx errors on every deploy, even though the new pods pass their health checks quickly. What's the most likely misconfiguration?
Almost certainly missing or misconfigured graceful shutdown on the
outgoing pods, the readiness probe timing on the incoming ones, or both.
Without server.shutdown=graceful, a pod receiving
SIGTERM terminates immediately, dropping any request still
in flight rather than finishing it β that's the direct source of dropped
requests during the exact window a pod is being replaced. The fix pairs
two settings: server.shutdown=graceful with a reasonable
spring.lifecycle.timeout-per-shutdown-phase so in-flight
requests get a real chance to complete, and a Kubernetes readiness probe
that flips to not-ready as soon as shutdown begins, so the load balancer
stops sending new traffic to that pod during its drain window rather than
continuing to route to a pod that's already refusing new connections.