Log in
Progress0 / 30 pages0%
2.
Full Stack Engineering · Sub-chapter 2 · 9 min read

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

  1. HTTP as the contract - methods, status codes, headers, idempotency, caching
  2. REST API design - resources, versioning, pagination, consistent error shapes
  3. When not REST - GraphQL and RPC, and the cost of each
  4. Request lifecycle - middleware, the path from socket to handler to response
  5. Never trust the client - input validation with zod at the boundary
  6. AuthN vs AuthZ - sessions vs JWT, OAuth, where tokens actually go
  7. Config & secrets - twelve-factor, env vars, statelessness, horizontal scaling
  8. Talking to the database - pooling, N+1, transactions (lightly - see Databases)
  9. Background jobs & queues - work that must not happen in the request
  10. 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:

text
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:

ts
// 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 cap limit.
  • One error shape, everywhere - pick it once and never deviate:
json
{ "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 - .env is 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:

ts
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/notes Route Handler with zod validation that returns 201 + Location on success and a structured 400 on bad input.
  • Add a GET with cursor pagination (?limit&cursor) and a capped limit; prove offset breaks under a concurrent insert.
  • Protect a route with auth: issue a short-lived token in an HttpOnly cookie, return 401 when missing and 403 when 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 withRetry against a flaky endpoint and add an Idempotency-Key so a retried POST does the work only once.
  • Add a token-bucket rate limiter and confirm the 11th request in a window returns 429 with Retry-After.
  • Move an email-send out of the request path into a queued background job; return 202 and watch the worker process it.

Further reading

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.

Knowledge check

Pass 80% to unlock
0/3 answered
1. Which HTTP method is NOT guaranteed to be idempotent?
2. A logged-in user requests another tenant's invoice and the server rejects it. Which status code is correct?
3. Why must you validate request input on the server even when the frontend already validates it?