HTTP Protocol & Methods

The request/response contract every Java backend speaks — methods, status codes, headers, and the production details that separate a working demo from a correct API

← Back to Index

What is HTTP — and Why Does It Exist?

HTTP (HyperText Transfer Protocol) is a text-based, request/response application-layer protocol: a client sends a request over a TCP (or, since HTTP/3, QUIC/UDP) connection, and a server sends back a response. It exists because before it was standardized, every client and server pair had to agree privately on how to format a message, what a "successful" outcome looked like, and how to signal an error — none of it interoperable between vendors.

What HTTP actually standardizes is narrow and precise: a request line (method, path, version), a set of headers (metadata), an optional body, and on the way back a status line with a numeric code whose first digit has a universally agreed meaning. That's the entire contract — everything else (REST conventions, JSON body shape, authentication schemes) is built on top of it, not part of it.

/*
 * BEFORE HTTP — every client/server pair invents its own wire format.
 * This is not historical fiction; it's what raw-socket protocols still
 * look like today when someone reinvents this problem badly.
 */
// Custom, undocumented, ad-hoc protocol over a raw socket:
outputStream.write("GETUSER|123|JSON\n".getBytes());
// What does "success" look like? What if the user doesn't exist?
// Every client that talks to this server has to know this by folklore.

/*
 * AFTER HTTP — the contract is standardized; only the payload is yours.
 */
// Request (what actually goes over the wire):
GET /api/users/123 HTTP/1.1
Host: api.example.com
Accept: application/json

// Response:
HTTP/1.1 200 OK
Content-Type: application/json

{"id": 123, "name": "Ada Lovelace"}

// Any HTTP client on Earth — curl, a browser, another JVM, Postman —
// already knows how to send this and how to interpret "200".

Versions at a Glance

Each revision solved a concrete, measurable problem with its predecessor — not novelty for its own sake.

  • HTTP/1.0 (1996): One request per TCP connection — a new handshake for every single asset on a page.
  • HTTP/1.1 (1997): Persistent connections (keep-alive) and the mandatory Host header, enabling name-based virtual hosting. Still the baseline every server must support.
  • HTTP/2 (2015): Binary framing and true multiplexing — many requests share one TCP connection without head-of-line blocking at the HTTP layer (TCP itself can still block, see below).
  • HTTP/3 (2022): Replaces TCP with QUIC over UDP, removing TCP-level head-of-line blocking entirely and cutting connection setup to one round trip (zero for resumed connections).
HTTP/3 just became a first-class citizen in the JDK

As of JDK 26 (JEP 517, GA March 2026), java.net.http.HttpClient supports HTTP/3 natively — no third-party library required. The default remains HTTP/2 for compatibility; HTTP/3 is opt-in per client or per request:

HttpClient client = HttpClient.newBuilder()
    .version(HttpClient.Version.HTTP_3)   // races HTTP/3 and HTTP/2, uses whichever connects first
    .build();

If your project targets Java 17–21 LTS, this isn't available yet — it lands with the next LTS. Worth knowing it exists before you read an outdated "Java can't do HTTP/3" claim somewhere.

Anatomy of an HTTP Request

// REQUEST LINE — method, path + query, protocol version
GET /api/products/123?include=reviews HTTP/1.1

// HEADERS — metadata about the request
Host: api.example.com
Accept: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
User-Agent: Mozilla/5.0

// BODY — present only for methods that carry a payload (POST/PUT/PATCH)
{
    "name": "New Product",
    "price": 29.99
}

Reading a Request in Spring

@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @PostMapping
    public ProductResponse createProduct(
            @Valid @RequestBody ProductRequest body,          // parsed from the JSON body
            @RequestParam("category") String category,       // ?category=electronics
            @RequestHeader("Authorization") String authHeader   // raw header value
    ) {
        return productService.create(body, category);
    }
}

public record ProductRequest(@NotBlank String name, @Positive BigDecimal price) {}

HTTP Methods — What Each One Actually Promises

MethodPurposeHas bodyIdempotentSafe
GETRetrieve a resourceNoYesYes
POSTCreate a resource / trigger a non-idempotent actionYesNoNo
PUTReplace a resource entirelyYesYesNo
PATCHPartially modify a resourceYesDepends on design — see belowNo
DELETERemove a resourceNoYesNo
HEADGET's response headers, no bodyNoYesYes
OPTIONSAsk what methods a resource allowsNoYesYes
Idempotent vs Safe — precise definitions, not folklore

Safe means the request has no observable side effect on the server's state — it can be prefetched, cached, or retried by any intermediary without asking permission. Idempotent is weaker: repeating the exact same request N times produces the same end state as doing it once, but side effects (like writing a row) are allowed on the first call. A DELETE /users/123 called twice still ends with the user gone either way — idempotent, but not safe.

PATCH's idempotency depends entirely on how you design it

The HTTP spec (RFC 5789) does not mandate PATCH be idempotent, and whether yours is depends on the semantics you give the request body. A patch that sets absolute values — {"email": "new@example.com"} — is idempotent: sending it five times leaves the same final state as sending it once. A patch expressed as a delta — {"loginCount": "+1"} or a JSON Patch "add" operation on an array — is not: each repetition changes the result. If a client might retry a PATCH after a timeout without knowing whether it succeeded, design the payload to be idempotent, or the retry will silently corrupt state.

GET and POST in Practice

@GetMapping("/api/users")
public Page<UserResponse> getUsers(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size) {
    return userService.findAll(PageRequest.of(page, size));
}

@PostMapping("/api/users")
public ResponseEntity<UserResponse> createUser(@Valid @RequestBody CreateUserRequest request) {
    UserResponse created = userService.create(request);
    URI location = URI.create("/api/users/" + created.id());
    return ResponseEntity.created(location).body(created);   // 201 + Location header — clients should follow it, not guess the URL
}

PUT vs PATCH — a Real Bug This Distinction Prevents

// PUT replaces the ENTIRE resource. Omitted fields are cleared, not left alone.
@PutMapping("/api/users/{id}")
public UserResponse replaceUser(@PathVariable Long id, @Valid @RequestBody UserRequest request) {
    return userService.replace(id, request);
    // If the client omits "phone" because it didn't change, this WILL null it out.
    // This is a genuinely common production bug: a mobile client built assuming
    // PUT means "update what I sent" instead of "replace everything with what I sent."
}

// PATCH updates only the fields present in the request.
@PatchMapping("/api/users/{id}")
public UserResponse updateUser(@PathVariable Long id, @RequestBody Map<String, Object> updates) {
    return userService.partialUpdate(id, updates);   // only touches keys actually present in the map
}

HTTP Status Codes

2xx — Success

200 OK          // standard success — GET, PUT, PATCH
201 Created     // POST that created a resource — MUST include a Location header
202 Accepted    // request accepted, processing is async — body has no final result yet
204 No Content  // success, deliberately empty body — DELETE, some PUTs

return ResponseEntity.ok(product);                       // 200
return ResponseEntity.created(location).body(product);   // 201
return ResponseEntity.noContent().build();               // 204

4xx — Client Errors, Precisely

400 Bad Request         // malformed syntax or failed validation — the request itself is broken
401 Unauthorized        // no valid credentials presented — "I don't know who you are"
403 Forbidden           // credentials are valid, but you're not allowed — "I know you, the answer is no"
404 Not Found           // resource doesn't exist (or you're hiding that it does — see below)
405 Method Not Allowed  // wrong verb for this path
409 Conflict            // request conflicts with current state (e.g. optimistic-lock version mismatch)
422 Unprocessable Entity// syntactically valid, semantically invalid (e.g. end date before start date)
429 Too Many Requests   // rate limited — should include a Retry-After header
401 vs 403 is the single most common status-code mistake

401 means the server doesn't know who you are — missing or invalid credentials; the correct client reaction is "log in." 403 means the server knows exactly who you are and the answer is still no; there is nothing a login prompt fixes. Returning 401 for an authorization failure sends the client into a login loop for a problem login can't solve. A related, deliberate choice some APIs make: returning 404 instead of 403 for a resource a user isn't allowed to see, so an attacker probing IDs can't even confirm the resource exists. Whether that's the right call depends on your threat model — it's a real trade-off, not a universal rule.

Production APIs Return Structured Errors, Not Strings

// Spring Boot 3.x ships RFC 9457 "Problem Details" support out of the box —
// no hand-rolled error DTO needed.
@ExceptionHandler(ProductNotFoundException.class)
public ProblemDetail handleNotFound(ProductNotFoundException ex) {
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    problem.setTitle("Product not found");
    problem.setProperty("productId", ex.getProductId());
    return problem;
}

// Serializes as:
// {
//   "type": "about:blank",
//   "title": "Product not found",
//   "status": 404,
//   "detail": "No product with id 456",
//   "productId": 456
// }
// Content-Type: application/problem+json — machine-parseable, not a plain string

5xx — Server Errors

500 Internal Server Error  // unhandled exception — you own this, fix the bug
502 Bad Gateway            // your reverse proxy got a broken response from your app
503 Service Unavailable    // overloaded or in maintenance — pair with Retry-After
504 Gateway Timeout        // upstream didn't respond in time

@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception ex) {
    log.error("Unhandled exception", ex);   // log the real cause server-side...
    return ProblemDetail.forStatusAndDetail(
        HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred");
    // ...but NEVER return ex.getMessage() or a stack trace to the client —
    // that's a routine way internal details (query fragments, class names,
    // file paths) leak to whoever is calling your API.
}

Headers That Actually Show Up in Production

Request Headers

Accept: application/json           // what format the client wants back
Authorization: Bearer eyJ...       // credentials — token, not a session cookie, for stateless APIs
Content-Type: application/json     // format of THIS request's body
If-None-Match: "a1b2c3"            // conditional GET — server replies 304 if the ETag still matches
X-Request-Id: 7e4b1e2a-...         // client-generated correlation ID, propagated through logs and traces

Response Headers

Content-Type: application/json     // format of the response body
Location: /api/users/456           // where the created/redirected resource lives (201, 3xx)
Cache-Control: max-age=3600        // how long an intermediary may cache this
ETag: "a1b2c3"                     // fingerprint of the current representation, used with If-None-Match
Retry-After: 30                    // paired with 429 or 503 — tells the client exactly when to retry
Headers you'll meet the moment there's a reverse proxy in front of your app

The instant your Spring Boot app sits behind Nginx, an ALB, or any API gateway (which in production, it always does — see below), the connection it sees is between itself and the proxy, not the real client. X-Forwarded-For carries the original client IP, X-Forwarded-Proto carries whether the original request was HTTP or HTTPS. Spring Boot must be explicitly told to trust and use them:

# application.properties
server.forward-headers-strategy=native

Without this, request.getRemoteAddr() returns the proxy's IP for every single request, and request.isSecure() incorrectly reports false even though the original client connected over HTTPS.

HTTP vs HTTPS — and Where TLS Actually Gets Terminated in Production

AspectHTTPHTTPS
Default port80443
EncryptionNone — plaintext on the wireTLS-encrypted
CertificateNot requiredRequired — issued by a trusted CA
Acceptable useNever in productionAlways, no exceptions
In most production deployments, your Spring Boot app never terminates TLS itself

The textbook setup — a keystore inside the JVM, Tomcat handling the TLS handshake — is real, but it's the exception in cloud-native deployments, not the rule. The common production topology terminates TLS at a load balancer, reverse proxy, or ingress controller (an AWS ALB, an Nginx instance, a Kubernetes Ingress), and the traffic from there to your JVM runs as plain HTTP inside a private network. Your application code needs server.forward-headers-strategy=native (Section 4) far more often than it needs a keystore.

Option A — TLS Terminated Upstream (the common case)

# application.properties — the app itself speaks plain HTTP
# and trusts the proxy's X-Forwarded-* headers
server.port=8080
server.forward-headers-strategy=native

Option B — TLS Terminated by the JVM Itself

Legitimate when there's no proxy in front of you at all — an internal tool, a single-instance deployment, local development against a real certificate.

# application.properties
server.port=8443
server.ssl.enabled=true
server.ssl.key-store=classpath:keystore.p12
server.ssl.key-store-password=${SSL_KEYSTORE_PASSWORD}
server.ssl.key-store-type=PKCS12
// Redirecting a plaintext port to the TLS port — the connector actually built,
// not left dangling as an undefined method call:
@Configuration
public class HttpsRedirectConfig {

    @Bean
    public ServletWebServerFactory servletContainer() {
        TomcatServletWebServerFactory tomcat = new TomcatServletWebServerFactory();
        tomcat.addAdditionalTomcatConnectors(httpToHttpsRedirectConnector());
        return tomcat;
    }

    private Connector httpToHttpsRedirectConnector() {
        Connector connector = new Connector(TomcatServletWebServerFactory.DEFAULT_PROTOCOL);
        connector.setScheme("http");
        connector.setPort(8080);
        connector.setSecure(false);
        connector.setRedirectPort(8443);   // any HTTP request on 8080 gets redirected here
        return connector;
    }
}

HTTP/1.1 vs HTTP/2 vs HTTP/3

FeatureHTTP/1.1HTTP/2HTTP/3
ConnectionsOne request in flight per connection (pipelining is unused in practice)Multiplexed over one TCP connectionMultiplexed over one QUIC/UDP connection
FormatTextBinaryBinary
TransportTCPTCPQUIC (UDP)
Head-of-line blockingYes, severelyFixed at the HTTP layer, still present at the TCP layerEliminated — independent streams
Header compressionNoneHPACKQPACK
# application.properties — enabling HTTP/2 (requires TLS)
server.http2.enabled=true
Why HTTP/2 fixed head-of-line blocking at one layer but not another

HTTP/2 multiplexes multiple logical streams onto one TCP connection — no more one-request-per-connection queuing at the HTTP level. But TCP itself still guarantees strictly ordered delivery: if a single packet is lost, TCP holds up every multiplexed stream until that packet is retransmitted, even streams whose data already arrived. That's the head-of-line blocking HTTP/3 removes by moving to QUIC, where each stream's loss recovery is independent.

Testing HTTP from the Command Line

# GET with headers
curl -H "Authorization: Bearer $TOKEN" \
     -H "Accept: application/json" \
     https://api.example.com/users

# POST with a JSON body
curl -X POST \
     -H "Content-Type: application/json" \
     -d '{"name": "John", "email": "john@example.com"}' \
     https://api.example.com/users

# -i shows response headers, -v is full verbose (TLS handshake, timing, everything)
curl -i https://api.example.com/users
curl -v https://api.example.com/users

# -w prints timing breakdown — genuinely useful when diagnosing "why is this slow"
curl -o /dev/null -s -w "DNS: %{time_namelookup}s  Connect: %{time_connect}s  Total: %{time_total}s\n" \
     https://api.example.com/users

Best Practices and Common Pitfalls

✅ Do

  • Return ProblemDetail (RFC 9457) for errors — structured, machine-parseable, and built into Spring Boot 3.x with no extra dependency
  • Use 401 for "I don't know who you are" and 403 for "I know exactly who you are and the answer is no" — never the reverse
  • Design PATCH payloads to be idempotent whenever a client might retry after a timeout without knowing the outcome
  • Set server.forward-headers-strategy=native the moment your app runs behind any reverse proxy or load balancer
  • Return a Location header on every 201 response so clients don't have to guess or reconstruct the created resource's URL

❌ Don't

  • Don't leak stack traces, exception messages, or internal class names in error responses — log them server-side, return a generic detail to the client
  • Don't assume PATCH is automatically safe to retry — it depends entirely on whether your payload expresses absolute values or deltas
  • Don't configure a keystore and manual HTTPS redirect in your Spring Boot app if a load balancer or ingress in front of it already terminates TLS — you'll be solving a problem that doesn't exist at that layer and missing the one that does (trusting forwarded headers)
  • Don't return 200 OK with an error described in the JSON body — if the request failed, the status code must say so

Interview Questions

🎓 Junior level

Q: What's the difference between PUT and PATCH?
PUT replaces the entire resource with the payload sent — any field omitted is treated as cleared. PATCH modifies only the fields present in the request, leaving the rest untouched.

Q: What does it mean for an HTTP method to be idempotent?
Calling it once or calling it five times in a row leaves the server in the same final state. GET, PUT, and DELETE are idempotent by definition; POST is not, because calling it twice typically creates two resources instead of one.

Q: What is the difference between a 401 and a 403 status code?
401 means the server doesn't recognize valid credentials — the client isn't authenticated. 403 means the server knows exactly who the client is and has decided they're not allowed to do this — the problem isn't identity, it's permission.

🔥 Senior level

Q: Your API's PATCH endpoint accepts {"loginCount": "+1"}-style deltas. A mobile client on a flaky connection retries the request after a timeout, not knowing if the first attempt succeeded. What goes wrong, and how do you fix it?
A delta-based PATCH is not idempotent: if the first request actually succeeded before the client's timeout fired, the retry increments the counter a second time, silently corrupting the count. The fix is either to make the payload idempotent — send the target absolute value instead of a delta — or, when a delta genuinely must be used, to require an idempotency key on the request so the server can recognize and safely no-op a retried request it already processed.

Q: You've deployed a Spring Boot app behind an AWS ALB that terminates TLS. Logs show every request coming from the same internal IP, and request.isSecure() returns false even though users are connecting over HTTPS. What's happening and how do you fix it?
The ALB terminates TLS and forwards plain HTTP to the application inside the private network, attaching X-Forwarded-For and X-Forwarded-Proto headers with the real client's original IP and scheme. If Spring Boot isn't told to trust and translate those headers, every request appears to come from the load balancer itself, over plain HTTP, from the servlet container's point of view. Setting server.forward-headers-strategy=native makes Spring translate those headers so getRemoteAddr() and isSecure() reflect the original client's connection, not the hop from the ALB.

Q: HTTP/2 was supposed to eliminate head-of-line blocking. Why do some users on lossy networks (mobile, satellite) still experience it, and what does HTTP/3 change?
HTTP/2 solved head-of-line blocking at the HTTP framing layer by multiplexing multiple streams over one TCP connection, but TCP itself still enforces strict, ordered, reliable delivery. A single lost packet forces TCP to pause delivery of every multiplexed HTTP/2 stream until that packet is retransmitted, even for streams whose data has already fully arrived — the blocking just moved down one layer instead of disappearing. HTTP/3 fixes this by discarding TCP in favor of QUIC, where each stream's loss recovery is independent, so one dropped packet only stalls the stream it belonged to.