What is an Application Server?

The runtime that turns raw TCP bytes into a method call on your code β€” the taxonomy, the request lifecycle, and how Virtual Threads just changed its oldest tuning problem

← Back to Index

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
CapabilityWeb ServerServlet ContainerApplication Server
Static contentYesYesYes
Servlets / JSPNoYesYes
EJB, JTA, JMSNoNoYes
Typical footprintVery lowLow–mediumHigh
ExamplesNginx, Apache httpdTomcat, Jetty, UndertowWildFly, 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:

StageComponentResponsibility
1ConnectorAccepts 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)
2Engine / HostDetermines which virtual host and which deployed application (context) should handle this request β€” relevant when one server hosts several apps or domains
3ContextYour application, as deployed. Traditionally one WAR = one context; covered in depth on Context & Deployment
4Filter chainCross-cutting concerns applied before your code runs β€” security, logging, CORS, compression
5Servlet / ControllerYour actual code β€” a Servlet directly, or (in practice today) Spring's DispatcherServlet routing into your @RestController
6ResponseTravels back out through the filter chain and the connector to the client
A modern Spring Boot app still goes through every one of these stages

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

AspectExternal server (traditional WAR)Embedded server (Spring Boot)
Deployment artifactmyapp.war, copied into /opt/tomcat/webapps/myapp.jar, self-contained, includes Tomcat/Netty inside
Run commandServer already running as its own process; deploying just drops in the WARjava -jar myapp.jar β€” the server starts with the app
Server versionWhatever's installed on the shared serverPinned in your build file, versioned with the app itself
Multiple apps per serverCommon β€” several WARs share one Tomcat instanceOne application per process, by design
Container-native fitRequires more setup to containerize correctlyNatural fit for a single-process Docker image
Which one is "modern" today

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.
Virtual threads help blocking I/O, not CPU-bound work β€” and pinning is a real caveat

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 maxThreads as a first response to thread-pool exhaustion without checking whether the real bottleneck is a slow downstream dependency

Interview Questions

πŸŽ“ Junior level

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.

πŸ”₯ Senior level

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.