Warming up the neural circuits...
By the end of this chapter you will:
90% of backend code is CRUD. Create, Read, Update, Delete. Master this pattern once — the of your career is variations on it.
A hotel front desk does four things to a guest record:
Every reservation system ever built is some flavor of this. Even Stripe charges? Create + read + maybe refund (update). Twitter? Posts get created, read, updated, deleted. Once you can do CRUD cleanly, you can build almost anything.
For a resource notes:
| Operation | Method | URL | Status (success) |
|---|---|---|---|
| Create | POST | /notes | 201 |
| Read all | GET | /notes | 200 |
| Read one | GET | /notes/:id | 200 |
| Update (full) | PUT | /notes/:id | 200 |
| Update (partial) | PATCH | /notes/:id | 200 |
| Delete | DELETE | /notes/:id | 204 |
Memorize this table. It's the grammar of every REST API.
We'll build /notes in-memory (we'll switch to Postgres in P1):
const router = require('express').Router();
const crypto = require('crypto');
let notes = []; // in-memory store
// CREATE
router.post
Wire it in:
app.use('/notes', require('./routes/notes'));Test with curl:
# Create
curl -X POST -H "Content-Type: application/json" \
-d '{"title":"Hello"}' http://localhost:3000/notes
# List
curl http://localhost:3000/notesThis is a full CRUD resource. We'll add and persistence in the next chapters.
| Code | Meaning | When you use it |
|---|---|---|
| 200 | OK | Successful GET, PATCH, PUT |
| 201 | Created | Successful POST that created a resource |
| 202 | Accepted | Queued for processing |
| 204 | No Content | Successful DELETE (no body) |
| 400 | Bad Request | Malformed |
| 401 | Unauthorized | Missing/invalid auth |
| 403 | Forbidden | Authenticated but not allowed |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | Duplicate, version mismatch |
| 422 | Unprocessable | Validation failed |
| 429 | Too Many Requests | Rate limited |
| 500 | Internal Error | Unhandled exception |
| 502 | Bad Gateway | Upstream failed |
| 503 | Service Unavailable | Maintenance / overload |
| 504 | Gateway Timeout | Upstream timed out |
If you're unsure between 400 and 422: 400 = "I can't even parse this." 422 = "I parsed it, but it failed business rules."
PUT replaces the entire resource. If the body omits a field, the field becomes null/missing.
PATCH is a partial update. Only fields present in the body change. The rest stay.
In practice, PATCH is what you usually want. PUT semantics break easily — clients forget a field, and your record loses data.
Some specs (JSON Patch, JSON Merge Patch) formalize PATCH bodies. For most apps, "the body has the fields to change" is fine.
Often you want to create / update / delete many at once. Three options:
1. Loop POSTs on the client. Simple, slow, N requests.
2. Bulk endpoint:
POST /notes/bulk
[{ "title": "A" }, { "title": "B" }, { "title": "C" }]Returns:
{
"created": [...3 notes],
"errors": []
}3. Bulk with mixed results. When some succeed and some fail — return a 207 (Multi-Status) or a 200 with per-item status. Don't 500 the whole call because one item is bad.
Offset — ?page=3&limit=20 — easy, but slow at scale (page 1000 = OFFSET 20000, full scan).
Cursor pagination — ?cursor=abc123&limit=20 — fast at any scale. Cursor is an opaque identifier (usually the last item's ID or sortKey).
For your first 10,000 records, offset is fine. After that, switch to cursor.
{
"data": [...],
"nextCursor": "abc123",
"hasMore": true
}The client uses nextCursor for the next page. When it's null, you've reached the end.
Query strings are the standard for all three.
GET /notes?status=published&author=42&sort=-createdAt&fields=id,title,bodystatus=published&author=42 — filterssort=-createdAt — sort by createdAt, descending (- prefix = desc)fields=id,title,body — return only these fields (projection)Tip: never blindly translate query params into . Whitelist field names; treat values as parameterized.
Three styles:
| Style | Example | Pros | Cons |
|---|---|---|---|
| URL | /api/v1/users | Visible, easy to route | Breaks "URLs are timeless" |
| Header | Accept: application/vnd.app.v1+json | Pure REST | Painful to test |
| Query | /api/users?v=1 | Easy | Pollutes other params |
In practice URL versioning wins. It's the easiest to operate. Start with /api/v1 on day one even if you'll never need v2.
When you do release v2:
v1 running for 6–12 months.Sunset header to v1 responses warning of removal date.A safe-to-retry operation is : running it twice has the same effect as once.
If a POST is critical (charge a card), make it idempotent with an idempotency key:
POST /payments
Idempotency-Key: 9c5...
{ "amount": 1000, "currency": "INR" }Server stores the key + result. Second call with same key returns the cached result. Stripe does this — copy the pattern.
| Mistake | Why it's wrong | What to do |
|---|---|---|
Returning 200 + {error} | Tooling thinks success | Return 4xx/5xx |
POST /createUser | Verb in URL — RPC, not REST | POST /users |
| Offset pagination at scale | Slow on big tables | Cursor pagination |
| No versioning | Day 1: free. Year 2: blocker. | /api/v1 from the start |
| Same endpoint returns 200 sometimes and 201 sometimes | Inconsistent contract | Pick one based on operation |
Production. Always return the created resource (or its ID + Location header) on POST. Frontends need it immediately.
Performance. GET endpoints should be cacheable. Set Cache-Control thoughtfully. POST/PATCH/DELETE never .
Security. Authorize per record. A logged-in user with GET /notes/42 should only see notes they own (or were shared).
category field to notes. Filter by it: GET /notes?category=work.createdAt as the cursor.deletedAt, GET filters them out. Add an ?includeDeleted=true for admins.Beginner. What's the right status code for "I created the thing"? 201, ideally with a Location header.
Senior. How would you version an API in a backwards-compatible way? URL versioning, dual-running v1 + v2, Sunset header, deprecation notices, monitoring of v1 traffic before removal.
CRUD is the spine of every API. Map operations to HTTP methods correctly, return the right status, paginate with cursors, version from day one, support idempotency where it matters. The pattern is small; doing it cleanly is what separates a junior API from a senior one.
Location header for POST./api/v1 from day one.