You've heard the word a thousand times. APIs. But most articles stop at the waiter analogy and call it a day. This one doesn't. By the end of this article you'll understand not just what an API is, but why it's designed the way it is, what actually happens at the network level when a request is made, how a server handles thousands of concurrent connections, and how to build and host your own API from scratch using Python and Flask. We'll also look at the mistakes developers make when building APIs and how to avoid them.
Let's go deep.
The Real Reason APIs Exist
Before HTTP, before REST, before JSON — software systems still needed to talk to each other. Early solutions were nightmares: shared memory regions, custom binary protocols, raw TCP sockets with hand-rolled parsing. Every integration was a one-off engineering project.
APIs are the answer to a design question: how do we let two systems communicate without either one needing to know the internal implementation details of the other?
This is the principle of encapsulation applied to networked systems. A good API is a contract. It says: "Send me a request in this shape, I'll send back a response in that shape. What I do in between is none of your business."
That contract is what makes software composable. Stripe doesn't care if you built your frontend in React or Django. They expose an API and you call it. The internals are invisible. This is not a convenience — it's a fundamental architectural principle that enables the entire modern software ecosystem.
What HTTP Actually Is (And Why APIs Use It)
Almost every API you'll encounter is an HTTP API. To understand APIs deeply, you need to understand what HTTP is doing under the hood.
HTTP (HyperText Transfer Protocol) is a text-based, application-layer protocol built on top of TCP/IP. When your browser (or your Python script) makes an HTTP request, here's what actually travels over the wire:
GET /v1/forecast?city=London HTTP/1.1
Host: api.weather.com
Accept: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Connection: keep-alive
That's it. Plain text. The server reads this, parses it, does some work, and sends back:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 87
Date: Mon, 27 Mar 2026 12:00:00 GMT
{
"city": "London",
"temperature": 14,
"unit": "C",
"condition": "Overcast"
}
Two blocks of text separated by a blank line. Everything above the blank line is headers (metadata about the request/response). Everything below is the body (the actual payload).
Understanding this is important because it demystifies the whole thing. There's no magic. An API endpoint is just a server that listens on a port, reads these text messages, and writes back other text messages. Every framework — Flask, FastAPI, Express, Rails — is ultimately doing this, just with a lot of ergonomic scaffolding on top.
The TCP Handshake: What Happens Before Your Request Even Sends
Before any HTTP data flows, TCP (Transmission Control Protocol) has to establish a connection. This is the three-way handshake:
- SYN — Your machine sends a SYN (synchronize) packet to the server's IP and port. "I want to connect."
- SYN-ACK — The server responds with SYN-ACK. "Okay, I acknowledge. I'm ready."
- ACK — Your machine sends ACK. "Great. Connection established."
Only after this handshake does your HTTP request actually get sent. This is why latency matters. If the server is geographically far from you, even before your API call is processed, you've already spent time just shaking hands.
With HTTP/1.1, connections can be kept alive (reused for multiple requests). With HTTP/2, multiple requests can be multiplexed over a single connection simultaneously. This is why upgrading your server infrastructure isn't just about CPU — network protocol choices have real performance implications.
DNS: How api.weather.com Becomes an IP Address
You don't make requests to IP addresses directly in most cases. You use domain names. But underneath, networks only understand numbers.
When you call https://api.weather.com/v1/forecast, before the TCP handshake even begins, your operating system does a DNS lookup:
- Checks the local cache. Found? Use it.
- Asks your router. Found? Use it.
- Asks a recursive DNS resolver (usually your ISP's or Google's 8.8.8.8).
- That resolver walks up the DNS hierarchy: root servers →
.comTLD servers →weather.comauthoritative nameserver. - Gets back:
api.weather.com → 104.22.6.49
Now your machine knows the actual IP to connect to. This entire process can happen in under 10 milliseconds when cached, or tens to hundreds of milliseconds on a cold lookup. This is why CDNs and DNS TTL tuning are real performance levers in production systems.
REST: A Style, Not a Standard
REST is not a protocol. It's not a library. It's not something you install. It's an architectural style — a set of constraints described by Roy Fielding in his 2000 PhD dissertation.
The six constraints of REST:
- Client-Server — Concerns are separated. The client handles the UI, the server handles data and logic. They communicate only through the interface.
- Stateless — Every request must contain all the information needed to process it. The server holds no session state between requests. This is why you send your auth token on every request instead of logging in once.
- Cacheable — Responses must declare whether they're cacheable. This enables CDNs and browser caches to reduce server load.
- Uniform Interface — This is the big one. All resources are identified by URLs. Actions are expressed via HTTP methods. Responses describe how to modify the resource (HATEOAS, though rarely implemented in practice).
- Layered System — The client doesn't know whether it's talking directly to the server or to a load balancer, a cache, or a gateway. Each layer only knows about the layer immediately above and below.
- Code on Demand (optional) — Servers can send executable code to clients (like JavaScript). Most REST APIs don't use this.
When people say "RESTful API," they usually mean: uses HTTP methods correctly, uses URLs as resource identifiers, returns JSON, and is stateless. The full Fielding definition is rarely implemented completely, especially HATEOAS.
The key practical takeaways:
- Use nouns in URLs, not verbs:
/users/123not/getUser?id=123 - Use HTTP methods for the action:
GET /users/123,DELETE /users/123,PATCH /users/123 - Use status codes correctly (more on this shortly)
- Keep it stateless — don't store session data server-side between requests
HTTP Status Codes: Not Optional
Bad APIs return 200 OK with {"error": "user not found"} in the body. Don't be that API. Status codes exist precisely to communicate outcome at the protocol level, before the client even parses the body.
2xx — Success
| Code | Meaning | Use When |
|---|---|---|
| 200 | OK | GET, PUT, PATCH succeeded with a response body |
| 201 | Created | POST successfully created a resource |
| 204 | No Content | DELETE succeeded; nothing to return |
3xx — Redirection
| Code | Meaning | Use When |
|---|---|---|
| 301 | Moved Permanently | Resource has a new URL forever |
| 304 | Not Modified | Cached version is still valid |
4xx — Client Error (The caller did something wrong)
| Code | Meaning | Use When |
|---|---|---|
| 400 | Bad Request | Malformed request, invalid parameters |
| 401 | Unauthorized | No credentials / invalid credentials |
| 403 | Forbidden | Valid credentials, but no permission |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | Duplicate entry, version conflict |
| 422 | Unprocessable Entity | Validation failed (correct format, invalid values) |
| 429 | Too Many Requests | Rate limit exceeded |
5xx — Server Error (Something on your end broke)
| Code | Meaning | Use When |
|---|---|---|
| 500 | Internal Server Error | Unhandled exception, bug in your code |
| 502 | Bad Gateway | Upstream server returned invalid response |
| 503 | Service Unavailable | Server is overloaded or in maintenance |
| 504 | Gateway Timeout | Upstream server timed out |
The distinction between 401 and 403 confuses people: 401 means "I don't know who you are", 403 means "I know who you are, and you can't do this."
Flask: The Minimal Python Web Framework
Flask is a micro web framework for Python. "Micro" doesn't mean limited — it means Flask gives you the essentials and stays out of your way. It doesn't dictate how you structure your project, which database to use, or how to handle authentication. That's your call.
Flask's core job: map incoming HTTP requests to Python functions and turn their return values into HTTP responses.
Install Flask
pip install flask
Your First API Endpoint
from flask import Flask, jsonify, request
app = Flask(__name__)
@app.route('/hello', methods=['GET'])
def hello():
name = request.args.get('name', 'World')
return jsonify({"message": f"Hello, {name}!"})
if __name__ == '__main__':
app.run(debug=True)
Run it:
python app.py
Test it:
curl "http://127.0.0.1:5000/hello?name=Venki"
# {"message": "Hello, Venki!"}
What just happened? @app.route('/hello', methods=['GET']) is a decorator. It registers the function hello as the handler for HTTP GET requests to the path /hello. When a request comes in, Flask's router matches the path, calls the function, and wraps the return value into an HTTP response.
Flask: The Full CRUD Pattern
Let's build a complete in-memory user management API. This is the foundation of every REST API you'll ever write.
from flask import Flask, jsonify, request, abort
app = Flask(__name__)
# In-memory "database" — a simple dict
users = {}
next_id = 1
# GET /users — list all users
@app.route('/users', methods=['GET'])
def list_users():
return jsonify(list(users.values())), 200
# GET /users/<id> — get a single user
@app.route('/users/<int:user_id>', methods=['GET'])
def get_user(user_id):
user = users.get(user_id)
if user is None:
abort(404)
return jsonify(user), 200
# POST /users — create a new user
@app.route('/users', methods=['POST'])
def create_user():
global next_id
data = request.get_json()
if not data or 'name' not in data or 'email' not in data:
return jsonify({"error": "name and email are required"}), 400
user = {
"id": next_id,
"name": data['name'],
"email": data['email']
}
users[next_id] = user
next_id += 1
return jsonify(user), 201
# PUT /users/<id> — replace a user completely
@app.route('/users/<int:user_id>', methods=['PUT'])
def update_user(user_id):
if user_id not in users:
abort(404)
data = request.get_json()
if not data or 'name' not in data or 'email' not in data:
return jsonify({"error": "name and email are required"}), 400
users[user_id] = {"id": user_id, "name": data['name'], "email": data['email']}
return jsonify(users[user_id]), 200
# PATCH /users/<id> — update specific fields
@app.route('/users/<int:user_id>', methods=['PATCH'])
def patch_user(user_id):
if user_id not in users:
abort(404)
data = request.get_json()
user = users[user_id]
# Only update fields that were provided
user['name'] = data.get('name', user['name'])
user['email'] = data.get('email', user['email'])
return jsonify(user), 200
# DELETE /users/<id> — remove a user
@app.route('/users/<int:user_id>', methods=['DELETE'])
def delete_user(user_id):
if user_id not in users:
abort(404)
del users[user_id]
return '', 204
if __name__ == '__main__':
app.run(debug=True)
Test with curl:
# Create
curl -X POST http://127.0.0.1:5000/users \
-H "Content-Type: application/json" \
-d '{"name": "Venki", "email": "v@example.com"}'
# Read
curl http://127.0.0.1:5000/users/1
# Update
curl -X PATCH http://127.0.0.1:5000/users/1 \
-H "Content-Type: application/json" \
-d '{"name": "Venkatesh"}'
# Delete
curl -X DELETE http://127.0.0.1:5000/users/1
Flask: Request Object Deep Dive
request is Flask's global object representing the current HTTP request. It's thread-safe via context locals — each request gets its own isolated instance.
from flask import request
@app.route('/demo', methods=['POST'])
def demo():
# Query string: /demo?page=2&limit=10
page = request.args.get('page', 1, type=int)
limit = request.args.get('limit', 10, type=int)
# JSON body
body = request.get_json() # Returns dict or None
body = request.get_json(silent=True) # Returns None instead of raising on bad JSON
# Form data (application/x-www-form-urlencoded)
username = request.form.get('username')
# Raw body bytes
raw = request.data
# Headers
auth_header = request.headers.get('Authorization')
content_type = request.content_type
# HTTP method
method = request.method # 'GET', 'POST', etc.
# Full URL info
full_url = request.url # http://127.0.0.1:5000/demo?page=2
base_url = request.base_url # http://127.0.0.1:5000/demo
host = request.host # 127.0.0.1:5000
path = request.path # /demo
# Client IP
client_ip = request.remote_addr
return jsonify({"page": page, "method": method})
Flask: Error Handling Done Right
Flask lets you register global error handlers. Use them to return consistent error responses instead of letting exceptions bubble up as ugly 500 HTML pages.
from flask import Flask, jsonify
from werkzeug.exceptions import HTTPException
app = Flask(__name__)
@app.errorhandler(400)
def bad_request(e):
return jsonify({"error": "Bad Request", "message": str(e.description)}), 400
@app.errorhandler(404)
def not_found(e):
return jsonify({"error": "Not Found", "message": str(e.description)}), 404
@app.errorhandler(405)
def method_not_allowed(e):
return jsonify({"error": "Method Not Allowed"}), 405
@app.errorhandler(500)
def internal_error(e):
return jsonify({"error": "Internal Server Error"}), 500
# Catch-all for any other HTTP exception
@app.errorhandler(HTTPException)
def handle_http_exception(e):
return jsonify({
"error": e.name,
"message": e.description,
"status_code": e.code
}), e.code
Now your API always returns JSON errors, never HTML. Your clients will thank you.
Flask Blueprints: Structuring a Real Application
When your API grows beyond a handful of routes, a single app.py becomes unmanageable. Flask's Blueprint system lets you split routes into separate modules.
project/
├── app.py
├── routes/
│ ├── __init__.py
│ ├── users.py
│ └── products.py
routes/users.py:
from flask import Blueprint, jsonify
users_bp = Blueprint('users', __name__, url_prefix='/users')
@users_bp.route('/', methods=['GET'])
def list_users():
return jsonify({"users": []})
@users_bp.route('/<int:user_id>', methods=['GET'])
def get_user(user_id):
return jsonify({"id": user_id})
app.py:
from flask import Flask
from routes.users import users_bp
from routes.products import products_bp
app = Flask(__name__)
app.register_blueprint(users_bp)
app.register_blueprint(products_bp)
if __name__ == '__main__':
app.run(debug=True)
Blueprints let you keep each resource's routes, logic, and error handling together. This is how you scale a Flask project without descending into chaos.
Middleware: Code That Runs Around Every Request
Sometimes you need logic that applies to every request — logging, authentication checks, adding response headers. Flask handles this with before_request and after_request hooks.
import time
import uuid
from flask import Flask, jsonify, request, g
app = Flask(__name__)
@app.before_request
def start_timer():
# g is Flask's per-request global storage
g.start_time = time.time()
g.request_id = str(uuid.uuid4())
@app.after_request
def log_request(response):
duration_ms = (time.time() - g.start_time) * 1000
print(
f"[{g.request_id}] {request.method} {request.path} "
f"→ {response.status_code} ({duration_ms:.1f}ms)"
)
# Add standard headers to every response
response.headers['X-Request-ID'] = g.request_id
response.headers['X-Response-Time'] = f"{duration_ms:.1f}ms"
return response
This pattern is how real APIs implement request tracing, logging, CORS headers, and rate limiting in a centralized way.
Authentication: Protecting Your Endpoints
Any API exposed to the internet needs authentication. Here's a simple API key implementation:
from flask import Flask, jsonify, request
from functools import wraps
app = Flask(__name__)
# In production, store these in a database, not a dict
VALID_API_KEYS = {
"key_abc123": "user_1",
"key_xyz789": "user_2",
}
def require_api_key(f):
@wraps(f)
def decorated(*args, **kwargs):
api_key = request.headers.get('X-API-Key')
if not api_key or api_key not in VALID_API_KEYS:
return jsonify({"error": "Unauthorized"}), 401
# Attach user info to the request context
request.current_user = VALID_API_KEYS[api_key]
return f(*args, **kwargs)
return decorated
@app.route('/protected', methods=['GET'])
@require_api_key
def protected_route():
return jsonify({
"message": f"Hello, {request.current_user}!",
"data": "This is private data."
})
Test it:
# No key — 401
curl http://127.0.0.1:5000/protected
# Wrong key — 401
curl -H "X-API-Key: wrong" http://127.0.0.1:5000/protected
# Valid key — 200
curl -H "X-API-Key: key_abc123" http://127.0.0.1:5000/protected
Rate Limiting: Protecting Your API From Abuse
Without rate limiting, a single misbehaving client can overwhelm your server. The flask-limiter library handles this cleanly.
pip install flask-limiter
from flask import Flask, jsonify
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
app = Flask(__name__)
limiter = Limiter(
app=app,
key_func=get_remote_address, # Rate limit per client IP
default_limits=["100 per hour", "10 per minute"]
)
@app.route('/public', methods=['GET'])
def public_route():
return jsonify({"data": "Anyone can call this, up to the default limits"})
@app.route('/expensive', methods=['GET'])
@limiter.limit("5 per minute") # Stricter limit for resource-heavy endpoints
def expensive_route():
# Imagine this calls an LLM or does heavy computation
return jsonify({"result": "expensive computation"})
When a client exceeds the limit, they receive a 429 status code automatically.
Consuming APIs with Python's requests Library
Building APIs is only half the picture. Here's how to consume them properly.
Basic Usage
import requests
response = requests.get(
'https://api.github.com/users/torvalds',
headers={'Accept': 'application/vnd.github.v3+json'}
)
print(response.status_code) # 200
print(response.headers) # Response headers dict
data = response.json() # Parsed JSON body
print(data['public_repos']) # Number of public repos
Handling Errors Properly
import requests
from requests.exceptions import RequestException, Timeout, ConnectionError
def fetch_user(user_id: int) -> dict | None:
try:
response = requests.get(
f'https://api.example.com/users/{user_id}',
headers={'Authorization': 'Bearer your_token'},
timeout=5 # Always set a timeout
)
response.raise_for_status() # Raises HTTPError for 4xx and 5xx
return response.json()
except Timeout:
print("Request timed out")
return None
except ConnectionError:
print("Could not connect to the server")
return None
except requests.HTTPError as e:
print(f"HTTP error: {e.response.status_code} — {e.response.text}")
return None
except RequestException as e:
print(f"Request failed: {e}")
return None
Using a Session for Repeated Calls
Every requests.get() call opens and closes a new TCP connection. If you're making many calls to the same API, use a Session to reuse the connection:
import requests
session = requests.Session()
session.headers.update({
'Authorization': 'Bearer your_token',
'Accept': 'application/json'
})
# These reuse the same underlying TCP connection (keep-alive)
r1 = session.get('https://api.example.com/users/1')
r2 = session.get('https://api.example.com/users/2')
r3 = session.get('https://api.example.com/products/5')
session.close()
This can reduce latency significantly when making dozens of API calls.
Hosting Your API Locally — And What Actually Happens
Flask's built-in dev server (app.run()) is not production-grade. It handles one request at a time. It's purely for development. Here's what it does internally when you call it:
-
Creates a TCP socket —
socket.socket(socket.AF_INET, socket.SOCK_STREAM) -
Binds to address —
socket.bind(('0.0.0.0', 5000))-
0.0.0.0means "all network interfaces" — your localhost AND your LAN IP -
127.0.0.1would mean "localhost only"
-
-
Starts listening —
socket.listen(n)— OS queues incoming connections -
Accept loop — for each connection:
conn, addr = socket.accept() - Read the HTTP request from the socket
- Parse it — method, path, headers, body
- Route it — Flask matches path to your function
- Run your function — generates the response
- Write the response back to the socket
- Handle keep-alive or close the connection
Flask's dev server does all this synchronously, single-threaded. For production, you replace it with Gunicorn (for synchronous apps) or Uvicorn (for async apps), which run multiple workers.
Running on Your Local Network
app.run(host='0.0.0.0', port=5000, debug=True)
Find your machine's local IP:
# Linux / macOS
ip addr show | grep 'inet ' | grep -v 127.0.0.1
# Windows
ipconfig | findstr IPv4
Let's say it's 192.168.1.100. Any device on your Wi-Fi can now hit:
http://192.168.1.100:5000/users
Exposing to the Internet with ngrok
pip install ngrok
ngrok http 5000
ngrok tunnels traffic from a public URL (https://abc123.ngrok.io) to your local port 5000. Useful for demos, webhooks, and testing.
The Gap Between Development and Production
| Concern | Dev (Flask dev server) | Production |
|---|---|---|
| Concurrency | Single-threaded | Gunicorn/Uvicorn workers |
| HTTPS | No | Nginx/Caddy as reverse proxy |
| Process management | Manual | systemd / Docker / Kubernetes |
| Environment variables | Hardcoded |
.env + secrets manager |
| Logging | print() | Structured logging (JSON) to ELK/CloudWatch |
| Error tracking | Terminal output | Sentry |
| Database | In-memory dict | PostgreSQL / MySQL with connection pooling |
| Static files | Flask serves them | CDN / Nginx |
A minimal production Flask deployment:
[Client] → [Nginx (HTTPS termination)] → [Gunicorn (WSGI server)] → [Flask App]
→ [PostgreSQL]
# Install gunicorn
pip install gunicorn
# Run 4 worker processes
gunicorn -w 4 -b 0.0.0.0:8000 app:app
Common API Design Mistakes (And How to Avoid Them)
1. Using verbs in URLs
# Wrong
GET /getUser?id=5
POST /createUser
DELETE /deleteUser?id=5
# Right
GET /users/5
POST /users
DELETE /users/5
2. Not versioning your API
If you ever need to make a breaking change, you'll thank yourself for having /v1/ in your URLs from day one. Clients can stay on v1 while you build v2.
GET /v1/users/5
GET /v2/users/5
3. Ignoring status codes
Don't return 200 OK with an error message in the body. Use the appropriate 4xx or 5xx code.
4. No pagination on list endpoints
# Bad — returns everything
@app.route('/users', methods=['GET'])
def list_users():
return jsonify(all_10_million_users)
# Good — paginated
@app.route('/users', methods=['GET'])
def list_users():
page = request.args.get('page', 1, type=int)
per_page = min(request.args.get('per_page', 20, type=int), 100)
offset = (page - 1) * per_page
paginated = list(users.values())[offset:offset + per_page]
return jsonify({
"data": paginated,
"page": page,
"per_page": per_page,
"total": len(users)
})
5. Not validating input
Never trust client input. Always validate before processing:
from flask import request, jsonify
@app.route('/users', methods=['POST'])
def create_user():
data = request.get_json(silent=True)
if data is None:
return jsonify({"error": "Request body must be valid JSON"}), 400
errors = {}
if not data.get('name') or not isinstance(data['name'], str):
errors['name'] = "Required, must be a string"
if not data.get('email') or '@' not in str(data.get('email', '')):
errors['email'] = "Required, must be a valid email"
if errors:
return jsonify({"error": "Validation failed", "details": errors}), 422
# Safe to proceed
...
6. Exposing internal details in error messages
# Bad — exposes your database schema and internal paths
{"error": "psycopg2.errors.UniqueViolation: duplicate key value violates unique constraint \"users_email_key\" DETAIL: Key (email)=(v@example.com) already exists."}
# Good
{"error": "Conflict", "message": "An account with this email already exists"}
A Complete, Production-Ready Flask API Template
Here's a starting point that incorporates everything above:
import time
import uuid
import logging
from flask import Flask, jsonify, request, g
from werkzeug.exceptions import HTTPException
# Configure structured logging
logging.basicConfig(
level=logging.INFO,
format='{"time": "%(asctime)s", "level": "%(levelname)s", "message": "%(message)s"}'
)
logger = logging.getLogger(__name__)
app = Flask(__name__)
# ─── Request lifecycle hooks ───────────────────────────────────────────────────
@app.before_request
def before():
g.start_time = time.time()
g.request_id = str(uuid.uuid4())
@app.after_request
def after(response):
duration_ms = round((time.time() - g.start_time) * 1000, 2)
logger.info(
f"method={request.method} path={request.path} "
f"status={response.status_code} duration_ms={duration_ms} "
f"request_id={g.request_id}"
)
response.headers['X-Request-ID'] = g.request_id
return response
# ─── Global error handlers ─────────────────────────────────────────────────────
@app.errorhandler(HTTPException)
def handle_http_exception(e):
return jsonify({
"error": e.name,
"message": e.description,
"request_id": g.get('request_id')
}), e.code
@app.errorhandler(Exception)
def handle_unexpected_error(e):
logger.exception("Unhandled exception")
return jsonify({
"error": "Internal Server Error",
"request_id": g.get('request_id')
}), 500
# ─── Routes ────────────────────────────────────────────────────────────────────
@app.route('/health', methods=['GET'])
def health():
return jsonify({"status": "ok", "uptime_ms": round(time.time() * 1000)}), 200
# Register blueprints here
# app.register_blueprint(users_bp)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=False)
Where to Go From Here
You now understand APIs at the level most developers never reach. The next steps:
Deepen your Flask knowledge: Add SQLAlchemy for a real database, Flask-Migrate for schema migrations, and marshmallow or Pydantic for request/response serialization.
Learn FastAPI: If you want async support, automatic OpenAPI docs, and Pydantic validation built in, FastAPI is the modern Python alternative. It builds on the same ASGI/WSGI concepts.
Understand authentication properly: Study OAuth 2.0 and JWT (JSON Web Tokens). These are how real-world authentication flows work.
Build something: The only way to really learn API design is to build one, use it, find its rough edges, and fix them. Start with a small project you actually care about.
Read the HTTP spec: RFC 7230–7235. Not the whole thing, but skim it. Knowing what the protocol actually specifies versus what frameworks paper over gives you a durable mental model.
If you found this useful, follow for more deep-dives into Python, backend systems, and software engineering fundamentals.
Top comments (0)