Module P-7 — Connecting External Services and Caching
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 and Caching with ioredis
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).
bash
Redis client singleton
typescript
The Cache-Aside Pattern
The most common caching strategy: check the cache first, hit the database only on a miss, then populate the cache for next time.
typescript
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.
Cache Stampede: When a Miss Multiplies Itself
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):
typescript
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:
typescript
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.
Cache invalidation
The cache must be invalidated when data changes — stale cache is worse than no cache:
typescript
Caching list queries
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.
typescript
Redis for refresh token storage
Using Redis instead of a Postgres table for refresh tokens — faster lookups, automatic expiry:
typescript
Sending Email with Nodemailer
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.
bash
Email transport singleton
typescript
Sending emails
typescript
Don't block the request on email
Email sending should never make the response wait:
typescript
For reliable email delivery in high-volume apps, put emails on a job queue (covered in P-12, BullMQ) instead of sending inline.
File Uploads to Object Storage (S3/R2)
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).
bash
Multer for parsing multipart uploads
typescript
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:
bash
typescript
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.
S3 client singleton
typescript
Upload service
typescript
Upload route
typescript
Pre-signed URLs for client-side uploads
For large files, upload directly from the client to S3 — skip your API server entirely:
typescript
The client receives uploadUrl, PUTs the file directly to S3, then sends just the key to your API to record the uploaded file.
Outbound HTTP with fetch
Node.js 18+ ships a global fetch — no extra libraries needed for most use cases.
typescript
Wrapper with timeout and error handling
typescript
Retries and a Circuit Breaker for Flaky Upstreams
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):
typescript
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:
bash
typescript
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.
Idempotency Keys for Client-Retried Mutations
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:
text
typescript
typescript
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.
Idempotent Webhook Processing
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.
typescript
The webhook route needs raw body access — register it before express.json():
typescript
Summary
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.
exportasyncfunctiongetPostById(id:number){const cacheKey =`post:${id}`;const cached =await redis.get(cacheKey);if(cached)returnJSON.parse(cached);const lockKey =`lock:${cacheKey}`;const gotLock =await redis.set(lockKey,'1','EX',10,'NX');if(!gotLock){// Someone else is already populating this key — wait briefly and retry onceawaitnewPromise((r)=>setTimeout(r,100));const retried =await redis.get(cacheKey);if(retried)returnJSON.parse(retried);// Still nothing — fall through and query anyway rather than fail the request}const post =await postsRepo.findById(id);if(!post)thrownewNotFoundError('Post');await redis.setex(cacheKey,CACHE_TTL,JSON.stringify(post));if(gotLock)await redis.del(lockKey);return post;}
exportasyncfunctionupdatePost(id:number, data: UpdatePostInput, userId:number){const post =await postsRepo.findById(id);if(!post)thrownewNotFoundError('Post');if(post.authorId !== userId)thrownewForbiddenError();const updated =await postsRepo.update(id, data);// Invalidate after successful writeawait redis.del(`post:${id}`);return updated;}exportasyncfunctiondeletePost(id:number, userId:number){const post =await postsRepo.findById(id);if(!post)thrownewNotFoundError('Post');if(post.authorId !== userId)thrownewForbiddenError();await postsRepo.delete(id);await redis.del(`post:${id}`);}
exportasyncfunctionlistPublishedPosts(page:number, limit:number){const cacheKey =`posts:published:${page}:${limit}`;const cached =await redis.get(cacheKey);if(cached)returnJSON.parse(cached);const posts =await postsRepo.findPublished({ page, limit });// Short TTL for lists — 60 seconds is fine for most feedsawait redis.setex(cacheKey,60,JSON.stringify(posts));return posts;}
// src/repositories/tokens.repository.tsimport redis from'../db/redis.js';constTOKEN_TTL=7*24*60*60;// 7 days in secondsexportasyncfunctioncreate(userId:number, token:string):Promise<void>{// Store with user ID for revoke-all-sessions capabilityawait redis.setex(`refresh:${token}`,TOKEN_TTL,String(userId));}exportasyncfunctionfindByToken(token:string):Promise<number|null>{const userId =await redis.get(`refresh:${token}`);return userId ?parseInt(userId):null;}exportasyncfunctiondeleteByToken(token:string):Promise<void>{await redis.del(`refresh:${token}`);}// Revoke all sessions for a user (requires a different data structure)// See the Set-based approach in the Architect phase for multi-device logout
// src/services/email.service.tsimport 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 sendingconst devTransport = nodemailer.createTransport({ jsonTransport:true,// logs to console});const transport = env.NODE_ENV==='production'? transporter : devTransport;
interfaceOrderConfirmationData{ to:string; orderNumber:string; items:Array<{ name:string; quantity:number; price:number}>; total:number;}exportasyncfunctionsendOrderConfirmation(data: OrderConfirmationData):Promise<void>{const itemsList = data.items
.map(item =>`${item.name} × ${item.quantity} — $${item.price.toFixed(2)}`).join('\n');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>
`,});}exportasyncfunctionsendPasswordReset(to:string, resetToken:string):Promise<void>{const resetUrl =`${env.APP_URL}/reset-password?token=${resetToken}`;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>`,});}
// src/services/orders.service.tsexportasyncfunctioncreateOrder(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;}
// src/middleware/upload.tsimport multer from'multer';exportconst 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(newError('Only JPEG, PNG, and WebP images are allowed'));}},});
npminstall file-type
import{ fileTypeFromBuffer }from'file-type';exportasyncfunctionvalidateFileType(file: Express.Multer.File):Promise<void>{const detected =awaitfileTypeFromBuffer(file.buffer);const allowed =['image/jpeg','image/png','image/webp'];if(!detected ||!allowed.includes(detected.mime)){thrownewValidationError('File content does not match an allowed image type');}}
// src/db/s3.tsimport{ S3Client }from'@aws-sdk/client-s3';import{ env }from'../config/env.js';exportconst s3 =newS3Client({ region: env.AWS_REGION, credentials:{ accessKeyId: env.AWS_ACCESS_KEY_ID, secretAccessKey: env.AWS_SECRET_ACCESS_KEY,},// For Cloudflare R2 or other S3-compatible services:// endpoint: env.S3_ENDPOINT,// forcePathStyle: true,});
// src/services/storage.service.tsimport{ PutObjectCommand, DeleteObjectCommand }from'@aws-sdk/client-s3';import{ s3 }from'../db/s3.js';import{ env }from'../config/env.js';import{ randomUUID }from'crypto';import path from'path';exportasyncfunctionuploadFile( file: Express.Multer.File, folder ='uploads',):Promise<string>{const ext = path.extname(file.originalname).toLowerCase();const key =`${folder}/${randomUUID()}${ext}`;await s3.send(newPutObjectCommand({ Bucket: env.S3_BUCKET, Key: key, Body: file.buffer, ContentType: file.mimetype, ContentLength: file.size,// ACL: 'public-read', // only if bucket is public}));// Return the public URL (for public buckets)return`https://${env.S3_BUCKET}.s3.${env.AWS_REGION}.amazonaws.com/${key}`;}exportasyncfunctiondeleteFile(url:string):Promise<void>{// Extract key from URLconst key =newURL(url).pathname.slice(1);// remove leading /await s3.send(newDeleteObjectCommand({ Bucket: env.S3_BUCKET, Key: key }));}
// src/routes/users.routes.tsrouter.patch('/:id/avatar', authenticate, upload.single('avatar'),// multer middleware — expects field named "avatar"validate(idParamSchema,'params'), usersController.updateAvatar,);// src/controllers/users.controller.tsexportconst updateAvatar =asyncHandler(async(req, res)=>{if(!req.file)thrownewValidationError('Avatar file is required');const avatarUrl =await storageService.uploadFile(req.file,'avatars');const user =await usersService.updateAvatar(req.params.id, avatarUrl, req.user!.id); res.json(user);});
import CircuitBreaker from'opossum';const breaker =newCircuitBreaker((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(()=>{thrownewAppError('Payment service temporarily unavailable',503);});exportasyncfunctiongetCharge(orderId:number){return breaker.fire(orderId);}
POST /payments
Idempotency-Key: 8f14e45f-ceea-467e-9575-b8c1a3a2f4b0
{ "orderId": 42, "amount": 5000 }
// src/middleware/idempotency.tsimport{ Request, Response, NextFunction }from'express';import redis from'../db/redis.js';exportasyncfunctionidempotency(req: Request, res: Response, next: NextFunction){const key = req.headers['idempotency-key']asstring|undefined;if(!key)returnnext();// endpoint can choose to require it insteadconst cacheKey =`idempotency:${req.path}:${key}`;const cached =await redis.get(cacheKey);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 sentconst originalJson = res.json.bind(res); res.json=(body:unknown)=>{ redis.setex(cacheKey,24*60*60,JSON.stringify({ status: res.statusCode, body }));returnoriginalJson(body);};next();}
// Apply to mutating endpoints where a duplicate execution is dangerousrouter.post('/payments', idempotency, paymentsController.create);
// src/controllers/webhooks.controller.tsimport crypto from'crypto';import{ asyncHandler }from'../utils/asyncHandler.js';import redis from'../db/redis.js';exportconst stripeWebhook =asyncHandler(async(req, res)=>{// 1. Verify signature (Stripe-specific — each provider has its own approach)const signature = req.headers['stripe-signature']asstring;const payload = req.body;// must be raw buffer — don't use express.json() on this route// Stripe-Signature header looks like: "t=1614556800,v1=5257a869e7ec..."const sigParts = Object.fromEntries( signature.split(',').map((part)=> part.split('=',2)as[string,string]),);const timestamp = sigParts.t;const providedSig = sigParts.v1;if(!timestamp ||!providedSig){return res.status(401).json({ error:'Invalid signature'});}// 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 aloneconst 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 eventswitch(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});});
// 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 elseapp.use(express.json({ limit:'10kb'}));