on this page
Every slow MERN app I have debugged had the same opening act: the team debated Redis, a CDN, or "maybe we need Go," while one Mongo query scanned a million documents and the React bundle shipped every chart library on npm.
The stack is not the problem. Unmeasured defaults are.
MongoDB: the usual culprit
On the invoice product, the dashboard that listed invoices by account and status was fine with fifty rows. At ten thousand, it was not fine — because we filtered on accountId and sorted on createdAt without a compound index.
The fix was not sharding. It was:
db.invoices.createIndex({ accountId: 1, status: 1, createdAt: -1 });
Then we stopped returning the world:
- Projection — only fields the table needs. Not the full extraction blob.
- Pagination —
limit+skipor cursor oncreatedAtfor deep pages. Default page size of 20, not 200. - Lean queries —
.lean()in Mongoose when you are not mutating documents. Hydrated documents cost memory and CPU.
The smell test: run explain("executionStats") on your slowest query. If totalDocsExamined dwarfs nReturned, you do not have a scaling problem. You have an index problem.
Express: stop doing heavy work on the hot path
Node is fast when you do not block it. That sounds obvious until someone synchronously parses a 12MB CSV in middleware.
Patterns that helped:
- Paginate at the database, not in memory after
find({}). - Stream large downloads instead of buffering entire files.
- Move email, PDF generation, and third-party calls to a queue — same lesson as the ingest essay, smaller scale.
- Compress JSON responses only when payloads are large; gzip on a 200-byte health check is noise.
Keep handlers thin. Validate, authorize, call a service, return. If a route does five sequential awaits to unrelated services, that is a waterfall you can parallelize with Promise.all where dependencies allow — or split into a job.
React: ship less, render less
The frontend wins were less glamorous than rewriting in Rust, but users felt them immediately.
- Route-level code splitting — admin charts and PDF viewers do not belong in the landing bundle.
React.lazyon heavy routes cut first-load JS noticeably. - Virtualize long lists — rendering 2,000 table rows because "it works on my laptop" will not work on a finance user's four-year-old ThinkPad.
- Stabilize props — memoize expensive child trees when profiling shows wasted renders. Do not memo everything on day one; use the React DevTools profiler and fix what actually re-renders.
- Fetch once, cache sensibly — duplicate
useEffectfetches on mount because two components need the same user object is a classic MERN tax. Lift data to a parent, use SWR or React Query, or expose a small/api/mecache on the server.
Images: serve WebP/AVIF, set width and height, lazy-load below the fold. This is baseline, not optional.
The N+1 populate trap
Mongoose .populate() in a loop is ORM comfort food that becomes a latency bomb. One route returned invoices and populated vendor, line items, and uploader for each row — three round trips per document, times fifty rows.
Fixes, in order of preference:
- Embed what you always need together if the document is read as a unit.
- $lookup in aggregation when you need a join-shaped answer once.
- Batch load related ids in one query and map in application code.
If populate appears inside for or .map(), that is a bug until proven otherwise.
Redis: cache what is expensive and stable
We did not Redis-all-the-things. We cached:
- Session-adjacent config that rarely changes
- Aggregates that were expensive to compute and okay stale for five minutes
- Rate-limit counters
We did not cache individual invoice rows that finance might edit. Stale reads in a ledger UI are worse than a few extra milliseconds.
What to measure
Before and after each change, track:
| Layer | Metric |
|---|---|
| Browser | LCP, bundle size, route transition time |
| API | p95 per route, payload bytes |
| Mongo | executionStats, connection pool wait |
| Product | Time to interactive on the slowest screen users actually use |
If you cannot name the slowest route and the slowest query, you are guessing. MERN gives you velocity; discipline gives you speed.
What I would not do first
- Rewrite the API in another language before indexing
- Add GraphQL because REST "feels chatty" — fix chatty handlers
- Microservices because Mongo is slow — fix Mongo
- Premature Kubernetes — you are not out of single-server optimizations yet
The MERN stack is enough for a lot of production software. Make each layer honest, measure p95, and save the rewrite for when the numbers — not the Twitter discourse — demand it.