Backend
Backend Engineering
Sub-chapter 2 of Full Stack Engineering · The server half: same input, same result, under load
The frontend is what users see; the backend is what they trust. Every form they submit, every login they expect to hold, every number they assume is correct - that's a promise your server makes. The backend is a promise: same input, same result, every time, under load, even when the client lies to you. And the client always lies eventually - a bored attacker, a broken retry, a stale mobile app from 2023 that never updates.
At Welzin a backend is judged on three things: it is correct, it is observable, and it does not fall over when traffic triples. Clever architecture that fails any of those three is not clever. This sub-chapter is server concerns only - the React/UI half lives in sub-chapter 1, and deep database internals live in the Databases chapter. Here we own the bytes between the wire and the data layer.
Outline
- HTTP as the contract - methods, status codes, headers, idempotency, caching
- REST API design - resources, versioning, pagination, consistent error shapes
- When not REST - GraphQL and RPC, and the cost of each
- Request lifecycle - middleware, the path from socket to handler to response
- Never trust the client - input validation with zod at the boundary
- AuthN vs AuthZ - sessions vs JWT, OAuth, where tokens actually go
- Config & secrets - twelve-factor, env vars, statelessness, horizontal scaling
- Talking to the database - pooling, N+1, transactions (lightly - see Databases)
- Background jobs & queues - work that must not happen in the request
- Resilience & observability - timeouts, retries, idempotency keys, structured logs, rate limiting
101 Primer
HTTP is the contract, learn it cold
HTTP methods carry meaning the whole web depends on:
- GET - read, no side effects. Safe and idempotent. Cacheable.
- POST - create / non-idempotent action. Calling twice creates two things.
- PUT - replace a resource at a known URL. Idempotent - same call twice = same state.
- PATCH - partial update. Not guaranteed idempotent unless you design it so.
- DELETE - remove. Idempotent - deleting twice still ends deleted.
Idempotent means safe to retry: the same request applied N times leaves the same state as applying it once. This is not academic - every retrying client and load balancer in the world assumes GET/PUT/DELETE are safe to repeat. Build a POST endpoint that's secretly idempotent (see idempotency keys below) and you can retry it too.
Status codes are how you talk to machines. Use them honestly:
200 OK success, body returned
201 Created POST made a resource (return Location header)
204 No Content success, nothing to return (often DELETE)
400 Bad Request the client sent garbage (validation failed)
401 Unauthorized you are not authenticated - who are you?
403 Forbidden you are authenticated but not allowed - I know who you are, no.
404 Not Found resource doesn't exist (or you shouldn't know it does)
409 Conflict version clash, duplicate, state conflict
422 Unprocessable well-formed but semantically invalid
429 Too Many Reqs rate limited (send Retry-After)
500 Internal Error you broke. never leak the stack trace.
503 Unavailable dependency down / overloaded
Headers worth knowing: Content-Type, Authorization, Cache-Control, ETag / If-None-Match (conditional GET → 304), Idempotency-Key, Retry-After, and the CORS family (Access-Control-Allow-*). Caching is a header conversation: Cache-Control: no-store for anything user-specific, public, max-age=... only for genuinely shared data.
A Route Handler is just a function from Request to Response
Our stack is Next.js 15 App Router. A Route Handler under app/api/ is the canonical server entry point:
// app/api/invoices/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const CreateInvoice = z.object({
customerId: z.string().uuid(),
amountCents: z.number().int().positive(),
currency: z.enum(["USD", "INR", "EUR"]),
});
export async function POST(req: NextRequest) {
const json = await req.json().catch(() => null);
const parsed = CreateInvoice.safeParse(json);
if (!parsed.success) {
return NextResponse.json(
{ error: { code: "VALIDATION", issues: parsed.error.flatten() } },
{ status: 400 },
);
}
const invoice = await createInvoice(parsed.data); // your domain logic
return NextResponse.json(invoice, {
status: 201,
headers: { Location: `/api/invoices/${invoice.id}` },
});
}
Same idea in Node/Express or FastAPI - different syntax, identical responsibilities: parse, validate, authorize, do work, shape a response, handle errors. Don't let framework sugar hide those five steps.
REST design: resources, not verbs
URLs name nouns; the method is the verb. POST /api/invoices, not POST /api/createInvoice. Nest only one level for ownership (/api/customers/:id/invoices); past that, query params. Conventions we enforce:
- Versioning - prefix the path:
/api/v1/.... Breaking the response shape means/v2, never silently mutating/v1. - Pagination - cursor-based for anything that grows:
?limit=50&cursor=.... Offset pagination breaks under concurrent inserts. Always caplimit. - One error shape, everywhere - pick it once and never deviate:
{ "error": { "code": "INVOICE_NOT_FOUND", "message": "No invoice with that id", "requestId": "req_8f2a" } }
A stable code is for machines; message is for humans; requestId ties the client's screenshot to your logs.
When not REST
- GraphQL when many clients need different slices of a complex graph and you're tired of bespoke endpoints - at the cost of caching complexity, N+1 traps, and harder rate limiting.
- RPC / tRPC / gRPC when caller and callee are both yours and you want typed function calls, not HTTP semantics - internal service-to-service, or a TS frontend calling a TS backend with end-to-end types.
Default to REST. Reach for the others when REST is actually hurting, not because it's trendy.
Never trust the client
The single most-broken backend rule. The browser's required attribute, the disabled button, the TypeScript types on the frontend - none of that runs on an attacker's machine. Validate every input at the server boundary with a schema (zod above). The frontend validation is UX; the backend validation is security. If only one can exist, it's the backend one.
This is also your SQL-injection defence: never string-concatenate user input into a query. Use parameterised queries / an ORM. (Deeper in the Databases chapter.)
AuthN vs AuthZ - different questions
- Authentication (AuthN) - who are you? Failure → 401.
- Authorization (AuthZ) - are you allowed to do this? Failure → 403.
Two mechanisms you'll meet:
- Sessions - server stores a session, client holds an opaque session ID in a cookie. Easy to revoke (delete the session). Needs shared session storage (Redis) once you scale past one instance.
- JWT - a signed token the client carries; the server verifies the signature without a lookup. Stateless and scales trivially - but you cannot un-issue one before it expires, so keep access tokens short-lived (minutes) and pair with a refresh token you can revoke.
OAuth ("Login with Google") is delegated auth: you redirect to the provider, they authenticate the user, you get back a code, you exchange it server-side for tokens. The user's Google password never touches you.
Where tokens live matters: prefer an HttpOnly, Secure, SameSite cookie so JavaScript - and thus XSS - can't read it. localStorage is convenient and exactly what an XSS payload exfiltrates first.
Config, secrets, and statelessness - the twelve-factor spine
Read The Twelve-Factor App once and live it. The load-bearing factors here:
- Config in the environment -
process.env.DATABASE_URL, never a committed config file. Same image runs in dev/staging/prod; only env vars differ. - Secrets never in git -
.envis gitignored; real secrets live in the platform's secret manager. - Stateless processes - no in-memory session, no "uploads sit on this box's disk." State goes to the database, cache, or object store. A stateless process is one you can run twelve copies of behind a load balancer and kill any one without anyone noticing - that is horizontal scaling.
Talking to the database (lightly)
The server owns the DB connection, and the two ways to wreck it:
- No pooling - opening a connection per request exhausts the database. Use a connection pool (and in serverless, a pooler like PgBouncer / Prisma Accelerate, because each invocation is a fresh process).
- N+1 queries - one query for a list, then one more per row in a loop. 1 + N round-trips. Fix with a join or a batched
WHERE id IN (...). This is the most common real-world latency bug.
Wrap multi-step writes that must all-or-nothing in a transaction. Depth lives in the Databases chapter - here, just never leak a pool and never loop queries.
Work that doesn't belong in the request
If a request triggers something slow or flaky - sending email, generating a PDF, calling a third-party API - don't make the user wait, and don't let their connection's death lose the work. Enqueue a job (BullMQ/Redis, SQS, a jobs table) and return 202 Accepted. A separate worker processes it, with retries. The HTTP request stays fast; the work survives a crash.
Resilience: timeouts, retries, idempotency keys
Every outbound call gets a timeout - a call with no timeout is a hang waiting to happen. Retries use exponential backoff with jitter, not a tight loop that hammers a struggling dependency:
async function withRetry<T>(fn: () => Promise<T>, tries = 3): Promise<T> {
for (let i = 0; ; i++) {
try {
return await fn();
} catch (err) {
if (i >= tries - 1) throw err;
const backoff = 2 ** i * 100 + Math.random() * 100; // jitter
await new Promise((r) => setTimeout(r, backoff));
}
}
}
Only retry idempotent operations. To make a POST safely retryable, accept an Idempotency-Key header: store the key with its result; a repeat key returns the stored result instead of charging the card twice. This is exactly how Stripe survives flaky networks.
Observability: if you can't see it, it's broken
- Structured logs - JSON, not
console.log("here ok"). One line per request: method, path, status, latency,userId, and a request ID generated at the edge and threaded through every log and downstream call. When a user reports a bug, you grep one ID and see the whole story. - Metrics - request rate, error rate, p95/p99 latency per route. Averages lie; watch the tail.
- Rate limiting - cap requests per IP/user (token bucket in Redis), return 429 with
Retry-After. Protects you from abuse and from your own runaway retry loops.
And the OWASP baseline you must not get wrong: validate all input (injection), scope CORS to known origins (never * with credentials), store passwords with bcrypt/argon2 (never plaintext, never MD5), enforce authZ on every endpoint (not just hide the button), and never return a stack trace to a client.
Hands-on Checkpoints
- Build a
POST /api/v1/notesRoute Handler with zod validation that returns201+Locationon success and a structured400on bad input. - Add a
GETwith cursor pagination (?limit&cursor) and a cappedlimit; prove offset breaks under a concurrent insert. - Protect a route with auth: issue a short-lived token in an
HttpOnlycookie, return401when missing and403when the user lacks the role. - Wire a request-ID middleware that attaches an ID to every log line; trace one request end to end by that ID.
- Implement
withRetryagainst a flaky endpoint and add anIdempotency-Keyso a retried POST does the work only once. - Add a token-bucket rate limiter and confirm the 11th request in a window returns
429withRetry-After. - Move an email-send out of the request path into a queued background job; return
202and watch the worker process it.
Further reading
- MDN - HTTP - methods, status codes, headers, caching
- Richardson Maturity Model - what "RESTful" actually means
- OWASP Top Ten - the bugs that get you breached
- Next.js Route Handlers - our server entry point
- The Twelve-Factor App - config, statelessness, and scaling
- zod - schema validation at the boundary
Welzin opinion: Frontend validation is a courtesy; backend validation is the law. If a rule isn't enforced on the server, it isn't enforced - assume every request reaching your handler was crafted by someone trying to break it, and you'll write the right code by default.