What you'll learn

By the end of this you'll have a repeatable process for diagnosing and fixing MERN stack performance issues — not from theory but from a real application where the metrics were embarrassing and the fixes were surprisingly contained.

Who this is for

  • Developers who have a working MERN app and know it's slow but aren't sure where to start
  • Engineers preparing for a performance review or migration proposal who need to show data
  • Full-stack developers who want to see MongoDB, Express, and React performance work side by side

You can skip this if you're building greenfield. Performance passes are for live systems with real data and real users.

What is a MERN performance pass? Plain English

It's a time-boxed audit of a MERN application (MongoDB, Express, React, Node.js) with the explicit goal of making measurable improvements to response time and Core Web Vitals. No rewrites, no framework changes — just find the worst bottlenecks, fix them, measure again.

Plain English version: find the slowest parts, fix the most impactful ones, measure before and after.

Prerequisites

  • A deployed MERN application with real (or realistic) data
  • Access to MongoDB Atlas or your MongoDB host (for explain plans)
  • Node.js profiling basics (you can learn them here as we go)
  • Chrome DevTools for front-end profiling

Setup from zero

Step 1 — Establish a baseline with real measurements

Before touching anything, we measured:

  • API response times: median and 95th percentile per endpoint, using morgan with a custom timing token
  • MongoDB query times: slow query log enabled (operationProfiling.slowOpThresholdMs: 100)
  • Front-end: Lighthouse mobile run on the three most-visited pages
  • Core Web Vitals: LCP, TBT, CLS from Chrome DevTools

Results before the pass:

| Metric | Value |
|--------|-------|
| GET /api/articles p95 | 1,840 ms |
| GET /api/articles/:slug median | 420 ms |
| Lighthouse Performance (home) | 54 |
| LCP (home) | 4.1 s |

These numbers gave us something to beat.

Step 2 — MongoDB index audit

The slow query log immediately surfaced two collection scans:

db.articles.find({ status: "published", hub: "ai" }).sort({ publishedAt: -1 })

No compound index. MongoDB was scanning every document to find the published AI articles, then sorting the whole set. On a 12,000-document collection this was a full collection scan on every list request.

Fix:

db.articles.createIndex({ status: 1, hub: 1, publishedAt: -1 });

GET /api/articles p95 dropped from 1,840 ms to 210 ms with this one index. That's the most expensive query bottleneck solved with four lines.

Step 3 — Express route profiling

We wrapped every route handler with a lightweight timing middleware:

app.use((req, res, next) => {
  const start = performance.now();
  res.on("finish", () => {
    const ms = (performance.now() - start).toFixed(2);
    if (parseFloat(ms) > 200) console.warn(`SLOW ${req.method} ${req.url} ${ms}ms`);
  });
  next();
});

This surfaced a second problem: the article detail route was running three separate findOne queries (article, author, related articles) sequentially:

const article = await Article.findOne({ slug });
const author = await Author.findOne({ _id: article.authorId });
const related = await Article.find({ tags: { $in: article.tags } }).limit(4);

Each query was ~40 ms. Sequential: 120 ms minimum before any processing. Parallel:

const [article, author] = await Promise.all([
  Article.findOne({ slug }),
  Author.findOne({ _id: articleDoc.authorId }),
]);

GET /api/articles/:slug median dropped from 420 ms to 95 ms.

Step 4 — React rendering pass

The home page Lighthouse score (54) traced to two React issues:

Issue 1: the article list re-rendered on every keystroke in the search box. The search box and the article list were siblings in state; typing in the box triggered a full re-render of 30 article cards.

Fix: useMemo on the filtered list, not the raw list — so the component only re-renders when the filtered output actually changes.

Issue 2: every article card imported and rendered a full avatar image component with fetchpriority="high". Thirty images all fought to be the LCP image.

Fix: only the first card gets priority (maps to fetchpriority="high"). The rest use lazy loading.

Post-fix Lighthouse: 91. LCP: 1.3 s.

Step 5 — Add response caching for list endpoints

Article lists change at most a few times per hour. We added a short cache header:

res.set("Cache-Control", "public, max-age=300, stale-while-revalidate=60");

Five minutes of CDN caching for list endpoints. The MongoDB index already made them fast; the cache makes them free.

The mental model

The mental model for a performance pass is: measure, find the bottleneck, fix it, measure again.

The most common mistake is fixing what seems slow rather than what is slow. The article list felt slow to the team — but the first measurement showed the article detail endpoint was actually the higher-priority problem. Data first, intuition second.

Key terms

Collection scan — MongoDB reading every document in a collection to satisfy a query. Always bad at scale. Prevented by appropriate indexes.

Sequential vs parallel queries — queries that depend on each other must be sequential. Queries that don't can run in Promise.all. Sequential chains are one of the most common MERN performance bugs.

p95 response time — the response time at the 95th percentile. 5% of requests are slower. The median hides outliers; p95 shows you the worst normal case.

Stale-while-revalidate — a cache directive that serves the cached response immediately while refreshing it in the background. Near-zero latency with eventual consistency.

Step-by-step

The pass followed one rule: fix the worst bottleneck, measure, repeat. These three changes delivered most of the win.

Problem: list endpoint p95 at 1,840 ms

GET /api/articles ran a full collection scan on every request — no compound index on status + hub + publishedAt.

Approach: one compound index, then explain("executionStats") to confirm totalDocsExamined dropped.

Outcome: p95 fell to 210 ms (−89%). This alone justified the half-day pass.

Problem: detail route ran three sequential queries

Article, author, and related lookups chained in series — ~120 ms minimum before any JSON serialization.

Approach: parallelise independent reads with Promise.all; keep related lookup dependent on article tags but start author fetch immediately.

Outcome: median response time 420 ms → 95 ms (−77%).

Problem: home page Lighthouse 54

Thirty avatar images all claimed fetchpriority="high". Search keystrokes re-rendered the entire article list.

Approach: single priority image on the first card; useMemo on filtered list output only.

Outcome: Lighthouse 91, LCP 1.3 s, TBT down 240 ms.

Working examples

Summary of all changes made during the pass:

| Change | Effort | Impact |
|--------|--------|--------|
| Compound MongoDB index | 15 min | /api/articles p95: −89% |
| Parallel DB queries | 30 min | /api/articles/:slug median: −77% |
| React useMemo on list filter | 20 min | Home TBT: −240 ms |
| Single priority image | 10 min | LCP: −2.8 s |
| CDN cache headers | 10 min | List endpoint load: eliminated |

Total: ~85 minutes of focused work. Three of these changes required no architectural change at all.

Patterns

Measure-first pattern — no change without a before number. No result without an after number. Gut feelings about performance are almost always wrong in the specific.

Index-before-cache pattern — add indexes before adding caches. Caching a slow query just means the slow query runs less often. An indexed query doesn't need the cache.

Parallel-by-default pattern — for every set of DB queries in a route handler, ask: do any of these depend on the result of another? If not, wrap them in Promise.all.

Common mistakes

Adding indexes without explaining first. Always run explain("executionStats") before and after. If totalDocsExamined didn't drop dramatically, the index isn't being used.

Over-memoising in React. useMemo adds overhead. Only use it when the computation is genuinely expensive (filtering/sorting >100 items) or when referential equality matters for downstream memos.

Setting long cache headers before the index pass. You lock in slow query behaviour for the duration of the cache TTL.

Little tip

Enable MongoDB's profiler at level 2 in development (db.setProfilingLevel(2)) and watch db.system.profile.find().sort({ ts: -1 }).limit(5) after any new feature. Catch full collection scans before they hit production.

Little tip

The Chrome DevTools Performance panel's "Bottom-Up" view, sorted by "Self Time", shows you the actual functions consuming the most CPU time during a React render. It's more actionable than the flame graph for identifying unexpected re-renders.

Troubleshooting

Index created but query still slow. Run explain("executionStats") and check winningPlan. If it shows COLLSCAN, the query planner chose not to use your index. Check field order in the index vs. query; MongoDB indexes are direction-aware.

Promise.all causes a bug that sequential queries didn't. One query probably depends on another's result. Map out the dependency graph before parallelising. Promise.all for truly independent queries only.

Lighthouse score improved but real user metrics didn't. Lighthouse tests on a simulated device; real users may be on faster or slower connections. Check CrUX data in Search Console for field data.

Checklist

  • [ ] Baseline metrics captured (API p95, Lighthouse, LCP, TBT)
  • [ ] MongoDB slow query log reviewed
  • [ ] explain("executionStats") run on top 5 slowest queries
  • [ ] Sequential DB query chains identified and parallelised
  • [ ] React re-render audit: unnecessary re-renders memoised
  • [ ] Only one priority image per page
  • [ ] Cache headers on list endpoints after index pass
  • [ ] After-metrics captured and compared to baseline

Practice task

Take any MERN application (your own or a public one). Enable the MongoDB profiler, run the app's main workflows, and identify the top three queries by millis. Add indexes for them, run explain, confirm the execution plan changed. Report the totalDocsExamined before and after.

FAQ

How often should you run a performance pass?
When metrics degrade (set an alert at p95 >500 ms on critical endpoints), after major features ship, and before scaling decisions. Not on a calendar schedule.

Is caching a substitute for indexing?
No. Caching reduces how often a slow query runs. Indexing makes the query fast. You want both, in that order.

Should I use Mongoose or the native MongoDB driver for performance-sensitive code?
Mongoose adds a small overhead for validation and middleware on every query. For read-heavy list endpoints, the native driver is measurably faster (~10–20%). Use Mongoose for write paths where validation matters; consider the native driver for hot read paths.

What to learn next

  • MongoDB aggregation pipeline optimisation — covering indexes, $lookup alternatives
  • Node.js --prof flag and V8 profiling for CPU-bound bottlenecks
  • React DevTools Profiler for identifying expensive component trees
  • [Building the Baseline content hub](/case-studies/building-baseline-content-hub)
  • [JSON formatter tool build](/case-studies/json-formatter-tool-build)
  • [Prompt library launch](/case-studies/prompt-library-launch)

Takeaways

Performance passes work because most applications have three or four catastrophic bottlenecks and a long tail of minor inefficiencies. Fix the catastrophic ones first. In this pass, the compound index and query parallelisation together delivered 80% of the improvement in under an hour.

If you remember only one thing: measure before you fix, and run explain("executionStats") on your slowest queries before you add any index. The data will tell you exactly what to do.