Web Server #
Behind every web application — from online stores to monitoring dashboards — there’s a process listening on a TCP port, accepting HTTP requests, processing them, and sending back responses. That’s what a web server does. In the Java ecosystem, there are three main approaches: the Servlet API, which has been the foundational standard for two decades, Spring Boot, the dominant choice for modern enterprise applications, and Jetty, which can be embedded directly into a plain Java application. This article covers how each works — from a simple servlet to a complete REST API with routing, request parsing, response formatting, filters, and production configuration.
Overview #
Every HTTP request passes through layers before your application code runs:
flowchart LR
A["Browser / API Client"] -->|"HTTP Request"| B["Web Server\n(Tomcat / Jetty / Netty)"]
B --> C["Filter / Middleware\n(auth, logging, CORS)"]
C --> D["Servlet / Controller\n(application logic)"]
D --> E["Response\n(JSON / HTML / file)"]
E --> A| Approach | Good for | Server | Configuration |
|---|---|---|---|
| Servlet API | Legacy enterprise apps, learning HTTP basics | Tomcat, Jetty, WildFly | web.xml or annotations |
| Spring Boot | Modern apps, REST APIs, microservices | Embedded Tomcat/Jetty | Auto-configuration |
| Embedded Jetty | Internal tools, lightweight standalone apps | Embedded Jetty | Java code |
The Servlet API #
A servlet is a Java component that handles HTTP requests directly. This is the most basic layer — every Java web framework (including Spring) ultimately runs on top of the Servlet API. Understanding servlets means understanding how HTTP works in Java.
Dependencies #
<!-- Maven — Jakarta Servlet API (for Tomcat 10+) -->
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>6.0.0</version>
<scope>provided</scope> <!-- provided by the server, not included in the WAR -->
</dependency>
Creating a Basic Servlet #
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
// @WebServlet registers the servlet at the /hello path without needing web.xml
@WebServlet("/hello")
public class HelloServlet extends HttpServlet {
// doGet: handle HTTP GET requests
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse res)
throws ServletException, IOException {
// Read a parameter from the query string: /hello?name=Budi
String name = req.getParameter("name");
if (name == null || name.isBlank()) name = "World";
// Set the response content type
res.setContentType("text/html; charset=UTF-8");
res.setCharacterEncoding("UTF-8");
// Write the response body
PrintWriter out = res.getWriter();
out.println("<!DOCTYPE html>");
out.println("<html><body>");
out.println("<h1>Hello, " + escapedHtml(name) + "!</h1>");
out.println("</body></html>");
}
// doPost: handle HTTP POST requests
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse res)
throws ServletException, IOException {
// Read form body from POST
String name = req.getParameter("name");
String email = req.getParameter("email");
// Set the response as JSON
res.setContentType("application/json; charset=UTF-8");
res.setStatus(HttpServletResponse.SC_CREATED); // 201
res.getWriter().println("""
{"status": "ok", "name": "%s", "email": "%s"}
""".formatted(name, email));
}
private String escapedHtml(String input) {
return input
.replace("&", "&")
.replace("<", "<")
.replace(">", ">");
}
}
Reading Requests #
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse res)
throws ServletException, IOException {
// Path and method
String method = req.getMethod(); // GET, POST, PUT, etc.
String path = req.getRequestURI(); // /api/products/123
String query = req.getQueryString(); // id=123&sort=asc
// Query string parameters
String id = req.getParameter("id");
String[] tags = req.getParameterValues("tag"); // ?tag=a&tag=b → ["a", "b"]
// Headers
String contentType = req.getHeader("Content-Type");
String userAgent = req.getHeader("User-Agent");
String bearer = req.getHeader("Authorization"); // "Bearer <token>"
// Client information
String ip = req.getRemoteAddr();
String host = req.getServerName();
int port = req.getServerPort();
// Path variable from the URL (with the pattern /products/*)
String pathInfo = req.getPathInfo(); // "/123" for the URL /products/123
// Session
var session = req.getSession(false); // false = don't create one if it doesn't exist
if (session != null) {
String userId = (String) session.getAttribute("userId");
}
}
Writing Responses #
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse res)
throws ServletException, IOException {
// Status code
res.setStatus(200); // OK
res.setStatus(HttpServletResponse.SC_OK); // more readable
res.sendError(404, "Product not found"); // set status + error body
// Response headers
res.setHeader("Cache-Control", "no-cache, no-store");
res.setHeader("X-Request-Id", java.util.UUID.randomUUID().toString());
res.addHeader("Set-Cookie", "sessionId=abc123; HttpOnly; Secure");
// Redirect
res.sendRedirect("/login"); // 302 redirect
res.sendRedirect("https://example.com"); // redirect to another URL
// JSON body
res.setContentType("application/json; charset=UTF-8");
res.getWriter().println("{\"id\": 1, \"name\": \"Laptop\"}");
}
Filters — Middleware for Servlets #
A filter is a component that runs before and after a servlet — suitable for logging, authentication, CORS, or compression.
import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.*;
import java.io.IOException;
import java.time.Instant;
// Apply to all URLs
@WebFilter("/*")
public class LogFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpReq = (HttpServletRequest) req;
HttpServletResponse httpRes = (HttpServletResponse) res;
long start = System.currentTimeMillis();
String path = httpReq.getRequestURI();
// Add CORS headers
httpRes.setHeader("Access-Control-Allow-Origin", "*");
httpRes.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
// Handle preflight OPTIONS requests
if ("OPTIONS".equalsIgnoreCase(httpReq.getMethod())) {
httpRes.setStatus(HttpServletResponse.SC_OK);
return;
}
// Continue to the next servlet
chain.doFilter(req, res);
// Log after the servlet finishes
long duration = System.currentTimeMillis() - start;
System.out.printf("[%s] %s %s → %d (%dms)%n",
Instant.now(), httpReq.getMethod(), path, httpRes.getStatus(), duration);
}
}
Running on Tomcat #
# Package into a WAR
mvn package -Dpackaging=war
# Deploy to Tomcat (copy to webapps/)
cp target/app.war $TOMCAT_HOME/webapps/
# Access at: http://localhost:8080/app/hello
Spring Boot #
Spring Boot is the most productive way to build a web server in Java today. It embeds Tomcat automatically, configures many things with convention over configuration, and provides a complete ecosystem — from security to databases. You don’t need an external server: just run main() and the server is up.
Dependencies and Setup #
<!-- Maven -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
// Gradle
implementation 'org.springframework.boot:spring-boot-starter-web'
// Application entry point
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication // = @Configuration + @EnableAutoConfiguration + @ComponentScan
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
// Tomcat runs on port 8080
}
}
REST Controller #
import org.springframework.web.bind.annotation.*;
import org.springframework.http.*;
import java.util.*;
@RestController // = @Controller + @ResponseBody
@RequestMapping("/api/products") // all endpoints start with /api/products
public class ProductController {
// Simulated data (in a real app, this comes from a database)
private final Map<Long, Product> db = new java.util.concurrent.ConcurrentHashMap<>();
private long idCounter = 1;
// GET /api/products — list all products
@GetMapping
public List<Product> getAllProducts(
@RequestParam(required = false) String search, // optional query param
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
return db.values().stream()
.filter(p -> search == null || p.name().contains(search))
.skip((long) page * size)
.limit(size)
.toList();
}
// GET /api/products/42 — one product by ID
@GetMapping("/{id}")
public ResponseEntity<Product> getProduct(@PathVariable Long id) {
Product p = db.get(id);
if (p == null) {
return ResponseEntity.notFound().build(); // 404
}
return ResponseEntity.ok(p); // 200 + JSON body
}
// POST /api/products — create a new product
@PostMapping
public ResponseEntity<Product> createProduct(@RequestBody ProductRequest req) {
// Manual validation (in production, use @Valid + Bean Validation)
if (req.name() == null || req.name().isBlank()) {
return ResponseEntity.badRequest().build(); // 400
}
Product created = new Product(idCounter++, req.name(), req.price());
db.put(created.id(), created);
// 201 Created + Location header
return ResponseEntity
.created(java.net.URI.create("/api/products/" + created.id()))
.body(created);
}
// PUT /api/products/42 — update a product
@PutMapping("/{id}")
public ResponseEntity<Product> updateProduct(
@PathVariable Long id,
@RequestBody ProductRequest req) {
if (!db.containsKey(id)) return ResponseEntity.notFound().build();
Product updated = new Product(id, req.name(), req.price());
db.put(id, updated);
return ResponseEntity.ok(updated);
}
// DELETE /api/products/42 — delete a product
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
if (db.remove(id) == null) return ResponseEntity.notFound().build();
return ResponseEntity.noContent().build(); // 204
}
}
// Records for the model (Java 16+)
record Product(Long id, String name, double price) {}
record ProductRequest(String name, double price) {}
Global Exception Handling #
Instead of handling exceptions in every controller, use @RestControllerAdvice to centralize error handling.
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.context.request.WebRequest;
@RestControllerAdvice
public class GlobalExceptionHandler {
// Handle a custom exception
@ExceptionHandler(ProductNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(
ProductNotFoundException ex, WebRequest request) {
ErrorResponse error = new ErrorResponse(
HttpStatus.NOT_FOUND.value(),
"NOT_FOUND",
ex.getMessage(),
request.getDescription(false)
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
// Handle all unexpected exceptions
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex, WebRequest request) {
ErrorResponse error = new ErrorResponse(
500, "INTERNAL_ERROR", "An internal error occurred", request.getDescription(false)
);
return ResponseEntity.status(500).body(error);
}
}
record ErrorResponse(int status, String code, String message, String path) {}
class ProductNotFoundException extends RuntimeException {
public ProductNotFoundException(Long id) {
super("Product with ID " + id + " not found");
}
}
application.properties Configuration #
# Server port (default 8080)
server.port=8080
# Context path — all URLs start with /api
server.servlet.context-path=/
# Request timeout
server.connection-timeout=5s
# Maximum request body size (default 1MB)
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
# Logging level
logging.level.root=INFO
logging.level.com.example=DEBUG
# Jackson — JSON format
spring.jackson.serialization.indent-output=true
spring.jackson.default-property-inclusion=NON_NULL
# Enable actuator endpoints for health checks
management.endpoints.web.exposure.include=health,info,metrics
Filters in Spring Boot #
import jakarta.servlet.*;
import jakarta.servlet.http.*;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component // Spring automatically registers this as a filter
public class RequestLoggingFilter implements jakarta.servlet.Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpReq = (HttpServletRequest) req;
HttpServletResponse httpRes = (HttpServletResponse) res;
long start = System.currentTimeMillis();
chain.doFilter(req, res);
long duration = System.currentTimeMillis() - start;
System.out.printf("%s %s → %d (%dms)%n",
httpReq.getMethod(), httpReq.getRequestURI(), httpRes.getStatus(), duration);
}
}
Running Spring Boot #
# Via Maven
mvn spring-boot:run
# Via JAR (after building)
mvn clean package
java -jar target/app-1.0.0.jar
# With a specific profile
java -jar app.jar --spring.profiles.active=production
# Access: http://localhost:8080/api/products
Embedded Jetty #
Jetty lets you build a web server that runs entirely from Java code — no Tomcat, no WAR, no deployment to an external server. The entire server lives in a single JAR that can be run with java -jar. This is ideal for lightweight microservices, internal tools, or when you want full control over server configuration.
Dependencies #
<!-- Maven — Jetty 12 -->
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-server</artifactId>
<version>12.0.7</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.ee10</groupId>
<artifactId>jetty-ee10-servlet</artifactId>
<version>12.0.7</version>
</dependency>
Minimal Server #
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.handler.DefaultHandler;
import org.eclipse.jetty.ee10.servlet.*;
import jakarta.servlet.http.*;
import java.io.IOException;
public class JettyMain {
public static void main(String[] args) throws Exception {
// Create a server on port 8080
Server server = new Server(8080);
// Context handler: handle all requests at the "/" path
ServletContextHandler ctx = new ServletContextHandler();
ctx.setContextPath("/");
// Register servlets at paths
ctx.addServlet(new ServletHolder(new HelloServlet()), "/hello");
ctx.addServlet(new ServletHolder(new ApiServlet()), "/api/*");
server.setHandler(ctx);
// Start the server
server.start();
System.out.println("Jetty server running at http://localhost:8080");
// Block until the server is stopped (Ctrl+C)
server.join();
}
}
// A simple servlet
class HelloServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse res)
throws IOException {
res.setContentType("application/json");
res.getWriter().println("{\"message\": \"Hello from Jetty!\"}");
}
}
Thread Pool and Timeout Configuration #
import org.eclipse.jetty.server.*;
import org.eclipse.jetty.util.thread.QueuedThreadPool;
public class ConfiguredJetty {
public static void main(String[] args) throws Exception {
// Thread pool configuration
QueuedThreadPool pool = new QueuedThreadPool();
pool.setMinThreads(5);
pool.setMaxThreads(50);
pool.setIdleTimeout(60_000); // remove idle threads after 60 seconds
Server server = new Server(pool);
// Connector configuration (port, timeouts)
ServerConnector connector = new ServerConnector(server);
connector.setPort(8080);
connector.setIdleTimeout(30_000); // drop idle connections after 30 seconds
connector.setAcceptQueueSize(100); // incoming connection queue
server.addConnector(connector);
// ... add handlers and start
server.start();
server.join();
}
}
Custom Handlers (Without Servlets) #
For very simple cases, Jetty also supports Handler directly without the Servlet API:
import org.eclipse.jetty.server.*;
import org.eclipse.jetty.server.handler.AbstractHandler;
import jakarta.servlet.http.*;
import java.io.IOException;
public class CustomHandler extends AbstractHandler {
@Override
public void handle(String target, Request baseReq,
HttpServletRequest req, HttpServletResponse res)
throws IOException {
res.setContentType("application/json; charset=utf-8");
res.setStatus(HttpServletResponse.SC_OK);
String response = switch (target) {
case "/ping" -> "{\"status\": \"ok\"}";
case "/version" -> "{\"version\": \"1.0.0\"}";
default -> "{\"error\": \"endpoint not found\"}";
};
res.getWriter().println(response);
baseReq.setHandled(true); // mark as handled
}
}
Direct Comparison #
To build a GET /api/products endpoint returning JSON, here’s the comparison of the three approaches:
Servlet API #
@WebServlet("/api/products")
public class ProductServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse res)
throws IOException {
res.setContentType("application/json");
res.getWriter().println("[{\"id\":1,\"name\":\"Laptop\"}]");
}
}
Spring Boot #
@RestController
@RequestMapping("/api")
public class ProductController {
@GetMapping("/products")
public List<Map<String, Object>> products() {
return List.of(Map.of("id", 1, "name", "Laptop"));
}
}
Embedded Jetty #
ctx.addServlet(new ServletHolder(new HttpServlet() {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse res)
throws IOException {
res.setContentType("application/json");
res.getWriter().println("[{\"id\":1,\"name\":\"Laptop\"}]");
}
}), "/api/products");
Spring Boot is the most concise because it handles JSON serialization automatically. Servlets and Jetty require manual serialization or an additional library like Jackson.
When to Use Each Approach #
Use the SERVLET API when:
✓ Learning HTTP basics and how Java web servers work
✓ You need to deploy to an existing app server (Tomcat, JBoss, WebLogic)
✓ Maintaining legacy applications that already use Servlets
✗ Avoid it for new applications — too verbose compared to the alternatives
Use SPRING BOOT when:
✓ Building new REST APIs or microservices
✓ You need a complete ecosystem: security, database, caching, testing
✓ The team is already familiar with Spring
✓ Auto-configuration saves setup time
✓ Almost always — it's the default choice for modern Java web development
Use EMBEDDED JETTY when:
✓ You need a lightweight server embedded in a standalone application
✓ Internal tools or desktop apps with a small web feature
✓ Full control over server configuration without Spring's overhead
✓ Distribution as a single JAR without external dependencies
Production tips for all approaches:
✓ Always set timeouts for requests and connections
✓ Add a logging filter for observability
✓ Handle exceptions centrally — don't let stack traces leak to clients
✓ Set security headers: Content-Security-Policy, X-Frame-Options, etc.
✓ Use HTTPS in production — configure SSL/TLS on the server or reverse proxy
Summary #
- The Servlet API is the foundation — every Java web framework runs on top of it.
HttpServlet,doGet(),doPost(),HttpServletRequest, andHttpServletResponseare building blocks you need to understand.- Spring Boot is the default choice for new applications —
@RestController+@GetMapping/@PostMapping+@RequestBody+ResponseEntityform a clean, idiomatic REST API pattern.ResponseEntityfor full response control — you can set the status code, headers, and body explicitly. UseResponseEntity.ok(),.created(),.notFound(),.badRequest().@RestControllerAdvicefor centralized error handling — instead of try-catch in every controller, catch specific exceptions in one place and return a consistent error format.- Filters for cross-cutting concerns — logging, authentication, CORS, and compression are filter concerns, not controller concerns. Keep them separate so controllers stay focused on business logic.
- Embedded Jetty for single-JAR distribution — good for tools and microservices that need to run anywhere without a server installation.
- Set timeouts — without timeouts, one slow request can block the thread pool and kill the server. Set
server.connection-timeoutin Spring Boot orsetIdleTimeoutin Jetty.- Don’t leak error details to clients — stack traces contain sensitive information. Catch all exceptions and return safe, informative error messages.