The Request/Response Cycle โ and the Bug Almost Everyone Ships Once
HTTP Protocol & Methods covers what a request and a response are structurally. This page is about the cycle โ the fact that sending a request is only half the work, and the most common production bugs live in the half developers skip: actually inspecting what came back before acting on it.
/*
* BEFORE โ the request is sent, and the response is trusted blindly.
* This code "works" in every happy-path demo and manual test.
*/
async function loadUserProfile(userId) {
const response = await fetch(`/api/users/${userId}`);
const user = await response.json(); // assumes the body is always valid JSON
renderProfile(user);
}
// The day the server returns a 500 with an HTML error page (a load balancer's
// default error page, or an unhandled exception rendering Whitelabel Error Page),
// response.json() throws "SyntaxError: Unexpected token '<'". The stack trace
// points at a JSON parser, not at "the request failed" โ this is a genuinely
// common production incident that takes longer to diagnose than it should,
// purely because the actual failure (a 500) never got checked for.
/*
* AFTER โ the cycle is closed properly: status is checked before the body
* is ever touched.
*/
async function loadUserProfile(userId) {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) { // true only for 2xx โ fetch() does NOT reject on 4xx/5xx
if (response.status === 404) {
throw new UserNotFoundError(userId);
}
throw new Error(`Request failed with status ${response.status}`);
}
const user = await response.json(); // only parsed once we KNOW it's the success shape
renderProfile(user);
}
fetch()fetch()'s promise only rejects on network
failure (DNS failure, connection refused, CORS block). A
404 or a 500 is still a "successful" fetch as far as the promise is
concerned โ the server answered, it just answered with bad news.
This is precisely why response.ok must be checked
explicitly; nothing else in the Fetch API will do it for you.
Anatomy of a Request
// REQUEST LINE โ method, path, protocol version
POST /api/orders HTTP/1.1
// HEADERS โ metadata about this request
Host: api.example.com
Content-Type: application/json
Content-Length: 61
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Accept: application/json
// BLANK LINE separates headers from body
// BODY โ present only for methods that carry a payload
{
"productId": 42,
"quantity": 2
}
| Component | Example | Purpose |
|---|---|---|
| Method | POST | The action being requested |
| Path | /api/orders | Which resource is targeted |
| Host header | api.example.com | Which virtual host on the server should handle this (mandatory since HTTP/1.1) |
| Content-Type | application/json | Format of the body being sent |
| Body | JSON payload | The actual data โ absent for GET, DELETE, HEAD |
Anatomy of a Response
// STATUS LINE โ protocol, status code, reason phrase
HTTP/1.1 201 Created
// HEADERS
Content-Type: application/json
Location: /api/orders/9001
Date: Thu, 23 Jul 2026 10:30:00 GMT
X-Request-Id: 7e4b1e2a-4f3c-4a1b-9c2d-8f1e2a3b4c5d
// BODY
{
"id": 9001,
"productId": 42,
"quantity": 2,
"status": "PENDING"
}
X-Request-Id is not decorationIn any system with more than one service or more than one server instance, a request ID generated at the edge and echoed back in the response is what lets you find this exact request across every log line it touched. Without it, correlating "the request the user just complained about" to a specific line in a specific service's logs is guesswork. If your stack doesn't set one yet, it is one of the highest-value-per-line-of-code additions you can make to a production API.
The Cycle on the Server: What Spring MVC Actually Saves You From
Raw Servlet โ Everything Done by Hand
@WebServlet("/api/users/*")
public class UserServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws IOException {
// Manual path parsing โ no @PathVariable to do this for you
String pathInfo = request.getPathInfo(); // "/123"
Long userId = Long.parseLong(pathInfo.substring(1));
Optional<User> user = userService.findById(userId);
if (user.isEmpty()) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"User not found\"}");
return;
}
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(user.get()));
}
}
Spring MVC โ the Same Cycle, the Boilerplate Removed
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
return userService.findById(id)
.map(UserResponse::fromEntity)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
// Spring parsed the path variable, will serialize the record to JSON,
// and sets Content-Type automatically. The path parsing bug above โ
// forgetting the leading "/" in substring(1) โ simply can't happen here.
}
}
// A record DTO, never the JPA entity โ see client-server.html for why
public record UserResponse(Long id, String name, String email) {
public static UserResponse fromEntity(User user) {
return new UserResponse(user.getId(), user.getName(), user.getEmail());
}
}
The Cycle in the Browser: Fetch API
async function createOrder(orderData, token) {
const response = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(orderData)
});
if (!response.ok) {
const problem = await response.json().catch(() => null); // body might not even be JSON
throw new Error(problem?.detail ?? `Request failed: ${response.status}`);
}
if (response.status === 201) {
const location = response.headers.get('Location');
console.log('Order created at:', location);
}
return response.json();
}
response.json(), .text(), and
.blob() all consume the underlying stream. Calling a
second one after the first throws
TypeError: body stream already read. If you need the
raw text for logging and the parsed JSON, call
response.clone() before consuming either one.
What Actually Happens Between "Request Arrives" and "Controller Runs"
Request Arrives
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโ
โ 1. FILTER CHAIN โ Security, CORS, logging โ runs for every request, controller or not
โโโโโโโโโโโโฌโโโโโโโโโโโโ
โผ
โโโโโโโโโโโโโโโโโโโโโโโ
โ 2. DISPATCHER โ Spring's front controller โ the single entry point for all MVC requests
โ SERVLET โ
โโโโโโโโโโโโฌโโโโโโโโโโโโ
โผ
โโโโโโโโโโโโโโโโโโโโโโโ
โ 3. HANDLER MAPPING โ Matches the URL + method to a specific @RequestMapping method
โโโโโโโโโโโโฌโโโโโโโโโโโโ
โผ
โโโโโโโโโโโโโโโโโโโโโโโ
โ 4. INTERCEPTORS โ preHandle() โ auth checks, request timing, before the controller runs
โโโโโโโโโโโโฌโโโโโโโโโโโโ
โผ
โโโโโโโโโโโโโโโโโโโโโโโ
โ 5. CONTROLLER โ Your method โ argument resolution, validation, business logic
โโโโโโโโโโโโฌโโโโโโโโโโโโ
โผ
โโโโโโโโโโโโโโโโโโโโโโโ
โ 6. INTERCEPTORS โ postHandle() / afterCompletion() โ logging, cleanup
โโโโโโโโโโโโฌโโโโโโโโโโโโ
โผ
โโโโโโโโโโโโโโโโโโโโโโโ
โ 7. MESSAGE โ Jackson serializes the return value to JSON (HttpMessageConverter)
โ CONVERTER โ
โโโโโโโโโโโโฌโโโโโโโโโโโโ
โผ
Response Sent
Two concrete, real classes that live at steps 1 and 4 โ useful the day you need to add cross-cutting behavior instead of repeating it in every controller method:
// Step 1 โ a filter: runs for EVERY request, even ones with no matching controller
@Component
public class RequestTimingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
long start = System.nanoTime();
try {
chain.doFilter(request, response);
} finally {
long durationMs = (System.nanoTime() - start) / 1_000_000;
log.info("{} {} -> {} ({} ms)",
request.getMethod(), request.getRequestURI(), response.getStatus(), durationMs);
}
}
}
// Step 4 โ an interceptor: only runs for requests that MATCH a controller
@Component
public class AuthCheckInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (!isAuthenticated(request)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false; // short-circuits the chain โ the controller is never invoked
}
return true;
}
}
Use a Filter for concerns that must apply regardless of whether a controller ends up handling the request at all โ CORS, request logging, gzip compression. Use an Interceptor when you need access to Spring MVC concepts like which handler method was matched, because it runs inside the DispatcherServlet, after routing has already happened.
Content Negotiation
The client states what representation it wants via the
Accept header; the server picks the best match it can
actually produce.
// Client prefers JSON, will accept XML at 90% preference
GET /api/users/123 HTTP/1.1
Accept: application/json, application/xml;q=0.9
@GetMapping(value = "/{id}", produces = { MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE })
public UserResponse getUser(@PathVariable Long id) {
return userService.findById(id); // Spring picks JSON or XML based on the Accept header, automatically
}
Multi-format produces is a real Spring MVC feature and
worth knowing for the exam-style question, but the overwhelming
majority of production REST APIs in 2026 serve JSON exclusively.
Reach for this when you have an actual consumer that requires XML
(often a legacy enterprise integration), not as a default posture.
Closing the Cycle Correctly: Error Handling
The full ProblemDetail (RFC 9457) pattern is covered in
depth in HTTP Protocol & Methods โ this is
how it plugs into the request/response cycle specifically: exceptions
thrown anywhere in step 5 of the pipeline above are intercepted before
a response is ever written.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(UserNotFoundException.class)
public ProblemDetail handleNotFound(UserNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
List<String> errors = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.toList();
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
problem.setProperty("errors", errors);
return problem;
}
}
// @RestControllerAdvice sits OUTSIDE any single controller โ it intercepts
// exceptions from every controller, at the exact point in the pipeline
// (step 5โ7 above) where the response would otherwise never get built.
When the Cycle Takes a While: Async Processing and Virtual Threads
// Synchronous โ the platform thread is blocked for the full duration
@GetMapping("/api/reports/{id}")
public Report getReport(@PathVariable Long id) {
return reportService.generateReport(id); // might take 30 seconds of mostly I/O wait
}
// Asynchronous โ the platform thread is released while the work runs elsewhere
@GetMapping("/api/reports/{id}")
public CompletableFuture<Report> getReportAsync(@PathVariable Long id) {
return CompletableFuture.supplyAsync(() -> reportService.generateReport(id));
}
The entire reason to reach for CompletableFuture or
DeferredResult historically was thread economy: a
traditional Tomcat platform-thread pool is small (often ~200
threads), and a controller blocked waiting on a slow database
query or a downstream HTTP call ties up one of those threads for
the whole wait, capping how many concurrent slow requests the
server can handle at all. With Virtual Threads (Java 21+,
spring.threads.virtual.enabled=true on Spring Boot
3.2+), each request runs on a cheap virtual thread; when
it blocks on I/O, the underlying platform (carrier) thread is
freed to run other virtual threads instead of sitting idle. The
plain synchronous version above scales to a similar order of
concurrent I/O-bound requests as the CompletableFuture
version, without the readability cost of the async style. This
does not replace async processing for CPU-bound
work, or for genuinely long-running background jobs that should
outlive the request entirely โ that's still a queue and a
worker, not a virtual thread.
# application.properties โ opt in to virtual threads for the whole app
spring.threads.virtual.enabled=true
Debugging a Broken Cycle
# 1. curl -v โ see the exact bytes on the wire, request and response
curl -v -X POST http://localhost:8080/api/orders \
-H "Content-Type: application/json" \
-d '{"productId": 42, "quantity": 2}'
# 2. Spring's own request/response logging
logging.level.org.springframework.web=DEBUG
# 3. A logging filter with full payload capture โ use sparingly, it logs bodies
@Component
public class RequestLoggingFilter extends CommonsRequestLoggingFilter {
public RequestLoggingFilter() {
setIncludeQueryString(true);
setIncludeHeaders(true);
setIncludePayload(true);
setMaxPayloadLength(10_000);
}
}
setIncludePayload(true) logs request bodies verbatimThat includes passwords, tokens, and PII if they appear in a JSON body. Never enable this filter unconditionally in a production profile โ gate it behind a dev/staging profile, or add explicit field redaction before this ships anywhere near real user data.
Best Practices and Common Pitfalls
โ Do
- Always check
response.ok(or the equivalent status check) before touching the response body โfetch()does not reject on 4xx/5xx - Return record DTOs from controllers, mapped explicitly from entities โ never serialize the entity itself
- Set and propagate a request ID (
X-Request-Id) from the edge through every log line touched by that request - Use
ProblemDetailfor every error response so failures are machine-parseable, not just human-readable strings - Reach for Virtual Threads before reaching for
CompletableFuturewhen the only goal is not blocking a platform thread during I/O wait
โ Don't
- Don't assume a resolved
fetch()promise means success โ it only means the network round-trip completed - Don't call
response.json()(or any body-reading method) twice on the same response withoutresponse.clone()first - Don't log full request payloads in production without redacting credentials and PII first
- Don't parse path segments manually with
substring()when@PathVariablealready does it correctly and safely
Interview Questions
Q: Does a JavaScript fetch() call throw an error when the server returns a 404?
No. The promise returned by fetch() only rejects on a
network-level failure. A 404 or 500 is still a "resolved" promise โ
you have to check response.ok or
response.status yourself to detect it.
Q: What's the difference between a Servlet Filter and a Spring MVC Interceptor?
A Filter is part of the Servlet spec and runs for every request that
reaches the servlet container, whether or not a controller ends up
handling it. An Interceptor is Spring-specific and runs inside the
DispatcherServlet, after the framework has already matched the request
to a specific controller method.
Q: What are the four stages every request/response cycle goes through, described simply?
The client builds and sends a request, the server processes it
(authenticate, run business logic, query data), the server sends back
a response, and the client processes that response (checks the status,
then reads the body).
Q: A frontend team reports intermittent "Unexpected token '<' in JSON" errors in production, only under load. Where do you look first, and why?
That error means response.json() was called on a body
that isn't JSON โ almost always an HTML error page. Under load
specifically, the likely cause is a reverse proxy or load balancer
returning its own HTML error page (a 502/503/504) when the application
is overwhelmed or a health check fails, rather than the application
itself returning a JSON error. The fix has two parts: the frontend
must check response.ok before parsing (see Section 0),
and the actual root cause โ why the app is failing under load, whether
it's connection pool exhaustion, thread starvation, or a downstream
dependency timing out โ needs its own investigation, because the
parsing error is a symptom, not the disease.
Q: You migrate a synchronous, blocking controller to Virtual Threads instead of rewriting it with CompletableFuture. What have you actually changed, and what have you not changed?
You've changed which thread type carries the request: a cheap virtual
thread now, instead of a scarce platform thread from Tomcat's pool.
When that virtual thread blocks on I/O โ a JDBC call, an HTTP call to
another service โ the JDK unmounts it from its carrier platform
thread, freeing that carrier to run other virtual threads, so blocking
I/O no longer caps concurrency the way it did with a fixed platform
thread pool. What you have not changed: the code is still
fundamentally synchronous and blocking from the programmer's
perspective โ there's no reactive composition, no
thenApply chaining, and CPU-bound work still occupies its
carrier thread exactly as before. Virtual threads solve thread
economy for I/O-bound blocking; they do not turn blocking CPU work
into non-blocking work, and they are not a substitute for a proper
job queue when work should outlive the request lifecycle.
Q: Why does a @RestControllerAdvice-based global exception handler still need each individual controller to fail correctly (throwing, not swallowing), for the response cycle to complete as intended?
@RestControllerAdvice intercepts exceptions that
propagate out of a controller method โ it has no visibility into a
catch block that swallows an exception and returns a
normal-looking response anyway. If a controller catches an exception,
logs it, and returns ResponseEntity.ok() regardless, the
global handler never runs, the client receives a 200 with possibly
incomplete or default data, and the failure is invisible anywhere
except a log line nobody's alerting on. The exception handler is only
as reliable as the discipline of every controller method actually
letting failures propagate.