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
Hostheader, 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).
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
| Method | Purpose | Has body | Idempotent | Safe |
|---|---|---|---|---|
| GET | Retrieve a resource | No | Yes | Yes |
| POST | Create a resource / trigger a non-idempotent action | Yes | No | No |
| PUT | Replace a resource entirely | Yes | Yes | No |
| PATCH | Partially modify a resource | Yes | Depends on design — see below | No |
| DELETE | Remove a resource | No | Yes | No |
| HEAD | GET's response headers, no body | No | Yes | Yes |
| OPTIONS | Ask what methods a resource allows | No | Yes | Yes |
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.
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 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
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
| Aspect | HTTP | HTTPS |
|---|---|---|
| Default port | 80 | 443 |
| Encryption | None — plaintext on the wire | TLS-encrypted |
| Certificate | Not required | Required — issued by a trusted CA |
| Acceptable use | Never in production | Always, no exceptions |
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
| Feature | HTTP/1.1 | HTTP/2 | HTTP/3 |
|---|---|---|---|
| Connections | One request in flight per connection (pipelining is unused in practice) | Multiplexed over one TCP connection | Multiplexed over one QUIC/UDP connection |
| Format | Text | Binary | Binary |
| Transport | TCP | TCP | QUIC (UDP) |
| Head-of-line blocking | Yes, severely | Fixed at the HTTP layer, still present at the TCP layer | Eliminated — independent streams |
| Header compression | None | HPACK | QPACK |
# application.properties — enabling HTTP/2 (requires TLS)
server.http2.enabled=true
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=nativethe moment your app runs behind any reverse proxy or load balancer - Return a
Locationheader 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
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.
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.