Frontend Labs
⋯

Part 9 — Layer 7: Application

Job

Provide the service the user actually wanted, using a protocol that both applications understand: HTTP to place an order, DNS to find a server, the Postgres wire protocol to save it.

In our story

Three L7 protocols take part in your one click: DNS, HTTP, and the Postgres protocol.

Step A: DNS (assuming nothing is cached). The browser needs an IP for shop.example.com:

Browser cache?        miss
OS resolver cache?    miss
 │
 ▼ UDP 53 → home router 192.168.1.1 (DNS forwarder) → ISP recursive resolver
                                                         │
     ┌───────────────────────────────────────────────────┘
     │ 1. Root server       "who handles .com?"           → a.gtld-servers.net ...
     │ 2. .com TLD server   "who handles example.com?"    → ns1.example-dns.net ...
     │ 3. Authoritative     "shop.example.com?"
     │       → CNAME shop-alb-123.region.elb.example.net
     │       → A     198.51.100.20   TTL 60
     ▼
Browser now has 198.51.100.20 (cached for 60 s)

DNS is an L7 protocol carried over UDP (L4), falling back to TCP for large responses. Increasingly it's encrypted (DNS-over-HTTPS/TLS). A cold lookup can take 20–100 ms or more. A cached one takes 0 ms.

Step B: the HTTP request. Inside the TLS tunnel, on HTTP/2 stream 15:

:method: POST
:scheme: https
:authority: shop.example.com
:path: /api/v1/orders
content-type: application/json
content-length: 43
cookie: session=s%3A9d8f...; AWSALB=...
idempotency-key: 7f3c9a2e-1b4d-4c8e-9f00-2a6b8d1e5c43
user-agent: Mozilla/5.0 ...
accept-encoding: gzip, br

{"cartId":"c_8812","paymentMethodId":"pm_77"}

Step C: L7 routing at the load balancer. Because the ALB decrypts the traffic and parses HTTP, it can make content-aware decisions that an L4 device can't:

Rule: Host = shop.example.com AND Path = /api/v1/orders*  → target group "orders-service"
Adds: X-Forwarded-For: 203.0.113.7      (your public IP, which is otherwise lost)
      X-Forwarded-Proto: https
      X-Amzn-Trace-Id: Root=1-6703...   (distributed-tracing correlation ID)
Picks a healthy pod: 10.0.2.77:8080 (least outstanding requests)
Sends over an existing keep-alive connection, HTTP/1.1 (or HTTP/2, gRPC)

Step D: your code. The orders-service handler:

1. Authenticate: look up the session cookie (Redis) → user u_4411
2. Idempotency: have we seen key 7f3c9a2e...? No → record it
3. Business logic: price the cart, call the payment provider (another full
   outbound L1–L7 journey through the NAT gateway!)
4. Persist: INSERT INTO orders ... over a pooled Postgres connection
   (Postgres wire protocol, L7, over TCP 5432, often TLS)
5. Respond: 201 Created  {"orderId":"o_99120371","status":"confirmed"}

Every outbound call in step D repeats the entire OSI journey on a different path. A single user click usually fans out into 5–50 internal L7 conversations.

How it works

1. HTTP's model: a stateless request/response with method, target, headers, and body. Stateless means every request carries everything the server needs (cookies, tokens), which is what lets any instance handle any request and makes horizontal scaling possible.

2. Methods and idempotency matter to the network layers below.

Method Safe? Idempotent? Can a proxy/client retry automatically?
GET, HEAD Yes Yes Yes
PUT, DELETE No Yes Yes (with care)
POST No No No, unless you add an Idempotency-Key

When a TCP connection drops after the request was sent but before the response arrived, the client can't tell whether the order was created. L4 can't answer that question. Only L7 idempotency can. That's why our request carries an Idempotency-Key.

3. Status codes: who generated them? In a proxy chain, the same code means different things depending on which hop produced it:

Code From your app From the load balancer / proxy
400/401/403/404 App logic Rare (WAF block → 403)
500 Your code threw an exception —
502 Bad Gateway — The upstream sent an invalid response or closed the connection (crash, keep-alive race, protocol mismatch)
503 Service Unavailable Deliberate load shedding No healthy targets (all failing health checks)
504 Gateway Timeout — Upstream didn't respond in time (slow DB, deadlock, network drop)

Incident tip: always check whether a 5xx came from the app's access log or only the LB's log. A 502/504 that appears only in LB logs means the request never got a response from your code. It's a network, timeout, or crash problem, not a logic bug.

4. The L7 infrastructure you already run:

Component L7 superpower
CDN Caching by URL/headers, edge TLS termination, close to users
WAF Blocks malicious payloads (SQLi, XSS) by reading the HTTP content
API gateway Auth, rate limiting per API key, request transformation
L7 load balancer / ingress Routing by host/path/header, retries, health checks
Service mesh sidecar (Envoy) mTLS, per-request load balancing, retries, circuit breaking, telemetry

5. HTTP versions.

HTTP/1.1 HTTP/2 HTTP/3
Transport TCP TCP QUIC over UDP
Concurrency One request per connection at a time (browsers open about 6 connections) Many streams on one connection Many streams, no TCP head-of-line blocking
Headers Plain text HPACK compressed QPACK compressed
Handshake (new connection) TCP + TLS = 2 RTT TCP + TLS = 2 RTT 1 RTT (0-RTT on resumption)
Connection migration (Wi-Fi → 4G) No (new 4-tuple = new connection) No Yes (connection IDs)

Try it yourself

# DNS: the answer, the full delegation chain, and the TTL
dig shop.example.com +noall +answer
dig +trace shop.example.com
dig @1.1.1.1 shop.example.com A +short    # ask a specific resolver
scutil --dns | head -20                   # macOS: which resolver am I using?

# HTTP: full request/response with headers
curl -v https://shop.example.com/api/v1/health

# Force a specific IP (bypass DNS) while keeping SNI/Host correct: tests one backend/LB
curl -v --resolve shop.example.com:443:198.51.100.20 https://shop.example.com/api/v1/health

# HTTP/3, if your curl supports it
curl --http3 -sI https://shop.example.com/

# Browser: DevTools → Network → click a request → Timing tab

What breaks

Symptom L7 cause
"Works for me" but not for users after a DNS change DNS TTL/caching. Old records live until they expire (and some resolvers ignore TTLs)
NXDOMAIN / SERVFAIL Missing record, broken delegation, DNSSEC validation failure
Duplicate orders Client or proxy retried a non-idempotent POST after a timeout
503 from LB, app looks healthy Health-check path misconfigured / returning non-200 / wrong port
413 Payload Too Large / 431 headers too large Proxy limits (body size, header size: watch for huge cookies/JWTs)
Real client IP is the LB's IP in logs Not reading X-Forwarded-For (or reading it insecurely, trusting spoofed values)
CORS errors in the browser Missing/incorrect Access-Control-Allow-* headers. A browser policy at L7

War story: "The retry storm"

A slow database caused orders-service latency to rise from 100 ms to 3 s. The ALB timed out at 2 s and returned 504. The mobile app retried 3 times. The API gateway also retried 2 times. Each hop's retries multiplied: one user tap became up to 3 × 3 = 9 requests to an already overloaded database, which then fell over completely. Fixes: retry at only one layer, use exponential backoff with jitter, set retry budgets, require idempotency keys for POSTs, and add a circuit breaker. Lesson: retries are an L7 policy that multiplies load across the whole stack. Somebody has to own the end-to-end retry design.

Why it matters to you

  • L7 is where business semantics live: idempotency, retries, auth, rate limits. Lower layers can't make those decisions for you.
  • DNS is part of your deployment and failover system. TTLs decide how fast you can move traffic. Lower them before a migration.
  • Know which hop generates each 5xx. It cuts incident time in half.
  • Every L7 proxy terminates the connection. Client IP, TLS, and timeouts all restart at each hop.

Go deeper in the series


Generated from real-life-example-osi.md · 8 Oct 2026 16:36