What this module covers: Production APIs don't live in isolation. They send email, upload files to object storage, call third-party APIs, and cache expensive queries to keep response times fast. This module covers Redis with ioredis and the cache-aside pattern, sending transactional email with Nodemailer, uploading files to S3-compatible object storage, making outbound HTTP requests with the native fetch API, and processing webhooks idempotently. Each section gives you production-ready patterns, not just the happy path.
Redis is an in-memory data store. The two primary use cases in a Node.js API are caching (store the result of an expensive query, serve it for subsequent requests) and session/token storage (store refresh tokens so they can be revoked).
The most common caching strategy: check the cache first, hit the database only on a miss, then populate the cache for next time.
// src/services/posts.service.ts import redis from '../db/redis.js'; import * as postsRepo from '../repositories/posts.repository.js'; import { NotFoundError } from '../errors/AppError.js';
const CACHE_TTL = 300; // 5 minutes in seconds
export async function getPostById(id: number) { const cacheKey = `post:${id}`;
// 2. Cache miss — query the database const post = await postsRepo.findById(id); if (!post) throw new NotFoundError('Post');
// 3. Populate cache with TTL await redis.setex(cacheKey, CACHE_TTL, JSON.stringify(post));
return post; }
A cold cache under a traffic spike is like one ATM with a thousand people reading the same "temporarily out of service" sign at once and independently deciding to walk to the same bank branch — every one of those thousand requests treats the miss as their own personal errand.
The getPostById function above has a race: if post:42 expires (or was never cached) and a thousand requests for it land within the same few milliseconds, every single one sees a cache miss and every single one queries the database — for the same row, at the same time. A popular post going viral is exactly when this hurts most: the cache is supposed to protect the database from load, and the moment it's most needed is the moment it stops helping.
Three mitigations, cheapest first:
Jittered TTLs — don't let a batch of keys expire at the exact same instant (common when they were all populated by the same batch job or warm-up script):
Locking — the first request to see a miss acquires a short-lived lock and populates the cache; everyone else waits briefly and retries the cache read instead of all hitting the database at once:
export async function getPostById(id: number) { const cacheKey = `post:${id}`; const cached = await redis.get(cacheKey); if (cached) return JSON.parse(cached);
if (!gotLock) { // Someone else is already populating this key — wait briefly and retry once await new Promise((r) => setTimeout(r, 100)); const retried = await redis.get(cacheKey); if (retried) return JSON.parse(retried); // Still nothing — fall through and query anyway rather than fail the request }
const post = await postsRepo.findById(id); if (!post) throw new NotFoundError('Post');
await redis.setex(cacheKey, CACHE_TTL, JSON.stringify(post)); if (gotLock) await redis.del(lockKey);
return post; }
Probabilistic early refresh — recompute the value slightly before it actually expires, with a probability that rises as the remaining TTL shrinks, so a background refresh usually wins the race instead of the whole crowd hitting an actual expiry at once. Best suited to expensive-to-recompute values (a report, an aggregation) rather than a single-row lookup.
Pick locking for hot single keys under a thundering herd, jittered TTLs as a cheap default for anything cached in batches, and probabilistic early refresh for expensive-to-recompute values where a few extra seconds of staleness beats a cold recompute.
The cache must be invalidated when data changes — stale cache is worse than no cache:
export async function updatePost(id: number, data: UpdatePostInput, userId: number) { const post = await postsRepo.findById(id); if (!post) throw new NotFoundError('Post'); if (post.authorId !== userId) throw new ForbiddenError();
const updated = await postsRepo.update(id, data);
// Invalidate after successful write await redis.del(`post:${id}`);
return updated; }
export async function deletePost(id: number, userId: number) { const post = await postsRepo.findById(id); if (!post) throw new NotFoundError('Post'); if (post.authorId !== userId) throw new ForbiddenError();
Lists are harder — when a post is created, which lists are stale? The simplest approach: use short TTLs for lists and longer TTLs for individual records.
Using Redis instead of a Postgres table for refresh tokens — faster lookups, automatic expiry:
// src/repositories/tokens.repository.ts import redis from '../db/redis.js';
const TOKEN_TTL = 7 * 24 * 60 * 60; // 7 days in seconds
export async function create(userId: number, token: string): Promise<void> { // Store with user ID for revoke-all-sessions capability await redis.setex(`refresh:${token}`, TOKEN_TTL, String(userId)); }
Nodemailer is the standard Node.js library for sending email. In production you connect it to a transactional email provider (SendGrid, Resend, Postmark, SES) — never your own SMTP server.
// src/services/email.service.ts import nodemailer from 'nodemailer'; import { env } from '../config/env.js';
// Transactional email via SMTP (works with SendGrid, Resend, Mailgun, SES) const transporter = nodemailer.createTransport({ host: env.SMTP_HOST, port: env.SMTP_PORT, secure: env.SMTP_PORT === 465, // true for port 465, false for 587 auth: { user: env.SMTP_USER, pass: env.SMTP_PASS, }, });
// For development — log emails to console instead of sending const devTransport = nodemailer.createTransport({ jsonTransport: true, // logs to console });
const transport = env.NODE_ENV === 'production' ? transporter : devTransport;
await transport.sendMail({ from: `"My Shop" <noreply@myshop.com>`, to: data.to, subject: `Order #${data.orderNumber} confirmed`, text: ` Your order has been confirmed.
Items: ${itemsList}
Total: $${data.total.toFixed(2)}
Thank you for your order! `.trim(), html: ` <h2>Order #${data.orderNumber} Confirmed</h2> <table> ${data.items.map(item => ` <tr> <td>${item.name}</td> <td>× ${item.quantity}</td> <td>$${item.price.toFixed(2)}</td> </tr> `).join('')} </table> <p><strong>Total: $${data.total.toFixed(2)}</strong></p> `, }); }
await transport.sendMail({ from: `"My Shop" <noreply@myshop.com>`, to, subject: 'Password reset request', text: `Click the link to reset your password: ${resetUrl}\n\nThis link expires in 1 hour.`, html: `<p>Click <a href="${resetUrl}">here</a> to reset your password.</p><p>This link expires in 1 hour.</p>`, }); }
Email sending should never make the response wait:
// src/services/orders.service.ts export async function createOrder(input) { const order = await ordersRepo.create(input);
// Fire-and-forget — the response goes out immediately // If the email fails, we log it and move on. The order was placed. sendOrderConfirmation({ to: user.email, orderNumber: order.id.toString(), items: order.items, total: order.total, }).catch(err => { console.error(`[Email] Failed to send order confirmation for order ${order.id}:`, err.message); });
return order; }
For reliable email delivery in high-volume apps, put emails on a job queue (covered in P-12, BullMQ) instead of sending inline.
Never store uploaded files on the API server's disk. Servers are ephemeral in cloud deployments, and local disk doesn't scale horizontally. Store files in object storage: AWS S3, Cloudflare R2, DigitalOcean Spaces (all S3-compatible).
// src/middleware/upload.ts import multer from 'multer';
export const upload = multer({ storage: multer.memoryStorage(), // hold in memory, we'll stream to S3 limits: { fileSize: 5 * 1024 * 1024, // 5 MB max files: 1, }, fileFilter: (req, file, cb) => { const allowed = ['image/jpeg', 'image/png', 'image/webp']; if (allowed.includes(file.mimetype)) { cb(null, true); } else { cb(new Error('Only JPEG, PNG, and WebP images are allowed')); } }, });
file.mimetype here is whatever Content-Type the client declared for that multipart form part — it is not inspected file content. It costs an attacker nothing to rename payload.php to photo.jpg and set the part's Content-Type to image/jpeg; the fileFilter above stops accidental wrong-type uploads, not a deliberate one.
Real hardening checks the file's magic bytes — the actual binary signature at the start of the file — rather than trusting the declared type:
if (!detected || !allowed.includes(detected.mime)) { throw new ValidationError('File content does not match an allowed image type'); } }
Call this after multer parses the upload and before the buffer reaches storageService.uploadFile — fileFilter is a cheap first pass, file-type is the check that actually matters.
The timeout wrapper above handles one failure mode: a call that hangs. It does nothing for a call that fails fast and repeatedly — a third-party API having a bad five minutes. Retrying blindly makes that worse: every failed request from every one of your instances immediately retries, multiplying load on an upstream that's already struggling.
Retry with exponential backoff and jitter — for transient failures (5xx, network errors), not for 4xx (the request itself is wrong; retrying won't fix it):
Circuit breaker — once an upstream has failed enough times in a row, stop calling it for a cooldown period instead of letting every request pay the timeout cost while it recovers:
npm install opossum
import CircuitBreaker from 'opossum';
const breaker = new CircuitBreaker( (orderId: number) => httpFetch(`${env.PAYMENT_SERVICE_URL}/charges/${orderId}`), { timeout: 10_000, // matches the fetch wrapper's own timeout errorThresholdPercentage: 50, // trip after 50% of requests fail... resetTimeout: 30_000, // ...stay open for 30s before testing again }, );
breaker.fallback(() => { throw new AppError('Payment service temporarily unavailable', 503); });
export async function getCharge(orderId: number) { return breaker.fire(orderId); }
While the breaker is open, breaker.fire() rejects immediately via the fallback — no network call, no waiting on a timeout — until resetTimeout elapses and it lets a single test request through to check whether the upstream has recovered.
The webhook idempotency check further down solves one direction of this problem: a third-party service retries the same event at you, and you deduplicate by their event ID. There's a second direction this module hasn't covered yet — your own clients retry the same mutation against you, and without a way to recognize "this is the same request, not a new one," the second attempt executes as if it were independent.
This is the same problem Stripe and every payment API solve with a client-supplied Idempotency-Key header. The client generates a unique key (a UUID) once per logical operation and sends it on every attempt, including retries:
POST /payments Idempotency-Key: 8f14e45f-ceea-467e-9575-b8c1a3a2f4b0 { "orderId": 42, "amount": 5000 }
// src/middleware/idempotency.ts import { Request, Response, NextFunction } from 'express'; import redis from '../db/redis.js';
export async function idempotency(req: Request, res: Response, next: NextFunction) { const key = req.headers['idempotency-key'] as string | undefined; if (!key) return next(); // endpoint can choose to require it instead
if (cached) { if (cached === 'processing') { // A request with this key is already in flight — this is the concurrent // case a "cache the response after it's done" check alone would miss. // Reject rather than letting a second copy of the mutation run. return res.status(409).json({ error: 'A request with this idempotency key is already being processed' }); } const { status, body } = JSON.parse(cached); return res.status(status).json(body); // replay the original response, don't re-execute }
// Claim the key before doing any work, not after — this is what closes the // in-flight race: two requests with the same key arriving milliseconds apart // both miss `redis.get` above, but only one of them wins this NX set. const claimed = await redis.set(cacheKey, 'processing', 'EX', 30, 'NX'); if (!claimed) { return res.status(409).json({ error: 'A request with this idempotency key is already being processed' }); }
// Wrap res.json to capture and store the response the first time it's sent const originalJson = res.json.bind(res); res.json = (body: unknown) => { redis.setex(cacheKey, 24 * 60 * 60, JSON.stringify({ status: res.statusCode, body })); return originalJson(body); };
next(); }
// Apply to mutating endpoints where a duplicate execution is dangerous router.post('/payments', idempotency, paymentsController.create);
The first request with a given key executes normally and its response is cached. Every subsequent request with the same key — a genuine retry after a dropped connection, or an accidental double-click — gets the original response replayed without touching the payment logic again.
Production story: on a UPI-rail integration, a mobile client's flaky connection meant a "confirm payment" request sometimes fired twice within the same second — the first attempt's response never made it back to the phone, so the app retried. Without a client-supplied idempotency key, the retry looked like a brand new request and became a second, real settlement instruction. The reconciliation team spent a night manually reversing a duplicate transfer that an Idempotency-Key header and a Redis-backed replay cache would have caught for free.
Webhooks are HTTP requests sent by third-party services (Stripe, GitHub, Twilio) to notify your API of events. Two rules for production webhook handling:
Verify the signature — don't trust the payload without checking it came from the real sender.
Process idempotently — webhook delivery is at-least-once. The same event may arrive twice.
// src/controllers/webhooks.controller.ts import crypto from 'crypto'; import { asyncHandler } from '../utils/asyncHandler.js'; import redis from '../db/redis.js';
export const stripeWebhook = asyncHandler(async (req, res) => { // 1. Verify signature (Stripe-specific — each provider has its own approach) const signature = req.headers['stripe-signature'] as string; const payload = req.body; // must be raw buffer — don't use express.json() on this route
// Replay protection — reject events whose timestamp has drifted too far // from now (Stripe recommends a 5 minute / 300 second tolerance window) const currentTimestamp = Math.floor(Date.now() / 1000); if (Math.abs(currentTimestamp - Number(timestamp)) > 300) { return res.status(401).json({ error: 'Timestamp outside tolerance' }); }
// Stripe signs `${timestamp}.${payload}`, not the raw payload alone const expectedSig = crypto .createHmac('sha256', env.STRIPE_WEBHOOK_SECRET) .update(`${timestamp}.${payload}`) .digest('hex');
let signatureIsValid: boolean; try { // Compare as hex-decoded bytes, not raw UTF-8 bytes of the hex string signatureIsValid = crypto.timingSafeEqual( Buffer.from(providedSig, 'hex'), Buffer.from(expectedSig, 'hex'), ); } catch { // Buffer.from(..., 'hex') on a malformed/wrong-length signature makes // timingSafeEqual throw a RangeError — catch it and fail closed with a 401 // instead of letting an uncaught exception 500 the request signatureIsValid = false; }
if (!signatureIsValid) { return res.status(401).json({ error: 'Invalid signature' }); }
const event = JSON.parse(payload.toString());
// 2. Idempotency check — have we processed this event before? const idempotencyKey = `webhook:processed:${event.id}`; const alreadyProcessed = await redis.get(idempotencyKey);
if (alreadyProcessed) { return res.status(200).json({ received: true }); // acknowledge without reprocessing }
// 3. Process the event switch (event.type) { case 'payment_intent.succeeded': await ordersService.confirmPayment(event.data.object.metadata.orderId); break; case 'customer.subscription.deleted': await subscriptionsService.cancelSubscription(event.data.object.id); break; default: // Unhandled event type — that's fine, acknowledge and move on }
// 4. Mark as processed (TTL of 7 days covers retry windows) await redis.setex(idempotencyKey, 7 * 24 * 60 * 60, '1');
res.status(200).json({ received: true }); });
The webhook route needs raw body access — register it before express.json():
// src/app.ts // Raw body for webhooks — before express.json() app.post( '/webhooks/stripe', express.raw({ type: 'application/json' }), webhooksController.stripeWebhook, );
// JSON body for everything else app.use(express.json({ limit: '10kb' }));
Redis caching: cache-aside pattern — check cache, query DB on miss, populate cache, invalidate on write. Use short TTLs (60s) for lists, longer (5 min) for individual records.
Refresh token storage in Redis: setex with matching TTL — tokens auto-expire without cleanup jobs.
Email with Nodemailer: connect to a transactional provider (Resend, SendGrid, SES). Fire-and-forget with .catch() for non-critical emails; use a job queue for critical ones.
File uploads: multer parses multipart form data, stream to S3 from memory. Pre-signed URLs let large files bypass your server entirely.
Outbound HTTP: native fetch with AbortController for timeouts. Centralise in a wrapper that handles non-OK responses uniformly.
Webhook idempotency: verify signature + deduplicate with Redis before processing. Acknowledge with 200 immediately to prevent provider retries.
Next: structured logging with Pino, correlation IDs that make logs searchable, multi-stage Docker builds, PM2 for production process management, and deploying via GitHub Actions CI/CD.
Knowledge Check
In the Cache-Aside pattern, what is the correct sequence of operations when retrieving data?
Why is it recommended to use a pre-signed URL for client-side uploads instead of uploading files directly through your Node.js API server?
When handling webhooks from third-party services like Stripe or GitHub, what two critical rules must be followed?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.