GoodLookRetail BlogEmber
Technology

REST API Internals

TypeScript · Node.js · Engineering Deep Dive

A
Ankit Kothari
2 October 2026 · 13 min read
0 Views0 Comments13 Min Read
REST API Internals

REST API Internals: A TypeScript & Node.js Field Guide

Caching, compression, OAuth 2.0, circuit breakers, and rate limiting — with production-ready TypeScript and Express code.

Packages used throughout this article:
npm install express compression helmet express-rate-limit opossum ioredis jsonwebtoken
npm install -D typescript @types/express @types/node @types/compression @types/jsonwebtoken ts-node

Table of Contents

  1. HTTP Caching with ETags

  2. Compression: Gzip vs Brotli

  3. Content Negotiation & REST Verbs

  4. OAuth 2.0 with JWT Validation

  5. Circuit Breaker with Opossum

  6. Rate Limiting Algorithms

00 — Setup

Project Structure

All examples assume a standard Express + TypeScript layout. Here's the base server config everything builds on:

TypeScriptsrc/server.ts — base setup

import express, { Application, Request, Response, NextFunction } from 'express';

import helmet from 'helmet';

const app: Application = express();

app.use(helmet()); // security headers out of the box

app.use(express.json()); // parse JSON bodies

app.use(express.urlencoded({ extended: true }));

// Global error handler — always last middleware

app.use((err: Error, req: Request, res: Response, _next: NextFunction) => {

console.error(err.stack);

res.status(500).json({ error: 'Internal server error' });

});

app.listen(3000, () => console.log('Server running on :3000'));

JSONtsconfig.json

{

"compilerOptions": {

"target": "ES2022",

"module": "commonjs",

"lib": ["ES2022"],

"outDir": "./dist",

"rootDir": "./src",

"strict": true,

"esModuleInterop": true,

"skipLibCheck": true

},

"include": ["src/**/*"]

}

01 — Performance

HTTP Caching with ETags

Caching is the highest-leverage performance tool in any REST API. When implemented correctly with validators, unchanged resources return a 304 Not Modified with no body — saving bandwidth and reducing database load entirely.

Cache-Control Directives — Decision Table

Directive

What it means

Use when

max-age=N

Browser may cache for N seconds

Standard public GET endpoints

s-maxage=N

Shared caches (CDN) use this over max-age

Override CDN TTL independently of browser

no-cache

Cache it, but revalidate with server first

Data that changes often but ETag is cheap

no-store

Never cache under any circumstance

Auth tokens, personal data, payment info

private

Browser only — CDNs must not cache

User-specific responses

immutable

Resource will never change at this URL

Versioned static assets (/app.v3.js)

⚠️

no-cache ≠ "don't cache." It means "cache it, but always revalidate." Use no-store if you genuinely never want it cached. This is the most common REST caching mistake.

ETag Middleware in TypeScript

Express has built-in weak ETag generation (app.set('etag', 'weak')), but for real control — especially when you want to base ETags on entity version fields from your database — write your own middleware:

TypeScriptsrc/middleware/cacheControl.ts

import { Request, Response, NextFunction } from 'express';

import crypto from 'crypto';

interface CacheOptions {

maxAge?: number; // seconds

sMaxAge?: number; // CDN override

isPrivate?: boolean;

noStore?: boolean;

}

/**

* Generates a strong ETag from any serializable entity.

* Uses SHA-1 of JSON — fast, deterministic, no version field needed.

*/

export function generateETag(entity: unknown): string {

const hash = crypto

.createHash('sha1')

.update(JSON.stringify(entity))

.digest('hex')

.slice(0, 16);

return "${hash}";

}

/** Attach Cache-Control + ETag to the response and short-circuit on 304 */

export function withCache(options: CacheOptions = {}) {

return (_req: Request, res: Response, next: NextFunction) => {

if (options.noStore) {

res.setHeader('Cache-Control', 'no-store');

return next();

}

const parts: string[] = [];

if (options.isPrivate) parts.push('private');

else parts.push('public');

if (options.maxAge !== undefined) parts.push(max-age=${options.maxAge});

if (options.sMaxAge !== undefined) parts.push(s-maxage=${options.sMaxAge});

res.setHeader('Cache-Control', parts.join(', '));

next();

};

}

TypeScriptsrc/routes/employees.ts — ETag + 304 flow

import { Router, Request, Response } from 'express';

import { generateETag, withCache } from '../middleware/cacheControl';

import { findEmployeeById } from '../services/employeeService';

const router = Router();

router.get(

'/:id',

withCache({ maxAge: 300, sMaxAge: 600 }), // 5min browser, 10min CDN

async (req: Request, res: Response) => {

const employee = await findEmployeeById(Number(req.params.id));

if (!employee) {

return res.status(404).json({ error: 'Not found' });

}

const etag = generateETag(employee);

res.setHeader('ETag', etag);

res.setHeader('Last-Modified', new Date(employee.updatedAt).toUTCString());

// If client sent If-None-Match and it matches → 304, no body

if (req.headers['if-none-match'] === etag) {

return res.status(304).end();

}

// Check If-Modified-Since fallback

const ifModSince = req.headers['if-modified-since'];

if (ifModSince) {

const clientDate = new Date(ifModSince).getTime();

const resourceDate = new Date(employee.updatedAt).getTime();

if (clientDate >= resourceDate) return res.status(304).end();

}

res.status(200).json(employee);

}

);

export default router;

💡

Express's built-in app.set('etag', 'strong') auto-generates ETags from response bodies, but it requires the full response to be buffered first. The manual approach above short-circuits before any DB work if If-None-Match can be validated at the service layer using a version column.

02 — Bandwidth

Compression: Gzip vs Brotli

The Node compression package handles gzip and deflate transparently. For Brotli, you have two options: handle it at the Nginx/CDN layer (recommended for production), or use Node's built-in zlib.createBrotliCompress() for specific endpoints.

Algorithm

JS savings

HTML savings

CPU cost

Node support

gzip

~70%

~70%

Low

Built-in zlib

br (Brotli)

~84% (+14%)

~91% (+21%)

Medium (level 4–6 for APIs)

Node 10.16+ zlib

deflate

~gzip

~gzip

Low

Avoid — client impl bugs

Gzip with the compression Middleware

TypeScriptsrc/middleware/compression.ts

import compression from 'compression';

import { Request, Response } from 'express';

// Apply gzip compression to responses > 1KB

export const gzipMiddleware = compression({

level: 6, // 0-9, 6 is the sweet spot (speed vs ratio)

threshold: 1024, // don't compress responses smaller than 1KB

filter: (req: Request, res: Response): boolean => {

// Never compress already-compressed formats

const type = res.getHeader('Content-Type') as string ?? '';

if (type.includes('image/') || type.includes('video/')) return false;

return compression.filter(req, res);

}

});

Brotli for a Specific Endpoint (Node built-in)

TypeScriptsrc/routes/export.ts — Brotli stream

import { Router, Request, Response } from 'express';

import zlib from 'zlib';

import { Readable } from 'stream';

const router = Router();

router.get('/data/export', async (req: Request, res: Response) => {

const acceptEncoding = req.headers['accept-encoding'] ?? '';

const data = await getLargeDataset(); // returns JSON string

const stream = Readable.from(data);

if (acceptEncoding.includes('br')) {

res.setHeader('Content-Encoding', 'br');

res.setHeader('Content-Type', 'application/json');

const brotli = zlib.createBrotliCompress({

params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } // 4 = fast for APIs

});

stream.pipe(brotli).pipe(res);

} else if (acceptEncoding.includes('gzip')) {

res.setHeader('Content-Encoding', 'gzip');

res.setHeader('Content-Type', 'application/json');

stream.pipe(zlib.createGzip()).pipe(res);

} else {

res.setHeader('Content-Type', 'application/json');

stream.pipe(res);

}

});

export default router;

🚫

Never compress encrypted or already-compressed payloads (JWTs, encrypted tokens, images, zip files). Compressing before encryption leaks information through size correlation — this is the CRIME/BREACH attack family.

03 — Interoperability

Content Negotiation & REST Verbs

Accept vs Content-Type — The Confusion Ends Here

Header

Direction

Describes

Server error code if unmet

Accept

Request only

What formats the client can receive

406 Not Acceptable

Content-Type

Both directions

The format of the body being sent right now

415 Unsupported Media Type

Content Negotiation Middleware in TypeScript

TypeScriptsrc/middleware/negotiation.ts

import { Request, Response, NextFunction } from 'express';

const SUPPORTED_TYPES = ['application/json', 'application/xml'];

/** Reject requests with unsupported Content-Type (for POST/PUT/PATCH) */

export function requireJson(req: Request, res: Response, next: NextFunction) {

if (['POST', 'PUT', 'PATCH'].includes(req.method)) {

if (!req.is('application/json')) {

return res.status(415).json({

error: 'Unsupported Media Type',

supported: ['application/json']

});

}

}

next();

}

/** Respond in format the client requested via Accept header */

export function negotiate(<T>) {

return (res: Response, data: T): void => {

const accept = res.req.accepts(SUPPORTED_TYPES);

if (!accept) {

res.status(406).json({ error: 'Not Acceptable', supported: SUPPORTED_TYPES });

return;

}

if (accept === 'application/xml') {

res.type('xml').send(toXml(data)); // your XML serializer

} else {

res.json(data);

}

};

}

HTTP Verb Semantics — PUT vs PATCH

Method

Semantics

Idempotent?

Body contains

POST

Create new resource

No

Full new entity

PUT

Replace entire resource

Yes

Complete entity — missing fields → null

PATCH

Partial update

Not guaranteed

Only fields that change

DELETE

Remove resource

Yes

Usually empty

TypeScriptsrc/routes/employees.ts — PUT vs PATCH

interface Employee {

id: number;

name: string;

department: string;

salary: number;

location: string;

}

// PUT — caller must send the FULL employee or fields become undefined

router.put('/:id', async (req: Request, res: Response) => {

const employee: Employee = req.body; // TypeScript enforces all fields

if (!employee.name || !employee.department || !employee.salary) {

return res.status(400).json({ error: 'PUT requires all fields' });

}

const updated = await employeeService.replace(req.params.id, employee);

res.json(updated);

});

// PATCH — only send what changes; use Partial<T> for type safety

router.patch('/:id', async (req: Request, res: Response) => {

const delta: Partial<Employee> = req.body;

if (Object.keys(delta).length === 0) {

return res.status(400).json({ error: 'PATCH body must not be empty' });

}

const updated = await employeeService.merge(req.params.id, delta);

res.json(updated); // only salary changed → name/dept/location untouched

});

04 — Security

OAuth 2.0 with JWT Validation

🔑 Which Grant Type?

Machine-to-machine, no user?

Client Credentials — pass client_id + client_secret, get token

Server-side app (can store secrets)?

Authorization Code — most secure; secret never touches browser

SPA or mobile (can't store secrets)?

Authorization Code + PKCE — replaces client secret with code verifier

Implicit grant?

Deprecated in OAuth 2.1 — token in URL = exposed in logs, history, Referer

Authorization Code Flow

User clicks "Login with Google" App ──► GET https://accounts.google.com/oauth/authorize ?client_id=YOUR_ID &response_type=code &redirect_uri=https://yourapp.com/callback &scope=profile email &state=RANDOM_CSRF_TOKEN ← prevents CSRF — validate on return Google shows consent screen → User approves Google ──► REDIRECT https://yourapp.com/callback ?code=AUTH_CODE&state=RANDOM_CSRF_TOKEN App validates state, then server-to-server only: ──► POST https://oauth2.googleapis.com/token body: grant_type=authorization_code code=AUTH_CODE client_secret=YOUR_SECRET ← never exposed to browser Google ──► { access_token, refresh_token, expires_in }

JWT Validation Middleware

TypeScriptsrc/middleware/auth.ts

import { Request, Response, NextFunction } from 'express';

import jwt, { JwtPayload } from 'jsonwebtoken';

// Extend Express Request to carry the decoded token

declare global {

namespace Express {

interface Request {

user?: JwtPayload;

}

}

}

const JWT_SECRET = process.env.JWT_SECRET!; // RS256 public key in prod

export function authenticate(req: Request, res: Response, next: NextFunction) {

const authHeader = req.headers['authorization'];

if (!authHeader?.startsWith('Bearer ')) {

return res.status(401).json({ error: 'Missing or malformed Authorization header' });

}

const token = authHeader.slice(7);

try {

const payload = jwt.verify(token, JWT_SECRET) as JwtPayload;

req.user = payload;

next();

} catch (err) {

if (err instanceof jwt.TokenExpiredError) {

return res.status(401).json({ error: 'Token expired' });

}

return res.status(401).json({ error: 'Invalid token' });

}

}

/** Role-based authorization — use after authenticate() */

export function requireRole(...roles: string[]) {

return (req: Request, res: Response, next: NextFunction) => {

const userRole = req.user?.role as string;

if (!roles.includes(userRole)) {

// 403 = authenticated but not authorized (not 401)

return res.status(403).json({ error: 'Forbidden' });

}

next();

};

}

// Usage:

// router.delete('/:id', authenticate, requireRole('admin'), deleteEmployee);

🚫

Never put tokens in URL parameters. They appear in server logs, browser history, and Referer headers. Always use Authorization: Bearer <token>. Also: use RS256 (asymmetric) over HS256 in production — your resource server can verify tokens with the public key without ever seeing the signing secret.

Refresh Token Flow

TypeScriptsrc/routes/auth.ts — token refresh

router.post('/auth/refresh', async (req: Request, res: Response) => {

const { refreshToken } = req.body;

if (!refreshToken) {

return res.status(400).json({ error: 'refreshToken required' });

}

try {

// Verify the refresh token (use a separate secret)

const payload = jwt.verify(refreshToken, process.env.REFRESH_SECRET!) as JwtPayload;

// Check it hasn't been revoked (Redis blocklist pattern)

const isRevoked = await tokenStore.isRevoked(payload.jti!);

if (isRevoked) return res.status(401).json({ error: 'Refresh token revoked' });

// Issue new access token (short-lived: 15 min)

const accessToken = jwt.sign(

{ sub: payload.sub, role: payload.role },

process.env.JWT_SECRET!,

{ expiresIn: '15m' }

);

res.json({ accessToken });

} catch {

res.status(401).json({ error: 'Invalid refresh token' });

}

});

05 — Resilience

Circuit Breaker with Opossum

In Node.js, the standard circuit breaker library is Opossum (the Netflix OSS equivalent of Hystrix for the JS ecosystem). It prevents cascading failures when a downstream service is slow or unavailable.

The Problem It Solves

Without circuit breaker — slow downstream cascades: Node server has 100 concurrent requests All 100 await paymentService.charge() ← hangs for 30s each Event loop is blocked by pending Promises → All other API routes: timeout / 503 With Opossum — circuit opens after threshold: First 5 failures within 10s → circuit OPENS Subsequent calls → fallback() fires immediately (no await) After 30s half-open → 1 probe request sent If probe succeeds → circuit CLOSES, normal flow resumes

TypeScriptsrc/services/circuitBreaker.ts

import CircuitBreaker from 'opossum';

interface PaymentRequest { amount: number; userId: string; }

interface PaymentResult { transactionId: string; status: string; }

/** The function we want to protect */

async function callPaymentService(req: PaymentRequest): Promise<PaymentResult> {

const response = await fetch('https://payments.internal/charge', {

method: 'POST',

body: JSON.stringify(req),

signal: AbortSignal.timeout(3000) // 3s hard timeout per call

});

if (!response.ok) throw new Error(Payment service: ${response.status});

return response.json();

}

const options = {

timeout: 3000, // call must complete in 3s or it's a failure

errorThresholdPercentage: 50,// open circuit if 50% of calls fail

resetTimeout: 30000, // try again after 30s (half-open)

volumeThreshold: 5, // need at least 5 calls before tripping

};

export const paymentBreaker = new CircuitBreaker(callPaymentService, options);

// Fallback: what to return when circuit is open

paymentBreaker.fallback((_req: PaymentRequest) => ({

transactionId: 'DEFERRED',

status: 'queued' // queue for retry later rather than hard fail

}));

// Observability hooks

paymentBreaker.on('open', () => metrics.increment('circuit.payment.open'));

paymentBreaker.on('halfOpen', () => metrics.increment('circuit.payment.halfOpen'));

paymentBreaker.on('close', () => metrics.increment('circuit.payment.close'));

TypeScriptsrc/routes/payments.ts — using the breaker

router.post('/payments', authenticate, async (req: Request, res: Response) => {

try {

// paymentBreaker.fire() wraps callPaymentService with circuit logic

const result = await paymentBreaker.fire(req.body);

const statusCode = result.status === 'queued' ? 202 : 201;

res.status(statusCode).json(result);

} catch (err) {

// Circuit is open AND no fallback — this shouldn't happen with fallback set

res.status(503).json({

error: 'Service temporarily unavailable',

retryAfter: 30

});

}

});

💡

Use AbortSignal.timeout(ms) (Node 17.3+) on every fetch call inside your protected function. Without it, the circuit breaker's timeout option fires but the underlying HTTP request keeps running, consuming memory and file descriptors.

06 — Stability

Rate Limiting Algorithms

Algorithm

Burst handling

Memory

Edge risk

Best for

Fixed Window

Poor — 2× burst at edges

O(1)

Double burst

Internal admin APIs only

Sliding Window

Precise

O(log n)

None

Public APIs, fair-use enforcement

Token Bucket

Allows controlled bursts

O(1)

None

Search, export — APIs with bursty legit traffic

Leaky Bucket

Queues bursts, adds latency

O(queue)

Latency spikes

Smoothing traffic into rate-limited downstream

express-rate-limit — In-Process (single instance)

TypeScriptsrc/middleware/rateLimiter.ts

import rateLimit, { RateLimitRequestHandler } from 'express-rate-limit';

import { Request, Response } from 'express';

/** General API rate limit: 100 req / 15 min per IP */

export const apiLimiter: RateLimitRequestHandler = rateLimit({

windowMs: 15 60 1000, // 15 minutes

max: 100,

standardHeaders: true, // sets RateLimit-* headers (RFC 6585)

legacyHeaders: false,

keyGenerator: (req: Request): string => {

// Use authenticated user ID when available, fall back to IP

return (req.user?.sub as string) ?? req.ip ?? 'unknown';

},

handler: (_req: Request, res: Response): void => {

res.status(429).json({

error: 'rate_limit_exceeded',

message: 'Too many requests. Please slow down.',

retryAfter: res.getHeader('RateLimit-Reset')

});

}

});

/** Strict login limiter: 5 attempts / 15 min (brute-force protection) */

export const loginLimiter = rateLimit({

windowMs: 15 60 1000,

max: 5,

skipSuccessfulRequests: true, // only count failed logins

standardHeaders: true,

legacyHeaders: false

});

// Usage in routes:

// app.use('/api/', apiLimiter);

// app.post('/auth/login', loginLimiter, loginHandler);

Redis-Backed Distributed Rate Limiter (multi-instance)

In-process rate limiters break in a load-balanced environment — each instance has its own counter, so a user can hit 100 req × N instances. Use Redis to share state:

TypeScriptsrc/middleware/distributedRateLimiter.ts

import { Request, Response, NextFunction } from 'express';

import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL!);

interface RateLimitConfig {

windowSeconds: number;

maxRequests: number;

keyPrefix: string;

}

/**

* Sliding window log rate limiter using Redis sorted sets.

* Accurate across distributed instances — no double-burst edge case.

*/

export function distributedRateLimit(config: RateLimitConfig) {

return async (req: Request, res: Response, next: NextFunction) => {

const key = ${config.keyPrefix}:${req.ip};

const now = Date.now();

const windowStart = now - config.windowSeconds * 1000;

// Atomic pipeline: remove old entries, add current, count, set expiry

const pipeline = redis.pipeline();

pipeline.zremrangebyscore(key, 0, windowStart); // prune expired

pipeline.zadd(key, now, ${now}-${Math.random()}); // add this request

pipeline.zcard(key); // count in window

pipeline.expire(key, config.windowSeconds); // auto-cleanup

const results = await pipeline.exec();

const count = results?.[2]?.[1] as number;

res.setHeader('X-RateLimit-Limit', config.maxRequests);

res.setHeader('X-RateLimit-Remaining', Math.max(0, config.maxRequests - count));

if (count > config.maxRequests) {

res.setHeader('Retry-After', config.windowSeconds);

return res.status(429).json({

error: 'rate_limit_exceeded',

retryAfter: config.windowSeconds

});

}

next();

};

}

// Usage:

// router.get('/search', distributedRateLimit({ windowSeconds: 60, maxRequests: 30, keyPrefix: 'search' }), searchHandler);

⚠️

Fixed Window edge case: With a 100 req/min limit, a client can send 100 requests at 11:59:59 and 100 more at 12:00:01 — 200 requests in 2 seconds, all within their limit. This can overwhelm your downstream. Sliding window eliminates this.

Always Return Retry-After on 429

HTTP ResponseCorrect 429 response

HTTP/1.1 429 Too Many Requests

Content-Type: application/json

Retry-After: 60

X-RateLimit-Limit: 100

X-RateLimit-Remaining: 0

X-RateLimit-Reset: 1719310000

{

"error": "rate_limit_exceeded",

"message": "100 requests per 15 minutes exceeded.",

"retryAfter": 60

}

Summary

Status Codes Developers Get Wrong

Code

When to use

Common mistake

200 OK

Successful GET, PUT, PATCH

Returning 200 for a successful POST (use 201)

201 Created

Resource successfully created via POST

Forgetting to include Location header with the new resource URL

202 Accepted

Request queued, not yet processed

Never using it — returning 200 for async operations

204 No Content

Successful DELETE or PATCH with no body

Returning 200 with an empty body instead

304 Not Modified

ETag/Last-Modified matched — no body

Not implementing cache validators at all

400 Bad Request

Malformed request, validation failure

Returning 500 for user input errors

401 Unauthorized

Not authenticated (missing/invalid token)

Using 401 when the user IS authenticated but lacks permission (use 403)

403 Forbidden

Authenticated but not authorized

Returning 403 when resource doesn't exist (leaks existence — use 404)

406 Not Acceptable

Cannot satisfy Accept header

Ignoring Accept header and returning JSON anyway

415 Unsupported Media Type

Cannot process the Content-Type sent

Returning 400 for unsupported content type

429 Too Many Requests

Rate limit exceeded

Not including Retry-After header — clients hammer immediately

503 Service Unavailable

Circuit open, dependency down

Returning 500 (implies bug, not transient unavailability)

REST API Internals: TypeScript & Node.js Field Guide · June 2026 · Express · Node 20+ · TypeScript 5

Promote this post

Share & amplify

LinkedIn post generator

Generate a polished, professional LinkedIn post from this article. Edit before posting.

Comments (0)

Sort:
Sign in to join the discussion.