Skip to main content

HTTP: The Hidden Essentials Every Developer Should Know

· 28 min read
Pere Pages
Software Engineer
An HTTP request and response travelling between a client and a server

Every framework, HTTP client, and browser hides the protocol behind a few convenient calls — so most developers never see the request that actually goes over the wire. This post pulls back that curtain: the communication model, the methods, the status codes, and the headers you've been using all along without knowing it.

HTTP is hiding in plain sight

You call fetch('/api/users'), or axios.post(...), or you click a link, and something comes back. The protocol underneath did a dozen things you never saw: it opened (or reused) a TCP (Transmission Control Protocol) connection, negotiated TLS (Transport Layer Security), picked an HTTP version, serialized a request line and a stack of headers, waited, and parsed a response with its own status line, headers, and body.

That abstraction is great — until it isn't. When a cached response is stale, a CORS (Cross-Origin Resource Sharing) preflight fails, a POST gets silently retried, an upload stalls, or a 304 confuses you, the framework can't help. You need to know what's actually on the wire.

HTTP (HyperText Transfer Protocol) is a text-based, stateless, request–response protocol. A client sends a request; a server sends back exactly one response. That's the whole shape of it. Everything else — methods, status codes, headers, cookies, caching, auth — is detail layered on top of that one sentence. Before we go through that detail, here's where HTTP actually sits.

Where HTTP sits in the stack

Network protocols are layered: each one rides on the layer below and ignores everything above it. The textbook OSI (Open Systems Interconnection) model defines seven layers; in practice a handful matter, and HTTP sits right at the top.

OSI layerJobLives here
7 — ApplicationWhat a request/response meansHTTP
6/5 — Presentation / SessionEncryption, session setupTLS
4 — TransportDelivery between two endpointsTCP (reliable), UDP (User Datagram Protocol — fast, no guarantees)
3 — NetworkRouting packets across the internetIP (Internet Protocol)

QUIC is the one that doesn't fit a single row: it's a transport that runs on top of UDP, re-doing TCP's job (reliability, ordering) in user space — so HTTP/3 is HTTP over QUIC over UDP over IP. Everything below layer 7 is what your framework hides; the rest of this post is mostly layer 7, with a few trips downstairs.

The request–response cycle

A single HTTP exchange is one request and one response. Conceptually:

Stateless by design

HTTP itself remembers nothing between requests. Each request must carry everything the server needs to handle it — which is why identity travels in a header (a cookie or an Authorization token) on every request, not just at "login". "Sessions" are an illusion built on top of this statelessness, usually with a cookie that points at server-side state.

The connection underneath

HTTP rides on a transport. For HTTP/1.1 and HTTP/2 that's TCP; for HTTPS, a TLS handshake sits between TCP and HTTP and encrypts everything above it. HTTP/3 swaps TCP for QUIC (a transport built on UDP) and folds TLS in.

VersionTransportKey idea
HTTP/1.0TCPOne request per connection; connection closed after each response.
HTTP/1.1TCPPersistent connections (keep-alive), chunked transfer, Host required.
HTTP/2TCPOne connection, many multiplexed streams; binary framing; header compression (HPACK). Browsers require TLS.
HTTP/3QUIC (UDP)Multiplexing without TCP head-of-line blocking; faster connection setup; TLS built in.

The protocol kept evolving while its semantics stayed put:

The headline features of HTTP/2 and HTTP/3 each remove a specific bottleneck of the version before — which also unpacks the jargon in that table:

VersionImprovementWhat it does
HTTP/2Multiplexed streamsHTTP/1.1 handles one request at a time per connection — the next waits for the previous response to finish (head-of-line blocking), which is why browsers used to open ~6 connections per origin. HTTP/2 interleaves many independent streams (request/response pairs) over a single connection at once. This is what loads a page's CSS, scripts, and images concurrently — each is still a separate GET, but they share one connection instead of queuing or needing six.
HTTP/2Binary framingHTTP/1.1 is human-readable text; HTTP/2 splits every message into binary frames tagged with a stream ID. That tagging is what makes interleaving possible — at the cost that you can no longer read it raw on the wire. (It's also why one exchange is called a stream: a request/response is carried as an ordered flow of frames over time, not one atomic message — so a body can even arrive incrementally.)
HTTP/2Header compression (HPACK)Every request repeats bulky, near-identical headers (cookies, User-Agent, Accept). HPACK compresses them against a shared table of already-seen headers, so the repeats cost almost nothing. (HTTP/3 uses QPACK, the QUIC-adapted equivalent.)
HTTP/2TLS in practiceThe spec technically allows cleartext HTTP/2 (h2c), but every major browser speaks it only over TLS — so in the browser, HTTP/2 effectively means HTTPS.
HTTP/3QUIC over UDPSwaps TCP for QUIC, which re-implements TCP's reliability and ordering on top of UDP and carries HTTP's streams — so it isn't "HTTP on raw UDP."
HTTP/3No head-of-line blockingHTTP/2's streams shared a single TCP connection, so one lost packet stalled every stream. QUIC delivers each stream independently, so loss in one no longer blocks the rest.
HTTP/3Connection migrationThe connection follows a connection ID rather than an IP/port, so a request can survive a network switch (Wi-Fi → cellular) without reconnecting.
HTTP/3Faster, encrypted setupTLS is folded into the QUIC handshake (fewer round trips), and a resumed connection can send data in its first packet (0-RTT).

If the jargon is still slippery, here's the plain-English version. Picture one connection as a highway. HTTP/1.1 is a single lane — one car (request) at a time, and everyone behind it waits. HTTP/2 makes it multi-lane: many cars travel at once. To share the road, each message is chopped into small labelled envelopes (frames), every envelope stamped with which conversation (stream) it belongs to; the receiver collects the envelopes and reassembles each conversation. So a frame is one labelled chunk, a stream is one full request↔response conversation, and multiplexing is many streams' frames interleaved on the one road.

HTTP/3 isn't a future bet: it has been an internet standard since 2022 (RFC 9114), and the major browsers and content delivery networks (CDNs) serve it by default today — though plenty of origin servers and internal services still top out at HTTP/2.

That raises an obvious question: if UDP can lose packets, how is HTTP/3 reliable? UDP doesn't make QUIC lossy — it simply does nothing, and QUIC rebuilds TCP's guarantees on top of it, in user space. Every QUIC packet is numbered and acknowledged (ACKed); anything that isn't ACKed is retransmitted, so no data is actually lost. The twist versus TCP is that QUIC tracks ordering per stream, so a lost packet only stalls its own stream rather than all of them — and because this logic lives in the app's library instead of the OS kernel (where TCP is frozen by middleboxes, the routers and firewalls that inspect traffic in transit), it can keep evolving.

Connection migration raises its own question: if the server finds a connection by a Connection ID that rides in every packet, can't someone copy it and hijack the connection? No — the Connection ID is just a routing label, not a secret, and QUIC's security never assumes it's hidden. Every QUIC packet is authenticated with the TLS-derived keys, so copying the ID gets an attacker nowhere: forged packets fail the check and are dropped. And when packets start arriving from a new address, the server validates the path before trusting the move — it sends an unguessable random token the real peer must echo back (PATH_CHALLENGE / PATH_RESPONSE), and rate-limits itself until that succeeds so it can't be tricked into flooding a spoofed address.

The semantics — methods, status codes, headers — are the same across versions. HTTP/2 and HTTP/3 change how bytes are framed and connections are managed, not what a GET or a 404 means. That's why you can reason about HTTP without caring which version negotiated underneath. The version is chosen for you: a server advertises HTTP/3 with an Alt-Svc response header (or an HTTPS DNS record), and the client tries QUIC/UDP and silently falls back to HTTP/2-over-TCP if UDP is blocked. The transport only really matters at the operations layer, not in application code — HTTP/3 needs UDP port 443 open through firewalls, load balancers, and proxies, and its performance wins only kick in once it actually negotiates.

HTTPS, certificates, and trust

TLS does two jobs: it encrypts the connection and proves the server is who it claims to be. The proof is a certificate the server presents during the handshake, signed by a Certificate Authority (CA) — a third party your client already trusts. Browsers, Node, and curl all ship with a built-in list of trusted CA root certificates; if the server's certificate chains up to one of them and matches the hostname, the connection is trusted. If not, you get the dreaded self-signed certificate / unable to verify errors.

That's where an HTTP client's ca option comes in. When you hit a server whose certificate is signed by a private or internal CA (corporate networks, a local dev proxy, a self-signed setup), it isn't in the default bundle, so validation fails. You tell the client to trust it explicitly:

import https from 'node:https';
import fs from 'node:fs';

// trust an internal/self-signed CA that isn't in the default bundle
https.get('https://internal.example.com', {
ca: fs.readFileSync('./internal-root-ca.pem'),
}, (res) => { /* res.statusCode, res.on('data', …) */ });

The same idea shows up everywhere: axios takes it via httpsAgent, curl via --cacert, and Node also reads a NODE_EXTRA_CA_CERTS environment variable. Two cousins you'll meet: rejectUnauthorized: false turns validation off entirely (convenient in dev, dangerous anywhere real — it accepts any certificate), and cert + key send a client certificate so the server can verify you in return (mutual TLS, or mTLS).

Keep-alive, and why it's still stateless

Opening a connection is expensive — a TCP handshake, plus a TLS handshake for HTTPS. Throwing it away after every request would make a page with fifty resources pay that cost fifty times. Keep-alive avoids that: the TCP/TLS socket stays open and the next request/response reuses it. HTTP/1.0 opts in with Connection: keep-alive; HTTP/1.1 does it by default (opt out with Connection: close); HTTP/2 goes further and multiplexes many requests over the one connection at once.

How is it actually "kept alive"? Plainly: instead of hanging up and redialling for every sentence, both sides keep the phone line open and keep talking. At the socket level, nothing closes the TCP connection after the response — it stays in the ESTABLISHED state, and the next request's bytes simply travel down the same socket. Connection: close ends it on purpose; the optional Keep-Alive: timeout=5, max=100 header hints how long the socket may sit idle and how many requests it may carry; and the server reaps connections that go quiet past the timeout. (Don't confuse this with TCP keepalive — a separate, much lower-level probe that just pokes an idle connection to check it's still there.)

This is also why Content-Length (or Transfer-Encoding: chunked) matters: if the socket stays open, the receiver needs each message's length to know where one response ends and the next begins.

But doesn't a persistent connection contradict "stateless"? No — they're different layers:

LayerWhat it isStateful?
Connection (TCP/TLS, keep-alive)A reused pipe for bytesYes — the socket is open and holds state
HTTP messages (request/response)The application semanticsNo — each request is self-contained

Keep-alive is a transport optimization; it never makes the server treat requests differently just because they share a socket. Your Cookie or Authorization header rides on every request, whether it's #1 or #50 on that connection. The clean test: if the connection drops and the next request opens a brand-new socket, nothing breaks — the request still carries its own identity and the server handles it identically. Requests are portable across connections, which is exactly what makes the protocol stateless. (One caveat: a few legacy schemes like NTLM/Kerberos auth do bind state to a specific connection — an exception layered on top, not how HTTP itself is defined.)

Anatomy of a message

Every HTTP message has the same three-part structure. A request:

POST /api/users HTTP/1.1
Host: example.com
Content-Type: application/json
Content-Length: 35
Accept: application/json

{"name": "Ada", "role": "engineer"}

A response:

HTTP/1.1 201 Created
Content-Type: application/json
Location: /api/users/42
Content-Length: 45

{"id": 42, "name": "Ada", "role": "engineer"}

The parts:

  1. Start line — for a request: METHOD path HTTP/version. For a response: HTTP/version status-code reason-phrase.
  2. HeadersKey: Value pairs, one per line, case-insensitive names. Terminated by a single blank line.
  3. Body (optional) — the payload, separated from headers by that blank line. Present on most responses and on POST/PUT/PATCH requests; absent on a plain GET.

The URL

The request target comes from a URL, and each piece maps to something the protocol uses:

https://user@example.com:443/api/users?role=admin#section
└─┬─┘ └──┬───┘ └────┬────┘└┬┘└───┬───┘└────┬────┘└──┬──┘
scheme userinfo host port path query fragment
ComponentExampleRole
schemehttpsPicks the protocol and default port (http→80, https→443).
userinfouserCredentials in the URL (rare, discouraged).
hostexample.comBecomes the Host header; routes between virtual hosts.
port443The TCP port to connect to.
path/api/usersWhich resource you want.
query?role=admin?key=value&key2=value2 parameters.
fragment#sectionNever sent to the server — client-side only.

Encoding the query string

URLs only allow a limited set of characters, so query values have to be percent-encoded: a space becomes %20, and & = ? # are reserved with special meaning, so a literal one inside a value must be escaped. Never hand-concatenate ?a= + value — one stray & or space corrupts the URL. Use the tools that encode for you:

SideReach for
ClientURLSearchParams (native, auto-encodes: new URLSearchParams({ q: 'a b', page: 2 }).toString()q=a+b&page=2), the URL object, or encodeURIComponent for a single value. Axios serializes a params object for you; qs / query-string handle nested objects and arrays.
ServerExpress exposes the parsed query as req.query (path params as req.params, the body as req.body via express.json()). Plain Node parses it with URL / URLSearchParams.

One gotcha: how nested objects and arrays encode (a[]=1&a[]=2 vs a=1&a=2 vs a=1,2) isn't standardized — qs and query-string disagree — so use the same convention on client and server.

Methods

The method is the verb — it declares your intent. The server isn't forced to honor the semantics, but the whole ecosystem (caches, proxies, browsers, CDNs) assumes you follow them, so violating them causes real bugs.

MethodPurposeHas body?SafeIdempotentCacheable
GETRetrieve a resourceNo
HEADLike GET but headers only, no bodyNo
POSTCreate / submit / trigger a processYesRarely
PUTReplace a resource entirelyYesNo
PATCHPartially modify a resourceYesNo
DELETERemove a resourceOptionalNo
OPTIONSAsk what's allowed (used by CORS preflight)NoNo
CONNECTEstablish a tunnel, e.g. HTTPS through a proxyNoNo
TRACELoop the request back for diagnosticsNoNo

Rarely written in application codeCONNECT is used by proxies to tunnel HTTPS, and TRACE echoes your request back for debugging (usually disabled for security). The seven above are the ones you actually reach for.

A few of them on the wire — notice how the response status mirrors the method's intent:

Three properties decide how the rest of the stack treats a request — and they're the source of subtle bugs when ignored:

  • Safe — read-only; the request shouldn't change server state. Browsers and crawlers prefetch safe methods freely. (Never hide a destructive action behind a GET — a crawler will eventually fire it.)
  • Idempotent — making the same request N times has the same effect as making it once. This is why a client or proxy may retry a failed PUT/DELETE/GET automatically, but must not retry a POST (you'd create two records). PATCH is generally not idempotent because a partial update can depend on current state.
  • Cacheable — the response may be stored and reused. GET and HEAD are the workhorses here.

What HEAD and OPTIONS are really for

These two feel obscure because you rarely call them by hand, but they're working constantly under the hood:

  • HEAD is a GET with the body stripped — same status and headers, no payload. It's how you ask about a resource without downloading it: does it exist, how big is it (Content-Length), has it changed (ETag / Last-Modified)? Link checkers, "do I already have the latest?" probes, and download managers lean on it.
  • OPTIONS asks what's permitted. A server can answer with an Allow: header listing the methods a resource supports, but in practice you meet OPTIONS as the CORS preflight the browser sends automatically (covered under Cross-origin requests below).

Status codes

The response's start line carries a three-digit code. The first digit is the family — learn the families and you can reason about a code you've never seen.

FamilyMeaningYou'll meet
1xxInformational100 Continue, 101 Switching Protocols (WebSocket upgrade)
2xxSuccess200 OK, 201 Created, 204 No Content, 206 Partial Content
3xxRedirection301 Moved Permanently, 302 Found, 304 Not Modified, 307/308
4xxClient error400, 401, 403, 404, 405, 409, 422, 429
5xxServer error500, 502, 503, 504

The ones that trip people up:

Code(s)What to know
201 vs 200A successful create should return 201 with a Location header pointing at the new resource.
204 No ContentSuccess, deliberately no body (common for DELETE and PUT).
301/308 vs 302/307Permanent vs temporary. The subtle part: 301/302 historically let clients switch to GET, while 307/308 preserve the original method and body. Use 308 to redirect a POST without it becoming a GET.
304 Not ModifiedNot an error — the server saying "your cached copy is still good" to a conditional request (see caching below).
401 vs 403401 = not authenticated (who are you?); 403 = authenticated but not allowed (I know you, no). The names are historically backwards; remember the distinction, not the words.
409 / 422The request was understood but conflicts with state, or failed validation.
429 Too Many RequestsRate limited; pair it with a Retry-After header.
502 / 503 / 504About infrastructure (a proxy/upstream is down, overloaded, or slow), not your code. Knowing the difference saves hours of debugging the wrong layer.

A redirect is really two requests — the server hands back a new URL in Location, and the client repeats the request against it:

Headers, in detail

Headers are where most of HTTP's real power — and most of the hidden behavior — lives. They're Key: Value metadata about the message. Names are case-insensitive; a header can appear once or, for some, multiple times. This is the part frameworks hide most aggressively, so it's worth going through by purpose.

Identifying the request

HeaderWhat it does
HostWhich virtual host you're addressing. Required in HTTP/1.1.
User-AgentIdentifies the client (browser, library, bot).
RefererThe page that linked here (yes, it's misspelled in the spec).
OriginThe scheme+host the request came from — central to CORS and CSRF (cross-site request forgery) defenses.

Describing the body (representation headers)

These describe the payload, on either a request or a response:

HeaderWhat it does
Content-TypeThe media (MIME) type of the body, e.g. application/json, text/html; charset=utf-8.
Content-LengthBody size in bytes. Lets the receiver know when the message ends.
Content-EncodingCompression applied to the body, e.g. gzip, br (Brotli).
Transfer-Encoding: chunkedBody sent in chunks of unknown total length (streaming) — used instead of Content-Length.

Content negotiation (the client states preferences, the server picks)

This is how one URL can serve JSON to an API client and HTML to a browser, or English to one user and German to another:

Request headerServer responds withNegotiates
AcceptContent-TypeMedia type (JSON/HTML/XML)
Accept-LanguageContent-LanguageHuman language
Accept-EncodingContent-EncodingCompression

Accept even supports weighted preferences: Accept: application/json;q=0.9, text/html;q=0.8.

Caching and conditional requests

Caching is the single biggest performance lever in HTTP, and it's almost entirely header-driven:

Header(s)Role
Cache-ControlThe modern control surface: max-age=3600 (fresh for an hour), no-cache (revalidate first), no-store (never cache), private/public, immutable.
ETag + If-None-MatchThe server stamps a response with an opaque version (ETag: "abc123"); the client sends it back. If unchanged, the server returns 304 with no body.
Last-Modified + If-Modified-SinceThe timestamp-based equivalent of the ETag validator.
VaryTells caches which request headers change the response (e.g. Vary: Accept-Encoding), so a gzipped body isn't served to a client that can't decode it.

State: cookies

Cookies are how stateless HTTP fakes continuity:

  • The server sends Set-Cookie: session=xyz; HttpOnly; Secure; SameSite=Strict; Max-Age=3600.
  • The browser stores it and returns it on every matching request via Cookie: session=xyz.

The attributes are security-critical: HttpOnly (JS can't read it — blocks cross-site scripting (XSS) theft), Secure (HTTPS only), SameSite (blocks cross-site sending — CSRF defense), Domain/Path/Max-Age/Expires (scope and lifetime).

Cross-origin requests (CORS)

Browsers block a page from reading responses from a different origin unless the server opts in with headers. For anything beyond a "simple" request, the browser first sends an OPTIONS preflight:

The key response headers are Access-Control-Allow-Origin, -Allow-Methods, -Allow-Headers, and -Allow-Credentials. A "CORS error" is almost always a missing or mismatched one of these — on the server side, even though the error shows up in the browser.

Not every cross-origin request is preflighted. Simple requests — GET/HEAD/POST with only safe headers and a Content-Type of application/x-www-form-urlencoded, multipart/form-data, or text/plain — go straight to the real request. The preflight kicks in for everything else: any other method (PUT/PATCH/DELETE), a JSON body (Content-Type: application/json), or custom headers (X-*, and Authorization). To avoid repeating it on every call, the browser caches a successful preflight for as long as the server's Access-Control-Max-Age allows.

The browser security model (CORS, CSP, and friends)

CORS is one slice of a bigger picture: a set of protections the browser enforces, each switched on by a header the server sends. They're worth knowing as a group, because they're easy to mix up:

MechanismProtects againstHow it works
Same-Origin Policy (SOP)A page reading another site's dataThe baseline rule: scripts may only read responses from their own origin (scheme + host + port). Everything below either relaxes or reinforces this.
CORS— (relaxes SOP)Lets a server opt specific other origins back in, via Access-Control-Allow-*.
CSP (Content-Security-Policy)XSS / injectionLists which sources scripts, styles, images, etc. may load from — blocks inline and unlisted scripts.
CSRF defenses (SameSite cookies, tokens)A logged-in user being tricked into a state-changing requestSameSite stops the browser attaching cookies to cross-site requests; anti-CSRF tokens prove the request came from your own page.
HSTS (Strict-Transport-Security)Downgrade / man-in-the-middleForces every future visit onto HTTPS, even if the user typed http://.
Clickjacking defense (X-Frame-Options / CSP frame-ancestors)Your page embedded in a hostile <iframe>Controls who may frame your site.
MIME-sniffing defense (X-Content-Type-Options: nosniff)The browser guessing a file is a scriptForces it to trust the declared Content-Type.
Subresource Integrity (SRI)A tampered CDN scriptThe integrity attribute pins a hash; the browser refuses a script that doesn't match.

The pattern is always the same: the server sends a header, the browser does the enforcing. None of this protects a non-browser client (curl, a server-to-server call) — it's specifically the browser sandbox protecting users.

Other headers worth knowing

Header(s)What it does
LocationWhere a 3xx redirect points, or where a 201 Created resource now lives.
Range / Accept-Ranges / Content-RangePartial transfers (resumable downloads, video seeking); server replies 206 Partial Content.
Connection: keep-alive / Keep-AliveConnection reuse in HTTP/1.1.
Retry-AfterHow long to wait after a 429 or 503.
Strict-Transport-Security, Content-Security-Policy, X-Content-Type-Options, X-Frame-OptionsSecurity headers: force HTTPS (HSTS), restrict what the page can load (CSP), stop MIME sniffing (nosniff), and block clickjacking.
Authorization / WWW-AuthenticateCredentials and challenges (its own topic below).

Request and response bodies

The body carries the actual data, and Content-Type tells the other side how to read it. The encodings you'll meet most:

Content-TypeUsed for
application/jsonThe default for modern APIs.
application/x-www-form-urlencodedClassic HTML form submit (key=value&key2=value2).
multipart/form-dataForms with file uploads — each part has its own headers.
text/html, text/plainDocuments.
application/octet-streamArbitrary binary.

For streaming or unknown-length bodies, the server uses Transfer-Encoding: chunked instead of Content-Length, sending the body in sized chunks and a zero-length chunk to signal the end. This is what powers things like server-sent events and progressive responses.

Don't conflate that with HTTP/2's binary framing, though. Framing means a response always travels as frames on the wire — a transport detail. Whether your code receives the body progressively (fetch's response.body stream) or waits for the whole thing (await response.json()) depends on the client API and whether the server flushes as it goes — exactly as it does over HTTP/1.1's chunked encoding. Framing makes app-level streaming efficient, not automatic.

"Streaming": HTTP vs real-time

This is worth pinning down, because "streaming" means two opposite things on the wire — and only one of them is HTTP:

Buffered streaming (Netflix, YouTube)Real-time (calls, video conferencing)
Carried overHTTP — HLS (HTTP Live Streaming) / DASH (Dynamic Adaptive Streaming over HTTP), fetched as a series of short segment GETsUDP — WebRTC / RTP (Real-time Transport Protocol), not HTTP
PriorityPerfect quality; a few seconds of buffer is fineLowest latency; a dropped frame is fine
On packet lossRetransmit — never show a corrupt frameDrop it — the moment has already passed

So on-demand video is ordinary HTTP: reliable, buffered, increasingly over HTTP/3. Live calls deliberately avoid HTTP and use raw UDP, where "don't retransmit" is the entire point — the opposite of what QUIC and TCP give you.

Putting it together

None of these pieces are exotic — you fire them on every page load. A single navigation to an HTTPS page typically means a DNS (Domain Name System) lookup, a TCP connection, a TLS handshake, then a cascade of requests over the same multiplexed connection:

Each sub-request carries its own caching and negotiation. Understanding the headers turns "it's slow / it's broken" into a specific, fixable layer.

Why your dev server fires so many requests

Open the Network tab in development and you'll see hundreds of requests where production shows a handful. That's not your app — it's the dev server choosing not to bundle. Tools like Vite serve your source as native ES modules (ESM), so every single import becomes its own little HTTP request for that file. On top of that you'll see a WebSocket for Hot Module Replacement (HMR — swapping edited modules into the running page without a reload), separate source-map files, and unminified assets. Production does the opposite: a bundler rolls thousands of modules into a few hashed, compressed, long-cached files — so the same app loads in a dozen requests instead of hundreds. Different request counts, same HTTP underneath.

Seeing it for real

Reading about HTTP only gets you so far — the fastest way to internalize it is to watch the raw bytes. curl shows you the exact request line, every header sent and received, the status code, and the body, with nothing hidden:

curl -v https://example.com/api/users

Add -i to include response headers in the output, -X POST -d '{...}' -H 'Content-Type: application/json' to send a body, and -H 'Authorization: Bearer ...' to authenticate. Once you can drive HTTP by hand, the framework stops being a black box.

For a full tour of using it as a debugging and scripting tool, see curl: the command-line superpower every developer should master.

Who are you? Authentication over HTTP

Because HTTP is stateless, identity has to travel with every request. The protocol gives you the plumbing — the Authorization request header and the WWW-Authenticate challenge response — and a 401 to say "authenticate first". On top of that plumbing sit the schemes you actually use:

SchemeLooks likeNotes
BasicAuthorization: Basic <base64(user:pass)>Simplest; only safe over HTTPS.
Bearer tokenAuthorization: Bearer <token>API keys, JWTs (JSON Web Tokens), OAuth access tokens.
Cookie sessionCookie: session=xyzThe Set-Cookie/Cookie dance from earlier.

The challenge-and-retry looks the same whatever the scheme — an unauthenticated request is refused, the client attaches credentials, and retries:

Each has real trade-offs around storage, expiry, refresh, and CSRF/XSS exposure. That's a post of its own — for the full picture, see Authentication: A Top-Down Guide.

See also