Request/Response Cycle

The mechanics of a single HTTP exchange โ€” request anatomy, the Spring MVC pipeline, and the response-handling bug nearly every developer ships at least once

← Back to Index

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);
}
The single most misunderstood fact about 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
}
ComponentExamplePurpose
MethodPOSTThe action being requested
Path/api/ordersWhich resource is targeted
Host headerapi.example.comWhich virtual host on the server should handle this (mandatory since HTTP/1.1)
Content-Typeapplication/jsonFormat of the body being sent
BodyJSON payloadThe 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 decoration

In 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();
}
The response body can only be read once

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;
    }
}
Filter or Interceptor โ€” which one?

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
}
In practice, almost nobody serves XML anymore

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));
}
Virtual Threads change when you actually need this

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 verbatim

That 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 ProblemDetail for every error response so failures are machine-parseable, not just human-readable strings
  • Reach for Virtual Threads before reaching for CompletableFuture when 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 without response.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 @PathVariable already does it correctly and safely

Interview Questions

๐ŸŽ“ Junior level

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

๐Ÿ”ฅ Senior level

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.