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.

React — split routes · virtualize lists · stop fetch waterfalls Express — paginate · lean JSON · never block the event loop MongoDB — index the filter · project fields · pool connections measure p95 per layer, not "the app feels slow"
Most MERN slowness is one unindexed query or one bloated bundle — not the stack.

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 + skip or cursor on createdAt for 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.

  1. Route-level code splitting — admin charts and PDF viewers do not belong in the landing bundle. React.lazy on heavy routes cut first-load JS noticeably.
  2. 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.
  3. 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.
  4. Fetch once, cache sensibly — duplicate useEffect fetches 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/me cache 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.