Spring Boot Fundamentals: Spring Boot Request Lifecycle

Introduction
Imagine a user hits your Spring Boot application with a request like this:
GET /hello HTTP/1.1
Host: localhost:8080
How does this request navigate through Spring Boot’s processing pipeline, and what happens at each step? From the embedded server receiving the request to mapping it to a controller method, handling filters, and returning a response — every component plays a vital role.
In this blog, we’ll explore:
- How Spring Boot’s embedded servers (e.g., Tomcat) work.
- The role of the DispatcherServlet and how it maps URLs to methods.
- How filters intercept requests and enhance functionality.
- Techniques to track and monitor requests at the server level.
- The benefits of compression and customization for production use.
Let’s dive into the journey of a request in Spring Boot, step by step.
The Journey Begins: Embedded Server Receives the Request
When a user sends the GET /hello request, the embedded server (Tomcat by default) is the first point of contact. It listens for incoming requests on port 8080.
How Does Tomcat Handle Requests?
Picture this: A user sends the GET /hello request to your server. Tomcat, listening on port 8080, springs into action:
- Tomcat receives the HTTP request on its default port.
- It forwards the request to the DispatcherServlet, the front controller in Spring Boot’s MVC architecture.
And so, the next stop in our journey begins: the DispatcherServlet.
DispatcherServlet: Mapping Requests to Methods
The DispatcherServlet is central to request handling. It maps URLs to specific controller methods and coordinates the flow between other Spring components.
How Does URL Mapping Work?
- During application startup, Spring Boot scans all controllers (@Controller or @RestController) for mappings like @RequestMapping, @GetMapping, etc.
- These mappings are stored in a HandlerMapping registry at runtime.
- Let’s see it in action with an example controller:
@RestController
@RequestMapping("/hello")
public class HelloController {
@GetMapping
public String sayHello() {
return "Hello, World!";
}
}
During startup, Spring Boot maps the /hello URL to the sayHello() method. This mapping is stored in memory.
HandlerMapping registry (Just for Curiosity, feel free to skip this part)
Spring Boot stores all URL-to-controller mappings in the HandlerMapping registry at runtime. You can inspect this registry to understand how your application routes requests.
Here’s how you can do it:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.handler.AbstractHandlerMethodMapping;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import javax.annotation.PostConstruct;
@Component
public class MappingInspector {
@Autowired
private ApplicationContext applicationContext;
@PostConstruct
public void printMappings() {
AbstractHandlerMethodMapping<RequestMappingInfo> handlerMapping =
applicationContext.getBean("requestMappingHandlerMapping", AbstractHandlerMethodMapping.class);
handlerMapping.getHandlerMethods().forEach((key, value) -> {
System.out.println("Mapping: " + key + " -> " + value.getMethod().getName());
});
}
}
What It Does:
- Retrieves the requestMappingHandlerMapping bean, which holds all URL mappings.
- Iterates through the mappings and prints them.
What Happens When the Request Arrives?
- The DispatcherServlet consults the runtime registry.
- It finds the mapping: GET /hello → HelloController.sayHello().
- The sayHello() method is executed dynamically, returning “Hello, World!”.
Why Runtime Mapping?
Spring Boot’s dynamic mapping approach ensures flexibility, allowing mappings to adapt to runtime conditions rather than being fixed at compile time.
Filters: Intercepting the Journey
Before the request reaches its final destination (the controller), it must pass through filters. Think of filters as checkpoints that validate, modify, or track the request.
The Role of Filters
Filters are versatile and can perform tasks like:
- Authentication: Validating user tokens.
- Rate Limiting: Preventing abuse by limiting requests.
- Logging: Recording request and response details.
Example: A Logging Filter
Let’s log every request that comes through:
@Component
public class LoggingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
System.out.println("Incoming Request: " + request.getMethod() + " " + request.getRequestURI());
filterChain.doFilter(request, response); // Continue the journey
}
}
For the /hello request, this filter logs:
Incoming Request: GET /hello
Tracking Requests Beyond the Controller
Sometimes, the journey ends prematurely. For example:
- A malformed request header might trigger a server-level error.
- A CORS violation might block the request outright.
To monitor such scenarios:
- Use filters to log errors early.
- Leverage Actuator’s /actuator/httptrace (more on actuators in the blogs to come)endpoint to track request activity.
Filters are excellent for tracking and processing requests at the servlet level, such as logging or modifying requests before they reach the DispatcherServlet. But what if you need to handle logic specific to controllers, like validating data returned by a method or adding common model attributes?
This is where interceptors come into play.
Why Do We Need Interceptors?
While filters operate globally on all requests, interceptors provide fine-grained control over requests that are handled by Spring MVC controllers. They allow you to:
- Pre-process requests before they reach the controller.
- Post-process responses returned by the controller.
- Clean up after the request is fully completed.
Imagine you want to log the execution time of all controller methods in your application. Filters can’t track controller execution because they don’t interact with Spring MVC. This is a perfect use case for interceptors.
Introducing Interceptors: Controller-Specific Logic
Interceptors are part of the Spring MVC ecosystem and work within the DispatcherServlet context. They allow you to hook into three key stages of request processing:
- preHandle: Executed before the controller method is invoked.
- postHandle: Executed after the controller method returns a response but before the view is rendered.
- afterCompletion: Executed after the entire request is completed, including rendering the view.
Example: Tracking Request Timing with an Interceptor
Let’s implement an interceptor to log the time taken by each controller method:
@Component
public class TimingInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
long startTime = System.currentTimeMillis();
request.setAttribute("startTime", startTime);
System.out.println("Interceptor: Starting timer for " + request.getRequestURI());
return true; // Continue to the next step in the processing chain
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) {
long startTime = (Long) request.getAttribute("startTime");
long duration = System.currentTimeMillis() - startTime;
System.out.println("Interceptor: Controller logic took " + duration + "ms");
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
System.out.println("Interceptor: Completed request for " + request.getRequestURI());
}
}
How to Register Interceptors
Unlike filters, interceptors must be registered explicitly using a configuration class:
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private TimingInterceptor timingInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(timingInterceptor)
.addPathPatterns("/**") // Apply to all requests
.excludePathPatterns("/static/**"); // Exclude static resources
}
}
When to Use Interceptors?
Use interceptors when you need logic tightly coupled with Spring MVC controllers, such as:
- Adding model attributes.
- Validating responses.
- Managing session data.
With filters and interceptors working together, you can track requests from the moment they hit your server (filters) to the point they leave the controller (interceptors). Together, they ensure robust request processing at every stage of the pipeline.
The Final Leg: Building and Compressing the Response
Once the controller processes the request, the response begins its journey back to the client. For our sayHello() method, the response “Hello, World!” is sent as JSON.
Making Responses Faster with Compression
Spring Boot can compress responses to reduce payload size, speeding up transfers.
How to Enable Compression:
server.compression.enabled=true
server.compression.mime-types=text/html,application/json
Why Compression Matters?
Imagine your server sends a 100 KB JSON response. With compression, this can shrink to ~10 KB, reducing bandwidth usage and improving user experience.
Optimizing the Journey: Server Customization
While Spring Boot’s embedded servers work well out of the box, customizing them for production workloads can significantly enhance performance, scalability, and reliability.
Timeout Configurations
Timeouts are critical for ensuring your application doesn’t get stuck waiting on slow or unresponsive clients. Let’s configure common timeouts:
- Connection Timeout: How long the server waits to establish a connection with a client.
- Keep-Alive Timeout: How long a connection remains open for reuse after the first request.
- Read Timeout: How long the server waits for the client to send data.
# Wait 20 seconds for a connection to be established
server.tomcat.connection-timeout=20s
# Keep connections alive for 15 seconds
server.tomcat.keep-alive-timeout=15s
Jetty Timeout Configurations (programmatic example):
@Bean
public JettyServletWebServerFactory jettyFactory() {
return new JettyServletWebServerFactory(server -> {
ServerConnector connector = new ServerConnector(server);
connector.setIdleTimeout(15000); // Keep-alive timeout (15 seconds)
server.addConnector(connector);
});
}
Thread Pool Configurations
In high-traffic environments, tuning the thread pool size can help your server handle concurrent requests more efficiently.
Tomcat Example:
# Maximum number of threads that can be created to handle requests.
server.tomcat.max-threads=300
# Minimum number of threads to keep alive (even when idle).
server.tomcat.min-spare-threads=50
# Maximum number of connections that can be queued when all threads are busy.
server.tomcat.accept-count=100
# Time (in milliseconds) that an idle thread will remain alive before being terminated.
server.tomcat.thread-idle-timeout=60000
# Enable or disable async request processing (useful for async servlets).
server.tomcat.async-supported=true
Thread Pool Configuration: Jetty
For Jetty, thread pool tuning is equally important, especially for lightweight APIs and high-concurrency scenarios. Below is how you configure thread pool properties programmatically:
import org.eclipse.jetty.util.thread.QueuedThreadPool;
import org.springframework.boot.web.embedded.jetty.JettyServletWebServerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class JettyConfig {
@Bean
public JettyServletWebServerFactory jettyFactory() {
return new JettyServletWebServerFactory(server -> {
QueuedThreadPool threadPool = new QueuedThreadPool();
threadPool.setMaxThreads(300); // Maximum threads
threadPool.setMinThreads(50); // Minimum threads
threadPool.setIdleTimeout(60000); // Idle timeout (in milliseconds)
server.setThreadPool(threadPool);
});
}
}
Best Practices for Thread Pool Tuning
- Monitor Application Behavior:
- Use metrics tools like Actuator (/actuator/metrics) to analyze thread usage and performance.
2. Test Under Load:
- Use tools like JMeter or Gatling to simulate real-world traffic and ensure thread pool configurations are sufficient.
3. Scale Based on Hardware:
- Adjust thread counts according to the CPU cores and available memory. Over-allocating threads can lead to resource thrashing.
4. Combine with Timeouts:
- Proper timeout settings (e.g., connection-timeout, keep-alive-timeout) ensure threads don’t get stuck waiting for unresponsive clients.
5. Balance Between Tomcat and Jetty:
- Choose Tomcat for general-purpose workloads or compatibility with legacy applications.
- Choose Jetty for high-concurrency, lightweight APIs, or edge deployments.
Compression and HTTP/2
Both Tomcat and Jetty support response compression and HTTP/2 for faster data transfers.
Enable HTTP/2 in Spring Boot:
server.http2.enabled=true
Why HTTP/2 Matters:
- Reduces latency by allowing multiple concurrent requests over a single connection.
- Optimizes bandwidth usage with header compression and multiplexing.
When to Choose Jetty Over Tomcat
While Tomcat is a robust, general-purpose server, Jetty excels in specific scenarios:
- Serverless and Edge Deployments:
- Jetty’s lightweight design is perfect for edge computing or serverless platforms where resources are constrained.
2. High-Concurrency APIs:
- Jetty’s event-driven architecture allows it to handle thousands of simultaneous requests with lower resource usage.
3. Custom Protocols:
- Jetty supports advanced customization and is often used in environments requiring WebSocket-heavy traffic or custom protocols.
Conclusion
From the embedded server receiving a request to the DispatcherServlet mapping it to a controller, filters intercepting it along the way, and compression speeding up the response — every component in Spring Boot plays a vital role in ensuring seamless request handling.
By understanding this journey, you can optimize your application for performance, scalability, and reliability. Stay tuned for our next blog, where we’ll dive into ORM and database interactions in Spring Boot, taking the journey further into the backend!