system-design

HTTP, HTTPS, and TLS

The protocol every API you design will speak, the security layer under it, and the caching semantics that most engineers never learn.

Prerequisites: Networking 101, TCP, UDP, IP Time to read: ~22 minutes


The problem

TCP gives you a stream of bytes between two machines. That’s not enough to build anything. You need agreed answers to:

HTTP answers the first four. TLS answers the last.


HTTP: the shape of a request

HTTP is text (in 1.1 — binary in 2 and 3, but the semantics are identical). A request:

GET /users/42?fields=name,email HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGci...
Accept: application/json
If-None-Match: "a3f5c9"
User-Agent: MyApp/1.2

And a response:

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 47
Cache-Control: private, max-age=60
ETag: "a3f5c9"

{"id":42,"name":"Bilal","email":"b@example.com"}

Four parts to each: a start line, headers, a blank line, and an optional body. That’s the entire protocol structure. Everything else is convention layered on top.

HTTP is stateless. The server remembers nothing between requests. Every request must carry everything needed to serve it (usually a token or cookie). This is not a limitation — it is the property that makes horizontal scaling possible. Any server can serve any request, so you can add and remove servers freely. → Scalability


Methods, and the two properties that actually matter

Method Purpose Safe? Idempotent? Cacheable?
GET Read
HEAD Read headers only
POST Create / process Rarely
PUT Replace at a known URI
PATCH Partial update ❌ (usually)
DELETE Remove
OPTIONS Capability discovery / CORS preflight

Two words carry most of the weight here:

Safe = no side effects. A crawler, a browser prefetcher, or a corporate proxy may issue safe requests without asking you. 🚨 This is why GET /deleteUser?id=42 is a genuine catastrophe waiting for a search engine crawler to find it.

Idempotent = doing it twice has the same effect as doing it once. This is the property that makes retries safe, and retries are unavoidable in distributed systems, because when a request times out you have no idea whether it succeeded.

The fix for POST is an idempotency key: the client generates a unique key, sends it in a header, and the server records it and returns the original result on a repeat. Stripe’s API is the canonical example. → Idempotency


Status codes that matter

Learn the classes first: 1xx informational, 2xx success, 3xx redirect, 4xx your fault, 5xx my fault.

Code Meaning When you’d return it
200 OK Success with a body Normal reads
201 Created Resource created POST that made something; include a Location header
202 Accepted Queued, not done yet Async work — you’ll poll or get a webhook
204 No Content Success, nothing to say DELETE, or a PUT with no useful body
301 / 308 Moved permanently Domain change. Cached aggressively and hard to undo — be careful.
302 / 307 Moved temporarily Short URL redirects, A/B tests
304 Not Modified Your cached copy is still good Response to If-None-Match — saves the whole body
400 Bad Request Malformed Validation failure
401 Unauthorized Not authenticated Missing/invalid token (badly named — it means unauthenticated)
403 Forbidden Authenticated but not allowed Wrong permissions
404 Not Found No such resource Also used to hide existence from unauthorized users
409 Conflict State conflict Optimistic-concurrency version mismatch, duplicate creation
422 Unprocessable Syntactically fine, semantically wrong Business-rule violation
429 Too Many Requests Rate limited Always include Retry-AfterRate Limiting
500 Internal Server Error We broke Unhandled exception
502 Bad Gateway Upstream returned garbage Your LB couldn’t get a valid response
503 Service Unavailable Overloaded/maintenance Shed load here; include Retry-After
504 Gateway Timeout Upstream too slow The timeout you set fired

🚨 The 4xx/5xx distinction is not pedantry — it drives behaviour. Clients retry 5xx and 429. They must not retry 400 or 422, because the request will never succeed. Returning 500 for validation errors causes clients to retry forever and turns a bug into an outage. Your monitoring and error budgets also key off this split.


Headers you’ll actually use

Caching — the most under-used part of HTTP:

Header Does
Cache-Control: public, max-age=3600 Anyone may cache for an hour
Cache-Control: private, max-age=60 Only the browser, not shared caches/CDNs
Cache-Control: no-store Never store (use for sensitive data)
Cache-Control: no-cache Store, but revalidate before each use (not “don’t cache”)
Cache-Control: stale-while-revalidate=60 Serve stale instantly, refresh in background — excellent for latency
ETag: "a3f5c9" A version fingerprint for the resource
If-None-Match: "a3f5c9" Client: “only send it if it changed” → gets 304 if not
Last-Modified / If-Modified-Since Same idea, timestamp-based, weaker
Vary: Accept-Encoding, Authorization Cache key must include these headers

📐 A 304 Not Modified is ~200 bytes instead of a 500 KB body. On a mobile network that’s the difference between instant and a visible pause. Conditional requests are free performance, and most APIs never implement them.

🚨 Vary is a classic bug source: forget Vary: Authorization on a CDN-cached endpoint and you will serve one user’s private data to everyone. This has happened to real companies.

Content negotiation: Content-Type, Accept, Accept-Encoding: gzip, br, Content-Encoding.

Identity: Authorization: Bearer <token>, Cookie / Set-Cookie.

Operational (add these, thank yourself later): X-Request-ID / traceparent for tracing, Retry-After, and rate-limit headers (X-RateLimit-Remaining, X-RateLimit-Reset).


Cookies, briefly

Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax; Max-Age=86400

The flags are the entire security story:

Sessions, JWT, OAuth


HTTPS and TLS

HTTP over the open internet is plaintext. Anyone on the path — the coffee shop router, the ISP, a compromised switch — can read every byte and modify it. TLS fixes three separate things:

  1. Confidentiality — nobody can read it.
  2. Integrity — nobody can modify it undetected.
  3. Authentication — you’re actually talking to example.com, not an impostor.

That third one is the part people forget, and it’s the hardest. Encryption is easy; knowing who you encrypted to is the whole problem.

The handshake, conceptually

sequenceDiagram
    participant C as Client
    participant S as Server
    C->>S: ClientHello (TLS versions, cipher suites, random, SNI)
    S->>C: ServerHello (chosen cipher) + Certificate + key share
    Note over C: Verify certificate chain against trusted CAs<br/>Check hostname, expiry, revocation
    C->>S: key share, Finished
    Note over C,S: Both derive the same session key
    C->>S: Encrypted HTTP request

TLS 1.3 does this in one round trip (down from two in TLS 1.2), and zero for a resumed session. It also removed all the old broken ciphers. If a design question involves TLS overhead, saying “with TLS 1.3 and session resumption the handshake cost is largely amortized” is a good signal.

Certificates and trust

A certificate binds a public key to a domain name, signed by a Certificate Authority your OS/browser already trusts. The chain goes: your cert → intermediate CA → root CA (pre-installed).

Practical things to know:

Where TLS terminates — a real design decision

Option A:  Client ══TLS══► Load Balancer ──plaintext──► App servers
Option B:  Client ══TLS══► Load Balancer ══TLS══► App servers   (re-encrypt)
Option C:  Client ══════════TLS all the way═══════════► App server (passthrough)
Option Pro Con
A. Terminate at LB Cheap; LB can inspect and route on content; centralized cert management Internal traffic is plaintext — unacceptable in zero-trust or regulated environments
B. Re-encrypt Encrypted end to end; LB still sees content Double the CPU; certs to manage internally
C. Passthrough LB never sees plaintext No L7 routing, no content-based rules, per-app cert management

Most systems do A inside a trusted VPC, and B or a service mesh when compliance or zero-trust demands it. Being able to explain this choice is a solid senior signal.

HSTS (Strict-Transport-Security) tells browsers “always use HTTPS for this domain,” which closes the window where a user’s first plaintext request could be hijacked.


⚖️ Trade-offs

Decision Gain Cost
Aggressive Cache-Control Fewer origin requests, faster users Stale content; invalidation is hard
ETags + conditional requests Big bandwidth savings Server must compute/store the ETag
TLS everywhere Security, and required by browsers for modern APIs CPU + handshake latency (small with 1.3)
Terminate TLS at the edge Fast handshakes near the user, warm origin connections Internal traffic unencrypted unless re-encrypted
Idempotency keys on POST Safe retries, no double charges Server must store keys and dedupe

In the real world


🚨 Interview traps


🎙️ Soundbites


🛠️ Try it

# See everything, including the TLS handshake
curl -v https://api.github.com/users/torvalds

# Conditional requests: grab the ETag, then use it
ETAG=$(curl -sI https://api.github.com/users/torvalds | grep -i '^etag:' | cut -d' ' -f2 | tr -d '\r')
curl -s -o /dev/null -w '%{http_code}\n' -H "If-None-Match: $ETAG" https://api.github.com/users/torvalds
# → 304, with no body transferred

# Inspect a certificate chain
openssl s_client -connect example.com:443 -servername example.com </dev/null | head -40

# Check when a cert expires
echo | openssl s_client -connect example.com:443 2>/dev/null | openssl x509 -noout -dates

Then: build a tiny endpoint that supports ETags and conditional GETs. It’s ~10 lines and it will make caching feel real.


Check yourself

1. What's the difference between idempotent and safe, and why do you care? Safe = no side effects at all (GET, HEAD). Idempotent = repeating it produces the same end state (PUT, DELETE, and GET). You care because networks force retries: when a request times out you don't know if it succeeded, so you must retry — and you can only safely retry idempotent operations. Non-idempotent operations (POST) need an idempotency key to become retry-safe.
2. A client gets a timeout on POST /payments. Should it retry? What should the API have provided? Not blindly — the payment may have succeeded and the response was lost. Retrying risks a double charge. The API should accept an `Idempotency-Key` header: the server stores the key with the result, and a retry with the same key returns the original response instead of charging again. Without that, the client's only safe options are to poll for status or reconcile later.
3. What does Cache-Control: no-cache actually mean? "You may store this, but you must revalidate with the origin before serving it." It is *not* "don't cache" — that's `no-store`. Practically, `no-cache` plus an ETag gives you cheap revalidation: the client stores the copy and usually gets a tiny `304` back instead of the full body.
4. Why is returning 500 for a validation error harmful? Because 5xx means "my fault, try again," so well-behaved clients and libraries retry — forever, for a request that can never succeed. It also pollutes your error rate metrics and error budget, hides real server failures in the noise, and can trigger circuit breakers or page an on-call engineer for what is actually a client bug. Validation failures are 400 or 422.
5. Your CDN starts serving one user's account page to other users. What header was probably missing? `Vary: Authorization` (or `Cache-Control: private`). Without it, the CDN keys the cache only on the URL, so the first authenticated response gets stored and returned to everyone requesting the same path. Any endpoint whose response depends on a header must declare that header in `Vary`, and per-user responses should be `private` or `no-store` at shared caches.

Further reading