All lessons
Web basicsBeginner13 min readIn my stack

HTTP

Methods, status codes, headers, cookies, caching and CORS, with raw examples.

  • Methods
  • Status codes
  • Headers
  • Cookies
  • Caching
  • CORS
Step 1/6
An API is like a waiter. GET /menu — “bring me the menu” — 200 OK, here it is.

In 30 seconds

HTTP is the language browsers, apps and servers use to talk. A client sends a request with a method, a path and headers; the server answers with a status code, headers and a body. Almost everything on the web, from pages to APIs to file downloads, is built on this simple message format, so understanding it makes debugging much easier.

Why I use it

  • I build APIs and websites every day, and most bugs become obvious once I read the raw request and response.
  • Correct status codes, headers and caching make apps faster and safer without extra code.
  • Cookie flags and CORS are where many security and integration problems hide.
  • Mobile apps, admin panels and web frontends all talk to my backends over HTTP, so a shared understanding saves time across the team.

How it works

01The message format

HTTP is a request–response protocol, like a waiter taking an order and bringing a dish. A request has a start line (method, path, version), headers (key: value pairs) and an optional body. A response has a status line (version, code, reason), headers and an optional body. HTTP is stateless: each request stands alone, and state such as a login is carried with cookies or tokens. In HTTP/2 and HTTP/3 the same messages are sent as binary frames, but the meaning is identical, so the text form below is still the best way to think about them.

http
POST /api/orders HTTP/1.1
Host: api.example.com
Content-Type: application/json
Authorization: Bearer <token>
Content-Length: 39
 
{"productId": 42, "quantity": 2}
 
HTTP/1.1 201 Created
Location: /api/orders/1001
Content-Type: application/json
 
{"id": 1001, "status": "pending"}

02Methods and their promises

GET reads a resource and must not change anything. HEAD is GET without a body, useful for checks. POST creates something or triggers an action. PUT replaces a resource completely, PATCH changes part of it, DELETE removes it. OPTIONS asks what is allowed and is used by CORS preflights. Two properties matter. Safe methods (GET, HEAD, OPTIONS) do not change state, so crawlers and prefetchers can call them freely. Idempotent methods (GET, HEAD, PUT, DELETE, OPTIONS) give the same result if repeated, so clients can retry them after a timeout. POST and PATCH are not idempotent by default; retrying a POST can create two orders unless the server uses an idempotency key.

  • Never change data on GET: a link preview or prefetch could trigger it.
  • Retry automatically only idempotent requests.
  • Use 405 Method Not Allowed with an Allow header for unsupported methods.

03Status code families

The first digit tells the story. 1xx informational (103 Early Hints lets a server send preload hints before the real response). 2xx success: 200 OK, 201 Created, 204 No Content. 3xx redirection: 301 and 308 permanent, 302 and 307 temporary (307 and 308 keep the method and body), 304 Not Modified for caching. 4xx client errors: 400 Bad Request, 401 Unauthorized (not authenticated), 403 Forbidden (authenticated but not allowed), 404 Not Found, 405, 409 Conflict, 410 Gone, 413 Payload Too Large, 415 Unsupported Media Type, 422 Unprocessable Content (validation), 429 Too Many Requests. 5xx server errors: 500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout. Correct codes let clients, caches, monitoring and search engines behave correctly without reading the body.

04Headers and cookies

Headers carry metadata. Request: Host, Accept, Accept-Language, Authorization, Content-Type, User-Agent, Cookie. Response: Content-Type, Content-Length, Location, Set-Cookie, Cache-Control and security headers such as Strict-Transport-Security, Content-Security-Policy, X-Content-Type-Options: nosniff and Referrer-Policy. Cookies are small values the server asks the browser to store and send back on every request to that site. Flags decide how safe they are. Secure: only sent over HTTPS. HttpOnly: JavaScript cannot read it, which limits damage from XSS. SameSite=Lax (the browser default) blocks sending on most cross-site requests, Strict blocks all, None allows cross-site but requires Secure. Path, Domain and Max-Age control scope and lifetime. A __Host- prefix forces Secure, Path=/ and no Domain.

http
HTTP/1.1 200 OK
Set-Cookie: __Host-session=abc123; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=7200
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Content-Security-Policy: default-src 'self'; img-src 'self' data:

05Caching: Cache-Control, ETag, 304

Caching avoids work. Cache-Control tells browsers and CDNs what to do: max-age=31536000, immutable for versioned assets like app.3f9a1c.js; no-cache for HTML that may be stored but must be revalidated each time; no-store for sensitive data that must never be stored; private for responses meant only for one user's browser; s-maxage for shared caches such as CDNs; stale-while-revalidate to serve a slightly old copy while refreshing in the background. Validators make revalidation cheap: the server sends an ETag (a fingerprint) or Last-Modified, the browser later sends If-None-Match or If-Modified-Since, and if nothing changed the server replies 304 Not Modified with no body. Vary tells caches which request headers change the response, such as Accept-Encoding.

http
GET /app.css HTTP/1.1
Host: example.com
If-None-Match: "a1b2c3"
 
HTTP/1.1 304 Not Modified
ETag: "a1b2c3"
Cache-Control: no-cache

06CORS without the confusion

Browsers enforce the same-origin policy: a page on https://app.example.com cannot read responses from https://api.example.com unless the API allows it. CORS (Cross-Origin Resource Sharing) is how the server grants that permission with response headers. Simple requests just need Access-Control-Allow-Origin. Requests with JSON bodies, custom headers like Authorization, or methods like PUT and DELETE trigger a preflight: the browser first sends OPTIONS asking whether the real request is allowed. If cookies are involved, the server must return the exact origin (not *) and Access-Control-Allow-Credentials: true. CORS is enforced only by browsers; it does not protect your API from curl or other servers, so you still need authentication.

http
OPTIONS /api/orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
 
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600
Vary: Origin

07Inspecting HTTP in practice

The browser's Network tab shows every request with headers, timing, cookies and the response. Right-click a request and choose Copy as cURL to reproduce it in a terminal. curl -v prints the full exchange; -I fetches only headers. For APIs, tools like Postman, Insomnia or HTTPie help, but plain curl in a runbook is the most portable. When debugging, compare a working and a failing request side by side: usually one header, cookie or status code explains the difference. Remember to remove tokens and cookies before sharing a copied request with anyone.

bash
curl -sI https://example.com/                      # headers only
curl -v https://api.example.com/health             # full exchange
curl -s -X POST https://api.example.com/orders \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"productId":42,"quantity":2}' -w "\n%{http_code}\n"

Use it when

  • Designing any API or web endpoint.
  • Debugging login, session, caching or cross-origin problems.
  • Setting security headers and cookie flags for a production site.
  • Integrating with third-party services and webhooks.

Skip it when

  • For constant real-time updates, plain request–response is wasteful; use WebSockets or Server-Sent Events.
  • For high-throughput internal service calls with strict schemas, gRPC can be a better fit.

Alternatives

  • WebSockets

    Two-way persistent connection for chat, live orders and dashboards.

  • Server-Sent Events

    One-way stream from server to browser over plain HTTP.

  • gRPC

    Binary RPC over HTTP/2 with generated clients, common between services.

Best practices

  1. Use the method and status code that match what actually happened.
  2. Set session cookies with Secure, HttpOnly and SameSite=Lax (or Strict).
  3. Cache versioned assets for a year with immutable; revalidate HTML.
  4. Use no-store for responses containing personal or payment data.
  5. Allow only known origins in CORS; never reflect any origin together with credentials.
  6. Send HSTS, nosniff, Referrer-Policy and a Content-Security-Policy.

Common mistakes

  • Returning 200 with an error message in the body, breaking clients and monitoring.
  • Mixing up 401 and 403.
  • Using Access-Control-Allow-Origin: * and expecting cookies to work, or reflecting any origin with credentials.
  • Changing data with GET requests.
  • Caching user-specific responses publicly at a CDN.

Deep dive

08Message framing and request smuggling

In HTTP/1.1 the receiver must know where a body ends. Content-Length gives the size in bytes; Transfer-Encoding: chunked sends the body in pieces, each with its own length, ending with a zero-size chunk. If a request carries both, or a malformed version of one, a front proxy and a back-end server may disagree about where the request ends. An attacker can then hide a second request inside the first body, which the back end treats as the start of the next user's request. This is HTTP request smuggling, and it can bypass security rules or poison caches. Defences: keep proxies and servers updated, reject requests with both headers or invalid framing, prefer HTTP/2 end to end where possible, and avoid hand-written proxies. HTTP/2 and HTTP/3 have their own framing, but downgrading to HTTP/1.1 at a proxy reintroduces the risk.

09Conditional requests for safe concurrent updates

Two admins open the same product, both edit it, and the second save silently overwrites the first: the lost update problem. HTTP solves it with validators. The GET response includes an ETag (a version fingerprint). The client sends the change with If-Match set to that ETag. If the resource changed in the meantime, the server refuses with 412 Precondition Failed and the client reloads and merges. If-None-Match: * on a PUT means create only if it does not exist, which prevents accidental overwrites. Servers can require preconditions on writes with 428 Precondition Required. Internally, this maps neatly onto a version column or updated_at check in a single UPDATE ... WHERE version = ? statement, so the check and the write are atomic.

http
GET /api/products/42 HTTP/1.1
 
HTTP/1.1 200 OK
ETag: "v7"
 
PATCH /api/products/42 HTTP/1.1
If-Match: "v7"
Content-Type: application/json
 
{"price": 4900}
 
HTTP/1.1 412 Precondition Failed      # someone saved v8 first

10Content negotiation, Vary and range requests

One URL can have several representations. The client states preferences with Accept, Accept-Language and Accept-Encoding, and the server picks one, ideally answering 406 when nothing fits. Any header that changes the response must be listed in Vary, or a cache may serve a Brotli body to a client that only accepts gzip, or a Russian page to an English visitor. Vary also splits the cache: Vary: User-Agent or Vary: Cookie can make caching nearly useless, so prefer separate URLs for languages and keep Vary to Accept-Encoding where possible. Range requests let a client ask for part of a file with Range: bytes=0-1023; the server answers 206 Partial Content and advertises support with Accept-Ranges: bytes. Video players and download managers depend on this for seeking and resuming.

bash
curl -s -o /dev/null -D - -H "Range: bytes=0-1023" https://example.com/video.mp4
# HTTP/2 206
# content-range: bytes 0-1023/48213004
# accept-ranges: bytes

11Cookies across sites, subdomains and frames

Site and origin are different ideas. An origin is scheme + host + port; a site is the registrable domain, so app.example.com and api.example.com are different origins but the same site. SameSite rules use sites, which is why a SameSite=Lax cookie is still sent between your subdomains. Cross-site, Lax cookies go only with top-level GET navigations, never with fetch or form POSTs from another site. Cookies set with a Domain attribute are shared with every subdomain, including ones run by a third-party service, so keep session cookies host-only. Third-party contexts are restricted: Safari and Firefox block third-party cookies by default, and Partitioned cookies (CHIPS) let an embedded widget keep state separately per top-level site. Browsers also limit cookie size, about 4 KB each, and every cookie is sent on every request, so large cookies cost bandwidth.

http
Set-Cookie: __Host-sid=8f2c...; Path=/; Secure; HttpOnly; SameSite=Lax
Set-Cookie: widget_state=abc; Path=/; Secure; SameSite=None; Partitioned

Real-world scenarios

01

Login works on desktop but not inside an embedded iframe

Problem

A booking widget runs in an iframe on partner sites. Users can log in on the main site, but inside the widget the session disappears after every request.

How to solve it

The session cookie is a third-party cookie in that context, so SameSite=Lax blocks it and some browsers block it entirely. Options: set the widget's cookie with SameSite=None; Secure; Partitioned so it works per partner site; or avoid cookies in the widget and use a short-lived token passed via postMessage and sent in the Authorization header. Do not loosen the main site's session cookie to SameSite=None, which reopens CSRF risk. Test in Safari and Firefox, which are strictest by default.

02

Customers see the wrong language from cache

Problem

The site detects language from Accept-Language and serves Russian or English at the same URL. After adding a CDN, visitors sometimes get the wrong language.

How to solve it

The response depends on a header the cache does not know about. The robust fix is separate URLs per language, such as /en/ and /ru/, with a one-time redirect from the root based on Accept-Language or a saved preference, and hreflang tags for search engines. Mark the redirect itself as private or Vary: Accept-Language so it is not shared. Adding Vary: Accept-Language to every page is possible but fragments the cache into many variants and some CDNs ignore it. Purge after the change and verify with curl using different Accept-Language values.

03

Two staff members overwrite each other's edits

Problem

In the admin panel, menu prices sometimes revert. Logs show two PATCH requests a minute apart from different users editing the same item.

How to solve it

Add optimistic concurrency. Return an ETag or a version number with each GET. Require If-Match on PATCH and PUT, and implement the update as UPDATE ... WHERE id = ? AND version = ?, incrementing the version. If no row changed, return 412 Precondition Failed with the current version. The admin UI then shows "this item was changed by someone else" and offers to reload. Return 428 if a client forgets the header, so old clients cannot bypass the check silently.

Cheat sheet

Show response headers only
curl -sI https://example.com/
Show request and response headers
curl -sv -o /dev/null https://example.com/ 2>&1 | grep -E "^[<>]"
Test a conditional GET
curl -sI -H 'If-None-Match: "a1b2c3"' https://example.com/app.css
Test a CORS preflight
curl -si -X OPTIONS https://api.example.com/orders -H "Origin: https://app.example.com" -H "Access-Control-Request-Method: POST"
Fetch part of a file
curl -s -r 0-1023 -o part.bin https://example.com/file.zip
Follow redirects and print each hop
curl -sIL https://example.com/old-page | grep -iE "^HTTP|^location"
Safe session cookie
Set-Cookie: __Host-sid=...; Path=/; Secure; HttpOnly; SameSite=Lax
Long-lived versioned asset
Cache-Control: public, max-age=31536000, immutable
Never store
Cache-Control: no-store

In my real projects

  • My Laravel and NestJS backends serve web frontends, admin panels and React Native apps over HTTP/JSON, with web and mobile channels sharing core logic.
  • In the pet-store app, the HTTP client retries only idempotent methods, so a timeout never creates a duplicate order.
  • Next.js sites I worked on use a custom server with security and cache headers.

Questions & answers

Q1What is the difference between 401 and 403?

401 Unauthorized actually means unauthenticated: the server does not know who you are, because the token or session is missing, expired or invalid. The client should log in or refresh credentials, and the response should include a WWW-Authenticate header for HTTP auth schemes. 403 Forbidden means the server knows who you are but you are not allowed to do this, for example a cashier trying to open finance reports. Logging in again will not help. For resources a user must not even know exist, such as another tenant's data, returning 404 is often better than 403 because it does not leak existence.

Q2Which HTTP methods are idempotent, and why does it matter?

GET, HEAD, OPTIONS, PUT and DELETE are idempotent: sending the same request twice leaves the server in the same state as sending it once. GET, HEAD and OPTIONS are also safe, meaning they should not change anything. POST and PATCH are not idempotent by default. It matters for retries: networks fail, and a client that did not get a response does not know if the server processed the request. Idempotent requests can be retried automatically. For POSTs such as payments or orders, I add an Idempotency-Key header so the server can return the original result instead of creating a duplicate.

Q3How does browser caching work with ETag and Cache-Control?

Cache-Control decides whether and how long a response can be reused. With max-age the browser uses its copy without asking until it expires. With no-cache it stores the copy but must revalidate before use. Revalidation uses validators: the server sent an ETag, so the browser asks with If-None-Match; if the resource has not changed, the server returns 304 Not Modified with no body, saving bandwidth and work. My usual setup is long max-age with immutable for hashed assets, no-cache with ETag for HTML, and no-store for private data such as account or payment pages.

Q4What is CORS and why does my request fail only in the browser?

CORS is a browser security mechanism. By default a script from one origin cannot read responses from another origin. The server can allow it by returning Access-Control-Allow-Origin and related headers. For non-simple requests, such as JSON bodies or an Authorization header, the browser sends a preflight OPTIONS request first, and if the server does not answer it correctly the real request never runs. curl and Postman do not enforce CORS, which is why the same call works there. The fix is server-side: allow the exact frontend origin, the needed methods and headers, and credentials if cookies are used.

Q5How do you make cookies secure?

Set Secure so the cookie is only sent over HTTPS, HttpOnly so JavaScript cannot read it and an XSS bug cannot steal the session, and SameSite=Lax or Strict so the browser does not attach it to most cross-site requests, which reduces CSRF. Scope it narrowly: avoid the Domain attribute unless subdomains need it, and consider the __Host- prefix, which enforces Secure, Path=/ and no Domain. Keep lifetimes short, rotate the session ID on login, and store only an opaque identifier, never personal data. For state-changing forms I still use CSRF tokens as defense in depth.

Q6What is the difference between 301, 302, 307 and 308?

301 and 308 are permanent redirects: browsers and search engines remember them and transfer ranking to the new URL. 302 and 307 are temporary: the original URL stays the canonical one. The difference inside each pair is the method. With 301 and 302, browsers historically change a POST into a GET on the new URL. 307 and 308 guarantee the method and body are kept. So for moving pages permanently, such as HTTP to HTTPS or an old slug to a new one, I use 301 or 308; for API endpoints that receive POSTs, 307 or 308; for short-term moves, 302 or 307.

Senior-level questions

S1How do you choose between cookies and bearer tokens for a web app plus a mobile app?

For the browser, I prefer an HttpOnly, Secure, SameSite session cookie on the same site as the app: JavaScript cannot steal it through XSS, the browser handles sending it, and server-side sessions are easy to revoke. The cost is CSRF, which SameSite plus CSRF tokens handles. For mobile apps, cookies are awkward, so I use short-lived access tokens with refresh tokens stored in the platform's secure storage. Putting long-lived tokens in browser localStorage is the option I avoid, because any XSS can read them. Many backends support both: the same auth service issues sessions for web and tokens for mobile. JWTs help when other services must verify identity without a lookup, but revocation becomes harder, so lifetimes must stay short.

S2What are the trade-offs of putting business rules into HTTP caching?

HTTP caching is extremely effective because browsers, CDNs and proxies all understand it, and it costs no code. But it moves part of your correctness into headers that many layers interpret slightly differently. Mistakes are severe: a public cache storing a private response is a data leak, and a long max-age on HTML means users cannot get a fix until it expires. I keep rules simple: immutable for hashed assets, short s-maxage plus stale-while-revalidate for public HTML, private or no-store for anything user-specific, and purges triggered by deploys or content events. I also write tests that assert headers on key routes, because header regressions do not show up in normal functional testing.

S3How would you protect an API from CSRF if it uses cookies?

First, use SameSite=Lax or Strict session cookies, which stop browsers from sending them on cross-site POST, PUT and DELETE requests and on fetch calls from other sites. Second, never change state with GET, since Lax still allows top-level GET navigations. Third, add a defence that does not depend on browser behaviour: a synchronizer token in forms, or a double-submit token sent in a custom header such as X-CSRF-Token, which other sites cannot set without passing CORS. Checking Origin or Sec-Fetch-Site headers against an allowlist is a cheap extra layer. CORS must not reflect arbitrary origins with credentials, or the whole scheme collapses. Pure bearer-token APIs are not vulnerable to CSRF, but they shift the risk to token storage.

Glossary

Origin
Scheme, host and port together, such as https://app.example.com:443; the unit of the same-origin policy.
Site
The registrable domain, such as example.com; subdomains of it are same-site. SameSite cookies use this idea.
Idempotent
Repeating the request has the same effect on the server as sending it once.
ETag
A version identifier for a response, used to revalidate caches and to prevent lost updates.
Preflight
An OPTIONS request the browser sends before certain cross-origin requests to ask permission.
Vary
A response header listing request headers that change the response, so caches store separate variants.
Chunked encoding
HTTP/1.1 way to send a body in pieces when its total size is not known in advance.
Request smuggling
An attack where a proxy and server disagree on request boundaries, letting a hidden request slip through.
206 Partial Content
The response to a Range request, carrying only the requested bytes of a file.
CHIPS
Cookies Having Independent Partitioned State: cookies marked Partitioned, stored separately for each top-level site.

Have a product in mind?Let's build it.

Tell me about your idea, your product or your team. I usually reply within one working day.