Appearance
9.9.4 — Uploads, Logging & Sessions
Three subsystems every real app needs, and each one has a trap that stays hidden until the day it matters.
Uploads look like a solved problem until twenty people upload at once and the server runs out of memory. Logging looks free until it is the slowest thing in the request. And sessions work perfectly until you run a second copy of the app, at which point users start getting randomly logged out and nobody can reproduce it.
1. File uploads with Multer
express.json() cannot parse multipart/form-data — the encoding browsers use for file uploads (a MIME-boundary-delimited stream mixing fields and binary parts). Multer is the standard middleware for it, and its central decision is where the bytes land: ⚑What is Multer and how do you handle file uploads? [EQ-950]
javascript
import multer from "multer";
const upload = multer({
storage: multer.diskStorage({ // (1) DISK, not memory — see below
destination: os.tmpdir(),
filename: (req, file, cb) =>
cb(null, `${crypto.randomUUID()}${path.extname(file.originalname).toLowerCase()}`),
}), // (2) NEVER use originalname as-is
limits: { fileSize: 5 * 1024 * 1024, files: 3, fields: 10 }, // (3) hard caps
fileFilter: (req, file, cb) => { // (4) declared type gate (weak — sectionbelow)
cb(null, ["image/jpeg", "image/png", "image/webp"].includes(file.mimetype));
},
});
router.post("/avatar", authenticate, upload.single("avatar"), asyncHandler(async (req, res) => {
await scanForMalware(req.file.path); // (5) content checks AFTER landing
const real = await sniffMagicBytes(req.file.path); // (6) TRUE type from file content
if (!ALLOWED.has(real)) throw new ValidationError("unsupported image type");
const key = await storage.put(req.file.path, `avatars/${req.user.id}`);
res.status(201).json({ url: cdnUrl(key) });
}));Where the bytes land: memory or disk
Multer offers two storage engines, and the choice is not a preference.
multer.memoryStorage() holds the entire file in a Buffer (3.8.3). That is genuinely convenient when files are small and you immediately forward them somewhere else — a 200 KB avatar going straight to object storage, for instance.
Now do the arithmetic for anything bigger. Twenty people upload a 100 MB video at the same time, which is a perfectly ordinary Tuesday. That is two gigabytes of heap, all at once, in a process that probably has a memory limit well below it. The process dies. Everyone's request dies with it, including the nineteen that had nothing to do with uploads. This is 3.8.4's rule stated as a failure: never let your memory use scale with the size of someone else's input.
diskStorage streams the incoming bytes to a temporary file, so memory stays flat no matter how large the upload is. The cost is that those temporary files are now your responsibility. Delete them when the request succeeds and on every path where it fails, which in practice means a finally block, plus a scheduled sweep of the temp directory for the cases your finally never ran.
At real scale the best answer is that the file never touches your server. The client asks your API for permission, your API returns a short-lived signed URL, and the browser uploads directly to object storage. Your server spends no bandwidth, no memory, and no disk on the file itself — it only issues the signature and records that the upload happened. Chapter 11.2 builds this out for resumable uploads.
Five rules for upload security
Upload endpoints are attacked more than any other kind, because they are the one place where a stranger gets to put a file on your infrastructure.
Set limits, always. fileSize, files, and fields are not optional tuning. Without them, one request can fill your disk or tie the process up indefinitely. Set a total request size cap at your reverse proxy too ([9.9.7]), so an oversized upload is rejected before it ever reaches Node.
Never use the client's filename. file.originalname is a string chosen by whoever sent the request. It may contain ../../etc/cron.d/something, which is an attempt to write outside your upload directory. It may contain a null byte, which can truncate a filename after your extension check has passed but before the filesystem sees it. It may be four kilobytes of unicode designed to break something. Generate your own name — a UUID works — and append only an extension you have whitelisted, lowercased.
Never believe the declared type. file.mimetype comes from a header the client wrote. A file containing HTML or a script can announce itself as image/png without difficulty. After the file has landed, read its first few bytes and check them against the signatures of the formats you allow — every real file format starts with a recognisable pattern. Reject anything where the declared type and the actual bytes disagree. The fileFilter in the code above is a convenience that stops honest mistakes early; it is not a security control.
Do not serve user files from your own domain. Store them outside anything the web server exposes, ideally in object storage, and serve them through a handler that sets Content-Disposition: attachment and a safe content type. The reason is specific: if a user uploads an HTML file or an SVG containing a script, and your site serves it from your own origin, the browser runs that script as your site. It can then read your cookies and act as the logged-in user (Part 8.5). A separate domain for user content is the stronger version of this defence.
Scan before anyone else can reach it. Treat an uploaded file as quarantined until it has been checked, and only then make it visible to other users.
One piece of plumbing to finish: Multer reports its own failures as a MulterError with codes like LIMIT_FILE_SIZE, and those arrive at your error funnel. Map them to 413 or 422 with stable codes (9.9.3). Left unmapped they become 500s, which tells the client your server is broken when actually their file was too big — and puts your on-call rotation on alert for a working system.
2. Logging: Morgan, Pino, and the hot path
Two distinct jobs, routinely conflated. Morgan is HTTP access logging — one line per request (method, path, status, duration, size), the Apache/Nginx-style record. Pino/Winston are application loggers — structured, leveled, arbitrary events with context. Production wants both, wired into one stream: ⚑Morgan vs Winston vs Pino for logging. [EQ-951]
javascript
const logger = pino({ level: process.env.LOG_LEVEL ?? "info" }); // ONE pipeline (9.7.5)
app.use((req, res, next) => { // request context FIRST (9.9.2)
const traceId = req.get("x-request-id") ?? crypto.randomUUID();
res.locals.traceId = traceId;
als.run({ traceId, userId: undefined }, () => { // AsyncLocalStorage (3.8.7) —
req.log = logger.child({ traceId }); // every downstream log carries it
next();
});
});
app.use(pinoHttp({ logger, genReqId: (req, res) => res.locals.traceId })); // access logFive decisions shape whether those logs are useful at 3am.
Log objects, not sentences. Compare logger.info({ orderId, amount }, "order placed") with logger.info(\order {id} placed for {amt}`). The first produces a JSON line where orderId` is a field, so your log platform can answer "show me every event for order 9142" or "sum the amounts for the last hour". The second produces a sentence, and every question you ever ask of it becomes a regular expression. The information is identical; only one of them is queryable.
Prefer Pino when throughput matters. Pino's design is deliberately narrow: turn the object into a JSON string quickly, write it to standard output, and do anything slower — shipping to a remote service, writing files — in a separate worker thread (3.8.6). Your request never waits on logging I/O. Winston is more configurable and slower. At a couple of hundred requests a second the difference is invisible; at thousands it is the difference between logging being free and logging being your bottleneck.
Write to standard output and nothing else. Not files, not a remote API called from the request path. The app prints lines; the platform running it collects them and sends them wherever they go. This keeps the slow, failure-prone part outside your process, which matters most during an incident — the moment you most need logs is also the moment the logging service is most likely to be struggling, and an app that blocks on shipping logs turns a logging outage into an application outage.
Redact secrets structurally. Authorization headers, cookies, passwords, tokens, and anything resembling card data should be impossible to log, not merely discouraged. Pino's redact option does this at the logger, which is the right place — a rule that lives in the logger cannot be forgotten by the next person. The companion habit is never logging entire request bodies, because that is how card numbers end up in a log search that half the company can read.
Mint one trace ID at the edge and carry it everywhere. Accept an incoming x-request-id if there is one, otherwise generate it. Put it on res.locals so response code can reach it, and into AsyncLocalStorage (3.8.7) so deeply nested code can log it without having req passed down through six function signatures. Echo it in error responses (9.9.3) so users can quote it. And send it as a header on outbound calls, so when a request crosses into another service the trail continues (Part 10.10).
3. Sessions, cookies, and Passport
The choice — server sessions vs stateless tokens — belongs to Part 8.4.2 (the identity chapter owns the full comparison, JWT anatomy, and revocation). What belongs here is the Express wiring and the failure modes it hides:
javascript
app.use(session({
store: new RedisStore({ client: redis }), // (1) NOT MemoryStore in production
secret: process.env.SESSION_SECRET, // (2) rotatable: pass an array
name: "sid", // (3) not "connect.sid" — fingerprinting
resave: false, saveUninitialized: false, // (4) don't write unmodified/empty sessions
cookie: {
httpOnly: true, // (5) JS cannot read it (XSS mitigation)
secure: true, // (6) HTTPS only — needs trust proxy (9.9.2)
sameSite: "lax", // (7) CSRF mitigation (Part 5.6 / 8.5)
maxAge: 1000 * 60 * 60 * 8,
},
}));The default store is a trap
If you leave out the store option, express-session uses a built-in MemoryStore, and its own documentation says it is not for production. Three separate things are wrong with it, and only one of them is obvious.
It leaks. Sessions go into a plain object and are never removed, so a long-running process accumulates every session anyone ever created until it runs out of memory.
It dies on restart. Every deploy logs out every user.
And the one that produces the bug report nobody can reproduce: it is per-process. Run your app with the cluster module or as several containers, and each copy has its own separate session object. A user logs in, the load balancer sends that request to worker 1, and the session is created there. Their next request goes to worker 2, which has never heard of them, so they appear logged out. Their next goes back to worker 1 and they are logged in again. From the outside it looks like the app randomly forgets people, and it never happens on a developer's laptop because there is only one process there. This is 3.8.6's "no shared state between workers" rule arriving as a login bug.
The fix is a store all processes share, which usually means Redis. The alternative is sticky sessions — configuring the load balancer to always send a given user to the same worker — and it is worse: it makes deployments lose sessions, it makes scaling uneven, and it puts your session design in your load balancer's configuration where nobody will look for it.
After trust proxy, this is the most common way an Express app breaks in production and not in development.
Passport
Passport is a thin framework for authentication. Its whole shape is two hooks and a plugin system.
The plugins are called strategies — one for username and password, one for JWTs, one per OAuth provider — and each one implements a verify callback whose job is to turn whatever credentials arrived into a user, or to fail. Then serializeUser and deserializeUser decide what of that user is kept in the session and how it is turned back into a user object on the next request.
There is one rule that matters here: store the user's id and nothing else. It is tempting to keep the whole user object, since it saves a lookup. But that object is now a snapshot frozen at login time. Remove someone's admin role and they keep it until their session expires, because every subsequent request rebuilds them from stale data. Storing the id means the role is read fresh each time, and a permission change takes effect immediately.
Passport's value is that a dozen different providers all plug into the same seam, so adding one is configuration rather than a new authentication path. Its limits are worth stating too. It does authentication — who are you — and says nothing at all about authorization — what may you do. That remains your middleware (9.9.2's router-level guards). And using an OAuth strategy does not save you from understanding the flow (Part 8.4.3), because the vulnerabilities in OAuth integrations are almost always a misconfigured callback URL or a missing state parameter — mistakes the library cannot make for you or prevent. ⚑How do you implement sessions and Passport in Express? [EQ-204b]
4. The expert lens
Anywhere a payload enters, someone else's volume becomes your memory. Uploads, request bodies, and log buffers are three versions of the same exposure. An uncapped multipart upload fills your disk. express.json() without a limit buffers whatever arrives. An unbounded log buffer runs you out of memory during exactly the incident it was recording.
So the discipline is the same in all three places, and it is 9.5.4's: put a bound on everything, decide out loud what happens when the bound is hit — reject with 413, or drop and count — and never write code whose memory use is a function of input size (3.8.4). How well an Express app survives an attack is mostly just the sum of its limit settings.
The line between stateless and stateful runs straight through the cookie. A session cookie is a pointer to state stored on your side. A JWT is the state itself, carried by the client. Everything else follows from that one choice: whether any replica can serve any request, whether you can revoke access instantly, what a restart destroys, what a second container knows. Part 8.4.2 makes the choice properly.
Express's role is only to keep the choice visible. A MemoryStore left in place in a clustered app is a decision to be stateful, made by nobody, on purpose by no one — which is precisely why it fails in such a confusing way.
Watching a system must cost less than running it. Access logs written on the request path, transports that block, logging whole request bodies, writing a file per request — each of these taxes every request in order to describe it. The invariant from 9.7.31 applies word for word: asynchronous, bounded, droppable, redacted. And structured, because a log line's value is only realised later, at query time, by someone who was not there when it happened.
Next: 9.9.5 — the security middleware stack: Helmet's headers, CORS configured honestly, rate limiting, compression, and the order that makes them all work.
What the interviewer will push on
These three subsystems come up as "have you actually run this?" questions rather than as design puzzles.
"How do you handle file uploads?" They are listening for where the bytes go. Name memory versus disk and do the arithmetic out loud — twenty concurrent uploads times a hundred megabytes is a dead process. Then go one step past what was asked and mention uploading directly to object storage with a signed URL, because that is what a system at scale actually does and it shows you know the middleware answer is not the only answer.
"What can go wrong with an upload endpoint?" They want a security list, and the two that separate candidates are the filename and the declared type. The filename is attacker-chosen text that may try to escape your directory, so you generate your own. The mime type is a header the client wrote, so you check the file's actual leading bytes. The strongest addition, which few people mention: never serve user-uploaded files from your own domain, because an uploaded HTML or SVG file will run as your site and can read your users' cookies.
"Users report being randomly logged out. Where do you start?" Sessions stored in process memory with more than one process running. Explain the mechanism — each worker has its own store, so the answer depends on which one the request lands on — and say why it never shows up in development. The fix is a shared store; mention sticky sessions as the inferior alternative and say why it is inferior.
"Why store only the user's id in the session?" Because anything else is a snapshot taken at login. Revoke a permission and a session carrying the whole user object keeps the old permission until it expires. This is a small question that reveals whether someone has thought about what happens after login, which is where most authorization bugs live.
"What makes a log line useful?" Structure. A field you can query beats a sentence you have to pattern-match, and the difference only becomes visible during an incident when you need to filter by order id across four services. Follow it with the trace ID: one identifier minted at the edge, carried through the request, echoed in the error response, and forwarded to downstream services.
Volunteer this one, because nobody asks: say that logging must never block the request. A logger that writes synchronously, or ships to a remote service from the request path, converts a slow logging backend into a slow API — and that failure arrives precisely during an incident, when logging volume spikes and the logging service is already struggling. Writing to standard output and letting the platform ship the lines is the version that stays up when things are going wrong.
Recall
- Multer parses
multipart/form-data. Disk vs memory is a DoS decision:memoryStorage= whole file in a Buffer (only for small, immediately-forwarded files);diskStorage= flat memory + cleanup duty; presigned direct-to-storage skips the server entirely at scale. Security five: mandatorylimits; never trustoriginalname(traversal — generate names, whitelist extensions); never trustmimetype(verify magic bytes); store outside the web root / serve withContent-Disposition; scan before exposure. MapMulterErrorto413/422in the funnel. - Logging: Morgan = HTTP access lines; Pino/Winston = structured application logs (Pino for hot paths — worker-thread transports, 9.7.31's pipeline). Structured JSON, stdout only, allow-list redaction, and one traceId minted at the edge, carried by
res.locals+ AsyncLocalStorage, echoed in errors and propagated outbound. - Sessions:
express-session+ a shared store (Redis) —MemoryStoreleaks, dies on restart, and is per-process (round-robin replicas ⇒ random logouts, 3.8.6). Cookie flags:httpOnly,secure(needstrust proxy— 9.9.2),sameSite,maxAge, non-defaultname;resave:false,saveUninitialized:false. Passport = strategy seam +serializeUserstoring the id only; authorization is still yours. - Lens: every payload boundary is a resource-exhaustion boundary (bound + overflow policy); the stateless/stateful line runs through the cookie; diagnostics must be cheaper than what they diagnose.
Self-test: When is memoryStorage acceptable, and what breaks otherwise? Give the five upload rules with the attack each stops. Why Pino over Winston on a hot path, and why stdout? Name three failure modes of MemoryStore under cluster. Why store only the user id in the session?
Quiz Bank
FoundationalWhy can't express.json handle file uploads, and what is Multer's central decision?
Browsers encode file uploads as multipart/form-data: a stream of MIME-boundary-delimited parts mixing text fields and binary blobs — not JSON, so express.json() (which parses application/json and consumes the request stream) neither understands it nor should touch it. Multer parses that format into req.file/req.files plus req.body for the text fields. Its central decision is storage engine: memoryStorage holds each file entirely in a Buffer (3.8.3) — acceptable only for small files immediately forwarded elsewhere, and a straightforward memory-exhaustion DoS at any real size or concurrency (3.8.4's never-let-memory-scale-with-input law); diskStorage streams to a temp file so memory stays flat, at the cost of owning cleanup on every path including errors. Both are eclipsed at scale by presigned direct-to-object-storage uploads, where the client uploads straight to S3 with a short-lived signed URL and your API only issues signatures and records outcomes — zero bandwidth, zero memory, zero temp files (Chapter 11.2).
FoundationalList the upload-security rules and name the attack each one prevents.
(1) Set limits (fileSize, files, fields) — without them one request can exhaust disk or memory (DoS); also cap total body size at the reverse proxy so the attack dies before Node. (2) Never use file.originalname as a path — it's attacker-controlled: ../../etc/cron.d/x is path traversal, null bytes truncate extension checks, and long unicode names break filesystems; generate a UUID name and append only a whitelisted, lowercased extension.
(3) Never trust file.mimetype — it's a client-declared header; a .php or HTML file can claim image/png, so verify the magic bytes of the stored file and reject mismatches (declared-type filters are a convenience, not a control). (4) Store outside the web root / in object storage, and serve through a handler with Content-Disposition: attachment and a safe Content-Type — serving user-uploaded HTML/SVG from your origin is stored XSS with cookie access (Part 8.5); a separate asset domain is the stronger form.
(5) Scan and quarantine before other users can reach the file, and only then publish. Plus the plumbing: map MulterError codes to 413/422 with stable codes in the error funnel (9.9.3) so limit violations read as client errors, not server bugs.
AppliedWire request logging end to end: which libraries, what structure, and how does one trace ID reach every log line and error response?
Two libraries, one pipeline: pino as the application logger (structured, leveled, fast — 9.7.31's enqueue-and-batch architecture, with transports on a worker thread so no request waits on I/O) and pino-http (or Morgan, if you prefer the Apache-style access line) for per-request access logs, configured to reuse the same logger and the same request id.
The trace flow: the first middleware accepts an inbound X-Request-Id (from the load balancer or an upstream service) or mints a UUID; it stores it on res.locals.traceId (request-scoped — 9.9.2) and enters an AsyncLocalStorage context (3.8.7) so code that never sees res — repositories, domain services, the outbound HTTP client — can attach it without threading a parameter through every signature; req.log = logger.child({ traceId }) gives handlers a pre-tagged logger. The error funnel logs with the same id and includes it in the response envelope (9.9.3), and the outbound client injects it as a header so downstream services join the same trace (Part 10.10).
Structure rules: JSON objects, never interpolated strings (queryability); redact an allow-list for authorization, cookie, password, token, card fields; write only to stdout and let the platform ship it — no HTTP log shipper inside the request path (9.7.31's refusal).
InterviewA user reports being randomly logged out. The app runs four replicas behind a load balancer with express-session and no store configured. Explain and fix.
Diagnosis: with no store, express-session uses MemoryStore — an in-process Map. Each of the four replicas therefore holds a different set of sessions. The load balancer round-robins requests, so a user who logs in on replica 2 has a valid sid cookie that replicas 1, 3, and 4 cannot resolve: roughly three of every four requests appear unauthenticated, which the user experiences as random logouts. Two more defects come free: memory grows unbounded (MemoryStore doesn't expire entries reliably — a leak), and every deploy or crash destroys all sessions (3.8.6's law: cluster workers and replicas share nothing by default).
Fix: move session state out of the process — connect-redis against a shared Redis (with a TTL matching cookie.maxAge), which restores replica-independence, survives restarts, and gives you server-side revocation. Sticky sessions at the balancer are the inferior alternative: they mask the problem while making deploys, autoscaling, and node failure lossy (9.7.6's draining becomes user-visible logouts). While in there, fix the co-travelling defaults: secure: true (with trust proxy set — 9.9.2 — or cookies silently won't set), httpOnly, sameSite, a non-default cookie name, resave: false, saveUninitialized: false, and store only the user id in the session so role changes take effect on the next request rather than persisting a stale user object.
StaffDesign the upload subsystem for a document-sharing product: files up to 2 GB, 500 concurrent uploaders, virus scanning, per-tenant quotas, resumability, and a compliance rule that documents are never stored on application servers. Specify the architecture and what Express actually does.
Express does almost nothing with the bytes — that's the design. The compliance rule ("never stored on application servers") and the 2 GB size both forbid the Multer path outright; the architecture is presigned direct-to-object-storage with server-side orchestration: (1)
Initiate — POST /documents with metadata (filename, size, contentType, tenant) → the API validates (9.9.3), checks the tenant quota transactionally (a conditional atomic decrement — 9.5.4's claim protocol; quota is the contested resource), creates a Document row in state pending (9.5.4's status pipeline), and returns presigned multipart-upload credentials scoped to one object key, one tenant prefix, short TTL, with size and content-type conditions baked into the signature. (2)
Transfer — the client uploads parts directly to object storage (S3 multipart or resumable protocol), which gives resumability for free (retry only failed parts — the requirement satisfied by the storage layer, not by your server holding state) and consumes zero application bandwidth, memory, or file descriptors; 500 concurrent 2 GB uploads never touch Node (3.8.2's pool untouched, 3.8.4's memory law satisfied structurally). (3)
Complete — POST /documents/:id/complete verifies the object exists with the expected size/etag (never trust the client's claim — the same "wire values lie" rule), transitions pending → scanning, and enqueues a scan job. (4) Scan — a separate worker fleet (3.8.6/Part 10.8) pulls the object into an isolated sandbox, scans, then transitions to available or quarantined; the document is not downloadable in any state but available — enforced at the read path by the state machine, not by a flag someone might forget. (5)
Download — presigned GET URLs with short TTLs, Content-Disposition: attachment, and a per-request authorization check before signing; the CDN never sees an unsigned path. What Express owns: validation, quota arithmetic, the state machine, signature issuance (the only place credentials are minted, with tenant-scoped policies), the completion verification, and the audit trail — all small, fast, stateless handlers.
Failure modes designed for: abandoned uploads (a sweeper expires pending documents and releases quota — the compensation path); duplicate completion calls (idempotent by document state, 9.6.3); scanner backlog (queue depth alarm — uploads keep succeeding, availability lags, and that's the honest degradation). The sentence for the design review: when the compliance rule says the bytes may not rest on your servers, the correct Express upload design is one that never receives them — the API becomes an authority that issues, verifies, and records, and the storage layer does the transferring it is already better at.
Flashcards
FlashMulter storage choice
memoryStorage = whole file in Buffer (small + forwarded only; DoS otherwise). diskStorage = flat memory, cleanup duty. At scale: presigned direct-to-S3.
FlashUpload security five
limits mandatory · never trust originalname (traversal) · never trust mimetype (magic bytes) · store outside web root + Content-Disposition · scan before exposure.
FlashMorgan vs Pino/Winston
Morgan = HTTP access lines. Pino/Winston = structured app logs; Pino for hot paths (worker-thread transports). Both to stdout, JSON, redacted.
FlashtraceId flow
Accept X-Request-Id or mint UUID → res.locals + AsyncLocalStorage → child logger → error envelope → outbound headers. One id, whole request, cross-service.
FlashMemoryStore trap
Per-process ⇒ random logouts across replicas; leaks; dies on restart. Production: Redis store (sticky sessions are the inferior masking fix).
FlashPassport essentials
Strategy seam + verify callback; serializeUser stores the ID ONLY (stale embedded users = privilege bugs). Authorization is still your middleware.
Scenario Drill
DrillIncident: your Express API's memory climbs to OOM every afternoon. Facts: an avatar-upload endpoint uses multer memoryStorage with no limits; logs are written with Winston to a file transport synchronously; sessions use the default store; and one endpoint logs full request bodies for debugging left on from last month. Rank the causes by likely contribution, fix each, and give the three guardrails that would have prevented all of them.
Ranking by contribution. (1) memoryStorage with no limits — the dominant cause and the only one that can OOM a process in seconds: each concurrent upload holds its entire file in heap (3.8.3), so a handful of large files (or one attacker) exceeds any heap; the afternoon pattern matches peak user activity.
Fix: limits: { fileSize, files, fields } immediately (a one-line DoS control), switch to diskStorage with cleanup in finally, and schedule the presigned-direct-upload migration for anything above avatar size. (2) Default MemoryStore sessions — a genuine leak with a slow ramp (sessions accumulate, expiry is unreliable) that matches "climbs over hours"; it's also silently causing cross-replica logouts nobody has connected to this incident yet.
Fix: Redis store with TTL. (3) Full-body logging left on — inflates every log record with request payloads; combined with (4) it holds those strings in buffers and doubles serialization cost; it's also a compliance exposure (that endpoint has been writing user data, possibly credentials, to disk for a month — this becomes an incident of its own, 9.9.3's redaction rule).
Fix: delete it; replace with allow-listed fields; audit and rotate anything sensitive that was captured. (4) Synchronous Winston file transport — less an OOM cause than a latency and backpressure amplifier: it blocks the event loop per line (3.8.2), which slows request completion, which increases concurrent in-flight requests, which multiplies (1)'s memory — the coupling worth naming, because it explains why the OOM arrives faster than upload volume alone predicts.
Fix: Pino to stdout, transports off-thread (9.7.31). The three guardrails: (a) a limits policy enforced by review and by proxy — body size at Nginx, express.json({ limit }), Multer limits, log buffer bounds: every payload boundary declares a maximum, and "no explicit limit" fails review (9.5.1's bound-everything law made a checklist item); (b)
memory as a first-class signal — RSS and heap-used dashboards with an alert on growth rate, plus --max-old-space-size set deliberately so the process restarts predictably instead of thrashing; a heap snapshot on threshold makes the retainer obvious in minutes (3.6.2); (c)
debug-instrumentation expiry — verbose logging behind a flag with a TTL and an owner, so "temporary" diagnostics cannot outlive the investigation (the class of change that caused (3) is always introduced under incident pressure and never removed on schedule). The write-up's closing line: three of the four causes were missing bounds, and the fourth was a bound applied to the wrong thing — an app's memory profile is the sum of its limit decisions, and defaults are decisions nobody made.