REST API Internals
TypeScript · Node.js · Engineering Deep Dive

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 jsonwebtokennpm install -D typescript @types/express @types/node @types/compression @types/jsonwebtoken ts-node
Table of Contents
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 |
|---|---|---|
| Browser may cache for N seconds | Standard public GET endpoints |
| Shared caches (CDN) use this over max-age | Override CDN TTL independently of browser |
| Cache it, but revalidate with server first | Data that changes often but ETag is cheap |
| Never cache under any circumstance | Auth tokens, personal data, payment info |
| Browser only — CDNs must not cache | User-specific responses |
| Resource will never change at this URL | Versioned static assets ( |
⚠️
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 |
|---|---|---|---|---|
| ~70% | ~70% | Low | Built-in zlib |
| ~84% (+14%) | ~91% (+21%) | Medium (level 4–6 for APIs) | Node 10.16+ zlib |
| ~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 |
|---|---|---|---|
| Request only | What formats the client can receive |
|
| Both directions | The format of the body being sent right now |
|
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 |
|---|---|---|---|
| Create new resource | No | Full new entity |
| Replace entire resource | Yes | Complete entity — missing fields → null |
| Partial update | Not guaranteed | Only fields that change |
| 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 |
|---|---|---|
| Successful GET, PUT, PATCH | Returning 200 for a successful POST (use 201) |
| Resource successfully created via POST | Forgetting to include |
| Request queued, not yet processed | Never using it — returning 200 for async operations |
| Successful DELETE or PATCH with no body | Returning 200 with an empty body instead |
| ETag/Last-Modified matched — no body | Not implementing cache validators at all |
| Malformed request, validation failure | Returning 500 for user input errors |
| Not authenticated (missing/invalid token) | Using 401 when the user IS authenticated but lacks permission (use 403) |
| Authenticated but not authorized | Returning 403 when resource doesn't exist (leaks existence — use 404) |
| Cannot satisfy | Ignoring Accept header and returning JSON anyway |
| Cannot process the | Returning 400 for unsupported content type |
| Rate limit exceeded | Not including |
| 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)