Prologue: "What's the difference between GET and POST?"
I was building my first API. Working on a simple message board backend with Node.js + Express when my teammate asked:
"We should use POST for creating posts, right? But what's actually different from GET?"
I froze. "Um... GET shows in the address bar and POST doesn't..." That's all I had. I didn't know why. I'd just memorized the pattern: "GET for fetching posts, POST for creating them."
Then I hit a bigger wall while building login functionality. Login worked fine, but the next request returned "unauthenticated user." The server seemed to forget the login from 1 second ago. "Does the server have dementia?" I wondered.
That's when I realized: I couldn't build web applications without properly understanding HTTP. So I dove in.
What Confused Me Initially
The name "HyperText Transfer Protocol" itself was confusing
- HyperText: Text with links? Then what about images or JSON?
- Transfer: Transfer what exactly?
- Protocol: Rules for whom to follow?
Stateless was the most confusing concept
"The server doesn't maintain state"
This made no sense. When you log in, shouldn't the server remember "this is a logged-in user"? But it can't remember? Then how does login persist? I couldn't understand.
And what makes HTTPS different?
Just adding an S to HTTP makes it "secure"? What exactly happens to make it safe?
The Aha Moment: "The Amnesiac Mailman"
Then a senior developer gave me this analogy that made everything click:
HTTP is a mailman.
- You: "Deliver this letter" (Request)
- Mailman: "Here you go" (Response)
But this mailman has amnesia. Delivered to you 1 minute ago, but 1 minute later asks "Who are you?"
So you use sticky notes (Cookies): "I'm John. Member ID 12345." Show this note to the mailman → "Oh, John!" (recognized)
Everything made sense in that moment. "That's what it was all along." Stateless wasn't a bug—it was intentional design. Cookies/Sessions were the workaround for this limitation.
HTTP's History: Why Was It Built This Way?
HTTP/0.9 (1991): It Started Simple
The first HTTP Tim Berners-Lee created was truly minimal:
GET /page.html
That's it. Only GET existed. No headers. Only HTML could be transferred. It literally was just a "Protocol to Transfer HyperText."
HTTP/1.0 (1996): Headers Were Born
As the internet grew, HTML alone wasn't enough. We needed to send images, know which browser was requesting. So headers were added:
GET /page.html HTTP/1.0
User-Agent: Mozilla/1.0
Accept: text/html
HTTP/1.0 200 OK
Content-Type: text/html
Content-Length: 1234
<html>...</html>
Now we could specify "what to send (Content-Type)" and "how much (Content-Length)."
HTTP/1.1 (1997): Persistent Connections
HTTP/1.0's problem: it opened a new connection for every request. A webpage with 10 images meant 11 connect-disconnect cycles. Inefficient.
HTTP/1.1 introduced Keep-Alive, allowing connection reuse. The Host header became mandatory, enabling virtual hosting (multiple domains on one IP).
GET / HTTP/1.1
Host: example.com
Connection: keep-alive
This version was the web's standard for 18 years until 2015. Still widely used today.
What About HTTP/2 and HTTP/3?
HTTP/2 (2015) improved speed. Multiple requests could be sent simultaneously over one connection (multiplexing), and headers were compressed.
HTTP/3 (2022) abandoned TCP entirely for QUIC (UDP-based). Even faster.
But the core concepts remain identical to HTTP/1.1: Request-Response, Stateless, header-body structure. The fundamentals haven't changed.
Request Anatomy: Dissecting a Request
When I first saw an HTTP request, I thought "this looks complicated." But it's actually simple when broken down.
Request Structure
POST /api/posts HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Cookie: session_id=abc123; theme=dark
Content-Length: 45
{"title": "New post", "content": "Hello"}
Four parts:
1. Request Line (First Line)
POST /api/posts HTTP/1.1
- Method (POST): What to do
- Path (/api/posts): Where to send
- Version (HTTP/1.1): HTTP version
2. Headers
Metadata. Headers I commonly use:
Host: Which domain to send to (required)
Host: example.com
User-Agent: Which browser/client
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)
Server uses this to distinguish mobile/desktop.
Accept: Desired response format
Accept: application/json
"Give me JSON please." Could be text/html, image/png, etc.
Content-Type: Format of data I'm sending
Content-Type: application/json
Required for POST/PUT. Server knows "oh, they sent JSON" and parses accordingly.
Authorization: Auth token
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
JWT tokens go here.
Cookie: Cookies
Cookie: session_id=abc123; theme=dark; user_id=456
Browser attaches this automatically. Semicolons separate multiple cookies.
3. Blank Line
Separates headers from body. Without this, we wouldn't know where headers end.
4. Body
Actual data. GET/DELETE usually have no body. POST/PUT do.
{"title": "New post", "content": "Hello"}
Response Anatomy: Dissecting a Response
Server responses have similar structure:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: max-age=3600, public
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Set-Cookie: session_id=xyz789; HttpOnly; Secure; SameSite=Lax
Content-Encoding: gzip
Content-Length: 512
{"id": 123, "title": "New post", "author": "John"}
1. Status Line (First Line)
HTTP/1.1 200 OK
- Version (HTTP/1.1)
- Status Code (200): Success/failure
- Status Message (OK): Human-readable description
2. Response Headers
Content-Type: What's being sent
Content-Type: application/json
Cache-Control: Caching policy
Cache-Control: max-age=3600, public
"Cache for 3600 seconds (1 hour). Public caches (like CDNs) OK too"
ETag: Resource version
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Content hash. Next request can ask "I have this version, has it changed?"
Set-Cookie: Set a cookie
Set-Cookie: session_id=xyz789; HttpOnly; Secure; SameSite=Lax
Tells browser "save this cookie."
Content-Encoding: Compression method
Content-Encoding: gzip
"Compressed with gzip. Decompress to use"
3. Blank Line and Body
Same as request.
HTTP Methods: Beyond CRUD
Initially I only knew GET/POST. Turns out there are more.
GET: Read
GET /posts/123
Characteristics:
- Only fetches data
- Doesn't change server state (Safe)
- Multiple calls return same result (Idempotent)
- No body
- Data via query string
GET /search?q=keyword&page=1&limit=10
When to use: Fetch posts, search, view profile
POST: Create
POST /posts
Content-Type: application/json
{"title": "New post", "content": "Content"}
Characteristics:
- Creates new resource on server
- Changes server state (Not Safe)
- Multiple calls create multiple resources (Not Idempotent)
- Data in body
When to use: Create post, register user, upload file
PUT: Full Update
PUT /posts/123
Content-Type: application/json
{"title": "Updated title", "content": "Updated content"}
Characteristics:
- Completely replaces resource
- Multiple calls have same result (Idempotent)
- Can create if doesn't exist (depends on implementation)
Warning: Sending partial data deletes the rest.
PUT /posts/123
{"title": "Only title"}
This deletes the content field!
PATCH: Partial Update
PATCH /posts/123
Content-Type: application/json
{"title": "Only title updated"}
Characteristics:
- Updates only specified fields
contentremains unchanged- Safer than PUT
When to use: Update only post title, change only profile picture
DELETE: Delete
DELETE /posts/123
Characteristics:
- Deletes resource
- Multiple calls have same result (Idempotent)
- Second call onwards may return 404
HEAD: Get Headers Only
HEAD /posts/123
Like GET but without body. Only headers returned.
When to use: Check file size only, verify resource exists
// Check file size before download
const response = await fetch('/large-file.zip', { method: 'HEAD' });
const size = response.headers.get('Content-Length');
console.log(`File size: ${size} bytes`);
OPTIONS: Check Supported Methods
OPTIONS /posts/123
HTTP/1.1 200 OK
Allow: GET, PUT, PATCH, DELETE
Access-Control-Allow-Methods: GET, PUT, DELETE
When to use: CORS preflight requests automatically use this. Browser asks "can I use this method?"
Idempotency: Important Concept
Idempotent = multiple identical requests produce same result
- GET, PUT, DELETE: Idempotent
- Calling GET /posts/123 ten times returns same post
- Calling DELETE /posts/123 ten times results in "deleted"
- POST: Not Idempotent
- Calling POST /posts ten times creates 10 posts
Why does this matter? It determines whether retrying after network errors is safe.
// DELETE is idempotent, so retry OK
async function deletePost(id) {
let retries = 3;
while (retries > 0) {
try {
await fetch(`/posts/${id}`, { method: 'DELETE' });
break;
} catch (err) {
retries--;
if (retries === 0) throw err;
}
}
}
Status Codes: What Numbers Tell You
Initially I only knew "200 is success, 404 is not found, 500 is server dead." Turns out there's much more variety.
1xx: Informational (Rarely Used)
100 Continue: "Got request headers, send body now"
For large file uploads, send headers first. If server responds 100, send body. If server refuses, don't send body, saving bandwidth.
2xx: Success
200 OK: Success
GET /posts/123
→ 200 OK
201 Created: Created successfully
POST /posts
→ 201 Created
Location: /posts/456
Location header contains new resource URL.
204 No Content: Success but nothing to return
DELETE /posts/123
→ 204 No Content
After successful deletion, nothing to return, right? Use 204.
206 Partial Content: Partial transfer
GET /video.mp4
Range: bytes=0-1023
→ 206 Partial Content
Content-Range: bytes 0-1023/5000000
This is the secret of video streaming. YouTube doesn't download entire videos—it fetches needed chunks. This is how.
3xx: Redirection
301 Moved Permanently: Permanent move
GET /old-page
→ 301 Moved Permanently
Location: /new-page
Search engines learn "oh, this URL changed" and update their index.
302 Found: Temporary move
GET /admin
→ 302 Found
Location: /login
"Go to login page now. Come back to /admin later"
307 Temporary Redirect: Like 302 but preserves method
POST /old-api
→ 307 Temporary Redirect
Location: /new-api
302 may change POST to GET. 307 keeps POST.
308 Permanent Redirect: 301's 307 version
Permanent move that doesn't change method.
4xx: Client Error (My Fault)
400 Bad Request: Malformed request
{"error": "title is required"}
Missing required fields, JSON syntax errors, etc.
401 Unauthorized: Authentication required
GET /api/profile
→ 401 Unauthorized
WWW-Authenticate: Bearer
"Log in first"
403 Forbidden: Insufficient permissions
DELETE /posts/999
→ 403 Forbidden
{"error": "You are not the author"}
Logged in but trying to delete someone else's post.
404 Not Found: Doesn't exist
GET /posts/99999
→ 404 Not Found
429 Too Many Requests: Too many requests
POST /api/login
→ 429 Too Many Requests
Retry-After: 60
{"error": "Too many login attempts. Try again in 60 seconds"}
Rate limiting. Defends against DDoS and brute force attacks.
5xx: Server Error (Server's Fault)
500 Internal Server Error: Server crashed
// Server dies from unhandled exception
app.get('/posts', (req, res) => {
const posts = null;
res.json(posts.filter(p => p.published)); // 💥 Cannot read property 'filter' of null
});
502 Bad Gateway: Gateway (proxy) problem
nginx (reverse proxy)
→ backend server (no response)
→ 502 Bad Gateway
Backend is dead or responding too slowly.
503 Service Unavailable: Server overload
→ 503 Service Unavailable
Retry-After: 120
Server under maintenance or too much traffic. Retry-After says "come back in 120 seconds."
Stateless: Why Can't the Server Remember?
This was the hardest concept to grasp. "Why intentionally make it inconvenient?"
The Problem: Server Amnesia
1. Client: "Logging in. ID: admin, PW: 1234"
2. Server: "Confirmed. Login successful!"
(1 second later)
3. Client: "Show my profile"
4. Server: "Who are you? Please log in first"
Server forgets login from 1 second ago. This is Stateless.
Why Design It This Way: Horizontal Scaling
If Stateful, this happens:
Server A: "John logged in. Must remember"
→ Stores in memory: { user: "John", logged_in: true }
(Next request goes to Server B)
Server B: "John? Who's that? Don't know"
When load balancer distributes requests across servers, Server B doesn't know what Server A remembered.
Solution 1: Sticky Session
Load balancer: "Always send John to Server A"
Problem: If Server A dies? John gets logged out.
Solution 2: Stateless + External Store
Both Server A and Server B check Redis
Redis: { session_id: "abc123" → { user: "John" } }
Any server can check Redis. This is modern web architecture.
Stateless Advantages
1. Easy Horizontal Scaling
1 server → 10 servers → 100 servers
All work identically
2. Server Restart OK
Server update → restart
Users don't get logged out (sessions in DB/Redis)
3. Simplicity
Each request is independent
Easy debugging
I finally understood. Stateless is a tradeoff for scalability.
Cookies: The Sticky Note System
If the server can't remember, the client must. That's cookies.
Basic Operation
1. Login success
Server → Client: "Set-Cookie: session_id=abc123"
2. Browser saves cookie
3. Next request
Client → Server: "Cookie: session_id=abc123"
(Browser auto-attaches)
4. Server: "abc123? Oh, John!" (DB lookup)
Cookie Attributes: Critical Security Settings
Set-Cookie: session_id=abc123; HttpOnly; Secure; SameSite=Lax; Max-Age=3600; Path=/; Domain=.example.com
Initially I thought "why so many options?" Each one matters.
HttpOnly: Block JavaScript access
// ❌ Doesn't work
document.cookie; // session_id not visible
Defends against XSS. Prevents hackers from stealing cookies with JavaScript.
Secure: HTTPS only
HTTP requests don't send cookie
HTTPS requests send cookie
Defends against WiFi hacking.
SameSite: CSRF defense
SameSite=Strict: Only send cookie for same-site requests
SameSite=Lax: Link clicks OK, POST not OK
SameSite=None: Send cookie for cross-site (Secure required)
Max-Age: Lifetime (seconds)
Max-Age=3600 → Delete after 1 hour
Max-Age=0 → Delete immediately (logout)
Expires: Expiration date
Expires=Wed, 21 Oct 2026 07:28:00 GMT
Similar to Max-Age but specifies date. Max-Age takes precedence.
Domain: Which domain can use
Domain=.example.com
→ OK for example.com, www.example.com, api.example.com
Path: Which path can use
Path=/api
→ OK for /api/users, not OK for /login
Sessions: Server-Side Memory
Storing everything in cookies poses security risks. So store only Session ID in cookie, actual data on server (DB/Redis).
// Login handling
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user || !user.checkPassword(password)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Create session
const sessionId = crypto.randomUUID();
await redis.set(sessionId, JSON.stringify({ userId: user.id }), 'EX', 3600); // 1 hour
// Set cookie
res.cookie('session_id', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 3600000 // 1 hour (milliseconds)
});
res.json({ message: 'Login successful' });
});
// Auth middleware
async function authenticate(req, res, next) {
const sessionId = req.cookies.session_id;
if (!sessionId) {
return res.status(401).json({ error: 'Not authenticated' });
}
const session = await redis.get(sessionId);
if (!session) {
return res.status(401).json({ error: 'Session expired' });
}
req.user = JSON.parse(session);
next();
}
// Protected route
app.get('/api/profile', authenticate, async (req, res) => {
const user = await User.findById(req.user.userId);
res.json(user);
});
HTTP Caching: No Need to Re-Send Same Data
When I first built a website, I wondered "why does the image download every time?" I didn't know about caching.
Cache-Control: Caching Policy
Cache-Control: max-age=3600, public
- max-age=3600: Use cache for 3600 seconds (1 hour)
- public: Public caches (CDNs) can store
- private: Only browser can store (personal info)
- no-cache: Can cache but check with server first
- no-store: Don't cache at all (sensitive info)
// Static files: 1 year cache
app.use('/static', express.static('public', {
maxAge: '1y'
}));
// API responses: no cache
app.get('/api/posts', (req, res) => {
res.set('Cache-Control', 'no-store');
res.json(posts);
});
ETag + If-None-Match: Conditional Requests
1. First request
GET /api/posts
→ 200 OK
ETag: "abc123"
[post data]
2. Next request (with ETag)
GET /api/posts
If-None-Match: "abc123"
3a. If unchanged
→ 304 Not Modified
(no body, browser uses cache)
3b. If changed
→ 200 OK
ETag: "def456"
[new data]
304 Not Modified is true innovation. No body sent, saving massive bandwidth.
app.get('/api/posts', async (req, res) => {
const posts = await Post.findAll();
const etag = crypto.createHash('md5').update(JSON.stringify(posts)).digest('hex');
// Compare with client's ETag
if (req.headers['if-none-match'] === etag) {
return res.status(304).end(); // Just 304, no body
}
res.set('ETag', etag);
res.set('Cache-Control', 'max-age=60');
res.json(posts);
});
Last-Modified + If-Modified-Since: Time-Based
1. First request
GET /api/posts
→ 200 OK
Last-Modified: Wed, 21 Oct 2025 07:28:00 GMT
2. Next request
GET /api/posts
If-Modified-Since: Wed, 21 Oct 2025 07:28:00 GMT
3. If unchanged
→ 304 Not Modified
Less precise than ETag but simpler.
Content Negotiation: Give Clients What They Want
Same URL can return JSON or HTML. Depends on what client wants.
Accept Header
GET /api/posts
Accept: application/json
→ JSON response
GET /api/posts
Accept: text/html
→ HTML response
app.get('/api/posts', async (req, res) => {
const posts = await Post.findAll();
if (req.accepts('json')) {
res.json(posts);
} else if (req.accepts('html')) {
res.render('posts', { posts });
} else {
res.status(406).send('Not Acceptable');
}
});
Accept-Encoding: Compression
GET /api/posts
Accept-Encoding: gzip, br
→ Compress response with gzip or Brotli
const compression = require('compression');
app.use(compression()); // Automatic gzip compression
Text compresses 70-90%. JSON 10KB → gzip 1KB. Huge savings.
Accept-Language: Multilingual
GET /api/posts
Accept-Language: ko-KR, en-US
→ Korean if available, otherwise English
HTTP vs HTTPS: Plaintext vs Encrypted
HTTP's Problem: Everything Visible
Login at cafe WiFi
Client → Router: "GET /login?password=1234"
↓
Hacker captures WiFi packets
↓
Hacker: "Password is 1234? lol"
Plaintext transmission risks:
- Eavesdropping: Anyone can read
- Tampering: Content can be modified
- Impersonation: Fake servers can pretend to be real
HTTPS: SSL/TLS Encryption
Client → Server: (encrypted data)
↓
Hacker captures packets
↓
Hacker: "aX93jK2... what is this? Can't read"
What HTTPS solves:
- Encryption: Can't read content
- Integrity: Tampering detected
- Authentication: Verify server is real (certificate)
How HTTPS Works (Simplified)
1. Client: "Starting HTTPS connection"
2. Server: "Here's my certificate" (includes public key)
3. Client: Verifies certificate (signed by CA?)
4. Client: Generates symmetric key → encrypts with server's public key → sends
5. Server: Decrypts with private key → obtains symmetric key
6. Now encrypted communication with symmetric key
Real Code: Express.js Server
Actual API server code I built:
const express = require('express');
const cookieParser = require('cookie-parser');
const cors = require('cors');
const compression = require('compression');
const redis = require('redis');
const crypto = require('crypto');
const app = express();
const redisClient = redis.createClient();
// Middleware
app.use(express.json());
app.use(cookieParser());
app.use(compression()); // gzip compression
app.use(cors({
origin: 'https://example.com',
credentials: true // Allow cookies
}));
// Login
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
// Validation
if (!email || !password) {
return res.status(400).json({ error: 'Email and password required' });
}
// User verification (actual: DB lookup)
const user = { id: 1, email: 'user@example.com', name: 'John' };
// Create session
const sessionId = crypto.randomUUID();
await redisClient.setEx(
`session:${sessionId}`,
3600, // 1 hour
JSON.stringify(user)
);
// Set cookie
res.cookie('session_id', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 3600000
});
res.status(200).json({ message: 'Login successful', user });
});
// Auth middleware
async function authenticate(req, res, next) {
const sessionId = req.cookies.session_id;
if (!sessionId) {
return res.status(401).json({ error: 'Not authenticated' });
}
const session = await redisClient.get(`session:${sessionId}`);
if (!session) {
return res.status(401).json({ error: 'Session expired' });
}
req.user = JSON.parse(session);
next();
}
// Post list (caching + ETag)
app.get('/api/posts', async (req, res) => {
const posts = [
{ id: 1, title: 'First post', author: 'John' },
{ id: 2, title: 'Second post', author: 'Jane' }
];
// Generate ETag
const etag = crypto.createHash('md5')
.update(JSON.stringify(posts))
.digest('hex');
// 304 Not Modified handling
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.set({
'ETag': etag,
'Cache-Control': 'max-age=300, public' // 5 min cache
});
res.json(posts);
});
// Create post (auth required)
app.post('/api/posts', authenticate, async (req, res) => {
const { title, content } = req.body;
if (!title || !content) {
return res.status(400).json({ error: 'Title and content required' });
}
const post = {
id: Date.now(),
title,
content,
author: req.user.name,
createdAt: new Date()
};
// Save to DB (omitted)
res.status(201)
.set('Location', `/api/posts/${post.id}`)
.json(post);
});
// Fetch post (caching)
app.get('/api/posts/:id', async (req, res) => {
const post = { id: req.params.id, title: 'Post title' };
res.set({
'Cache-Control': 'max-age=3600, public',
'Last-Modified': new Date('2025-01-01').toUTCString()
});
res.json(post);
});
// Update post (auth required)
app.patch('/api/posts/:id', authenticate, async (req, res) => {
const { title } = req.body;
// Permission check (omitted)
const updatedPost = { id: req.params.id, title };
res.json(updatedPost);
});
// Delete post (auth required)
app.delete('/api/posts/:id', authenticate, async (req, res) => {
// Delete handling (omitted)
res.status(204).end(); // No Content
});
// Rate limiting
const rateLimits = new Map();
app.post('/api/login-limited', async (req, res) => {
const ip = req.ip;
const now = Date.now();
const windowMs = 60000; // 1 min
const maxRequests = 5;
if (!rateLimits.has(ip)) {
rateLimits.set(ip, []);
}
const requests = rateLimits.get(ip).filter(time => now - time < windowMs);
if (requests.length >= maxRequests) {
return res.status(429)
.set('Retry-After', '60')
.json({ error: 'Too many requests' });
}
requests.push(now);
rateLimits.set(ip, requests);
// Login handling...
res.json({ message: 'OK' });
});
app.listen(3000, () => {
console.log('Server running on http://localhost:3000');
});
Real Code: Client (fetch API)
Frontend HTTP request code:
// GET request
async function getPosts() {
const response = await fetch('https://api.example.com/posts', {
method: 'GET',
headers: {
'Accept': 'application/json'
},
credentials: 'include' // Include cookies
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// Check caching headers
const cacheControl = response.headers.get('Cache-Control');
const etag = response.headers.get('ETag');
console.log('Cache:', cacheControl, 'ETag:', etag);
return response.json();
}
// POST request (login)
async function login(email, password) {
const response = await fetch('https://api.example.com/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
credentials: 'include', // Receive cookies
body: JSON.stringify({ email, password })
});
if (response.status === 401) {
throw new Error('Invalid credentials');
}
if (!response.ok) {
throw new Error('Login failed');
}
return response.json();
}
// Authenticated request
async function getProfile() {
const response = await fetch('https://api.example.com/profile', {
credentials: 'include' // Auto-send cookies
});
if (response.status === 401) {
// Redirect to login
window.location.href = '/login';
return;
}
return response.json();
}
// Conditional request with ETag
let cachedPosts = null;
let cachedETag = null;
async function getPostsWithCache() {
const headers = {
'Accept': 'application/json'
};
if (cachedETag) {
headers['If-None-Match'] = cachedETag;
}
const response = await fetch('https://api.example.com/posts', {
headers,
credentials: 'include'
});
if (response.status === 304) {
console.log('Using cached data');
return cachedPosts; // Use cached data
}
cachedETag = response.headers.get('ETag');
cachedPosts = await response.json();
return cachedPosts;
}
// Error handling
async function robustFetch(url, options) {
try {
const response = await fetch(url, options);
// Handle by status code
switch (response.status) {
case 200:
case 201:
return response.json();
case 204:
return null; // No Content
case 304:
return { cached: true };
case 400:
const error = await response.json();
throw new Error(`Validation error: ${error.message}`);
case 401:
window.location.href = '/login';
break;
case 403:
throw new Error('Permission denied');
case 404:
throw new Error('Resource not found');
case 429:
const retryAfter = response.headers.get('Retry-After');
throw new Error(`Rate limited. Retry after ${retryAfter}s`);
case 500:
case 502:
case 503:
throw new Error('Server error. Please try again later.');
default:
throw new Error(`HTTP ${response.status}`);
}
} catch (err) {
if (err.name === 'TypeError') {
// Network error
throw new Error('Network error. Check your connection.');
}
throw err;
}
}
Mistakes I Made: My HTTP Blunders
Mistake 1: Sending Password via GET
When first building login functionality, I did this:
// ❌ Never do this
async function login(email, password) {
const response = await fetch(`/login?email=${email}&password=${password}`);
return response.json();
}
Problems:
- Password exposed in URL
- Saved in browser history (Ctrl+H reveals it)
- Logged in server logs (
[GET] /login?password=1234) - Also in proxy logs
// ✅ Correct way
async function login(email, password) {
const response = await fetch('https://example.com/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
return response.json();
}
Mistake 2: CORS Error
Frontend (localhost:3000) → Backend (localhost:4000):
Access to fetch at 'http://localhost:4000/api' from origin
'http://localhost:3000' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
Initially thought "why doesn't same computer work?" Different ports = different origins.
Solution:
// Backend
const cors = require('cors');
app.use(cors({
origin: 'http://localhost:3000',
credentials: true // Allow cookies
}));
// Frontend
fetch('http://localhost:4000/api', {
credentials: 'include' // Send cookies
});
Mistake 3: Confusing POST with Query String
// ❌ Strangely mixed
fetch('/api/posts?category=tech', {
method: 'POST',
body: JSON.stringify({ title: 'Title' })
});
"Why is category in query string?" Confusing later.
Guidelines:
- Filtering, pagination: Query string
GET /api/posts?category=tech&page=1 - Resource identification: URL path
GET /api/posts/123 DELETE /api/users/456 - Create/update data: Body
POST /api/posts { "title": "Title", "content": "Content" }
Mistake 4: Updates Not Showing Due to Caching
Updated a post but frontend didn't show changes. Browser was using cache.
Solution:
// Invalidate cache after update/delete
app.patch('/api/posts/:id', async (req, res) => {
// Update...
res.set('Cache-Control', 'no-cache'); // Don't use cache
res.json(updatedPost);
});
Or change ETag so browser knows "oh, it changed."
Mistake 5: Trying to Read HttpOnly Cookie with JavaScript
// ❌ Doesn't work
const sessionId = document.cookie; // session_id not visible
HttpOnly cookies block JavaScript access. Browser auto-attaches them, no need to worry.
Mistake 6: No Error Handling Crashes App
// ❌ No exception handling
app.get('/api/posts', (req, res) => {
const posts = null;
res.json(posts.filter(p => p.published)); // 💥 Server dies
});
// ✅ try-catch
app.get('/api/posts', async (req, res) => {
try {
const posts = await Post.findAll();
res.json(posts);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
}
});
Key Takeaways
What I learned from studying HTTP:
1. Request-Response Structure
- Client requests (Request), server responds (Response)
- Method + URL + headers + body
- Status code indicates success/failure
2. Stateless Is Design for Scalability
- Server doesn't remember
- Solved with Cookies/Sessions
- Easy to scale across multiple servers
3. Methods Express Intent
- GET: Read (safe, idempotent)
- POST: Create (not safe, not idempotent)
- PUT: Full update (not safe, idempotent)
- PATCH: Partial update
- DELETE: Delete (not safe, idempotent)
4. HTTPS Is Mandatory
- HTTP: Plaintext, can be eavesdropped
- HTTPS: Encrypted, secure
- Passwords must use HTTPS + POST
5. Caching Is Key to Performance
- Cache-Control sets policy
- ETag enables conditional requests
- 304 Not Modified saves bandwidth
6. Cookies Need Security Settings
- HttpOnly: XSS defense
- Secure: HTTPS only
- SameSite: CSRF defense
Initially thought "just data exchange." Now I understand HTTP is the language of the web. Every request header, every cookie attribute has a reason. Understanding them enables building safer, faster web applications.
The "amnesiac mailman" analogy made everything click. Understanding HTTP meant understanding how the web works.