DEV Community

Cover image for How to Fix Core Web Vitals in a MERN Stack App (Complete Guide)
Amar Kumar
Amar Kumar

Posted on

How to Fix Core Web Vitals in a MERN Stack App (Complete Guide)

Why Most MERN Apps Fail Core Web Vitals

You built a beautiful React app. It works perfectly on your machine. You push it live, run Lighthouse, and see this:

  • LCP: 4.8s ❌
  • CLS: 0.32 ❌
  • INP: 380ms ❌

Sound familiar?

The problem isn't your code quality — it's that MERN apps have unique performance challenges that generic tutorials don't cover:

  1. JavaScript-heavy React bundles slow down First Contentful Paint
  2. MongoDB queries with no indexing destroy Time to First Byte (TTFB)
  3. Unoptimized images blow up Largest Contentful Paint (LCP)
  4. Dynamic content injections cause Cumulative Layout Shift (CLS)

In this guide, I'll walk through real fixes with actual code — the same approach we use at Kraviona when optimizing client MERN apps for Technical SEO.


Understanding the 3 Core Web Vitals

Before fixing, let's understand what Google actually measures:

Metric What It Measures Good Score
LCP (Largest Contentful Paint) How fast the main content loads < 2.5s
INP (Interaction to Next Paint) How fast the page responds to clicks < 200ms
CLS (Cumulative Layout Shift) How much layout jumps around < 0.1

Fix #1 — Reduce LCP with Code Splitting

The biggest React mistake: shipping one giant JavaScript bundle.

❌ Bad (Default Setup)

// App.jsx — Everything loads at once
import Dashboard from './pages/Dashboard';
import Analytics from './pages/Analytics';
import Settings from './pages/Settings';
import Reports from './pages/Reports';

function App() {
  return (
    <Router>
      <Route path="/dashboard" element={<Dashboard />} />
      <Route path="/analytics" element={<Analytics />} />
      <Route path="/settings" element={<Settings />} />
      <Route path="/reports" element={<Reports />} />
    </Router>
  );
}
Enter fullscreen mode Exit fullscreen mode

This loads ALL pages even when user visits only /dashboard. Bundle size = 800KB+.

✅ Fix — React Lazy + Suspense

// App.jsx — Load only what's needed
import { lazy, Suspense } from 'react';

const Dashboard = lazy(() => import('./pages/Dashboard'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Settings = lazy(() => import('./pages/Settings'));
const Reports = lazy(() => import('./pages/Reports'));

function App() {
  return (
    <Router>
      <Suspense fallback={<div className="skeleton-loader" />}>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/analytics" element={<Analytics />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/reports" element={<Reports />} />
      </Suspense>
    </Router>
  );
}
Enter fullscreen mode Exit fullscreen mode

Result: Initial bundle drops from 800KB → ~120KB. LCP improves by 1.5–2s easily.


Fix #2 — Fix TTFB from Node.js/Express

LCP also depends on how fast your server responds. Slow MongoDB queries = slow TTFB = bad LCP.

❌ Bad — No Indexes, No Caching

// routes/products.js
router.get('/products', async (req, res) => {
  const products = await Product.find({ category: req.query.category });
  res.json(products);
});
Enter fullscreen mode Exit fullscreen mode

Every request hits MongoDB with a full collection scan. With 50K documents = 800ms+ response.

✅ Fix — Add Indexes + Redis Cache

// models/Product.js — Add indexes
const productSchema = new Schema({
  name: String,
  category: { type: String, index: true }, // ← Add index
  price: Number,
  createdAt: { type: Date, index: true }
});

// routes/products.js — Add Redis caching
import redis from 'redis';
const client = redis.createClient();

router.get('/products', async (req, res) => {
  const cacheKey = `products:${req.query.category}`;

  // Check cache first
  const cached = await client.get(cacheKey);
  if (cached) {
    return res.json(JSON.parse(cached));
  }

  // Fetch from DB
  const products = await Product.find({ 
    category: req.query.category 
  }).lean(); // .lean() returns plain objects, 2x faster

  // Store in cache for 5 minutes
  await client.setEx(cacheKey, 300, JSON.stringify(products));

  res.json(products);
});
Enter fullscreen mode Exit fullscreen mode

Result: Response time drops from 800ms → 15ms on cached requests. TTFB goes green instantly.


Fix #3 — Fix LCP with Next.js Image Optimization

If you're using Next.js (which I recommend for SEO), never use raw <img> tags.

❌ Bad

<img 
  src="/hero-banner.jpg" 
  alt="Hero Banner"
  style={{ width: '100%' }}
/>
Enter fullscreen mode Exit fullscreen mode

Problems: No lazy loading, no WebP conversion, no size optimization.

✅ Fix — next/image

import Image from 'next/image';

// For above-the-fold images (hero, banners)
<Image
  src="/hero-banner.jpg"
  alt="Hero Banner"
  width={1200}
  height={600}
  priority={true}        // ← Preload this image (critical!)
  quality={85}
  placeholder="blur"     // ← Show blur while loading
  blurDataURL="data:image/jpeg;base64,/9j/4AAQ..."
/>

// For below-the-fold images
<Image
  src="/product.jpg"
  alt="Product"
  width={400}
  height={300}
  loading="lazy"         // ← Default, but explicit is better
/>
Enter fullscreen mode Exit fullscreen mode

Key rule: Add priority={true} only to the hero image visible above the fold. This tells the browser to preload it immediately.

Result: LCP score typically improves by 30–50%.


Fix #4 — Fix CLS (Layout Shift)

CLS happens when elements move after page load. Most common causes in React apps:

Cause 1: Images without dimensions

// ❌ Bad — No dimensions = layout shift when image loads
<img src="/banner.jpg" alt="Banner" />

// ✅ Fix — Always define width/height
<img 
  src="/banner.jpg" 
  alt="Banner"
  width={1200}
  height={400}
  style={{ aspectRatio: '3/1' }}
/>
Enter fullscreen mode Exit fullscreen mode

Cause 2: Dynamic content injection (ads, toasts, banners)

// ❌ Bad — Content jumps when cookie banner appears
function App() {
  const [showBanner, setShowBanner] = useState(false);

  useEffect(() => {
    setShowBanner(true); // This causes layout shift!
  }, []);

  return (
    <div>
      {showBanner && <CookieBanner />}
      <MainContent />
    </div>
  );
}

// ✅ Fix — Reserve space upfront
function App() {
  return (
    <div>
      {/* Always render, just hide/show */}
      <div style={{ minHeight: '60px' }}>
        <CookieBanner />
      </div>
      <MainContent />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Cause 3: Custom fonts causing FOUT

/* In your CSS / globals.css */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter.woff2') format('woff2');
  font-display: swap; /* ← Prevents invisible text, reduces CLS */
}
Enter fullscreen mode Exit fullscreen mode

Also add font preloading in your <head>:

<link 
  rel="preload" 
  href="/fonts/inter.woff2" 
  as="font" 
  type="font/woff2" 
  crossorigin
/>
Enter fullscreen mode Exit fullscreen mode

Fix #5 — Improve INP (Interaction to Next Paint)

INP replaced FID in 2024. It measures all interactions, not just the first click.

Common Culprit: Heavy onClick handlers

// ❌ Bad — Blocking the main thread
function SearchBar() {
  const [results, setResults] = useState([]);

  const handleSearch = (query) => {
    // This runs synchronously, blocking UI
    const filtered = hugeDataArray.filter(item => 
      item.name.toLowerCase().includes(query.toLowerCase())
    );
    setResults(filtered);
  };

  return <input onChange={(e) => handleSearch(e.target.value)} />;
}

// ✅ Fix 1 — Debounce the handler
import { useMemo } from 'react';
import debounce from 'lodash/debounce';

function SearchBar() {
  const [results, setResults] = useState([]);

  const handleSearch = useMemo(
    () => debounce((query) => {
      const filtered = hugeDataArray.filter(item =>
        item.name.toLowerCase().includes(query.toLowerCase())
      );
      setResults(filtered);
    }, 300),
    []
  );

  return <input onChange={(e) => handleSearch(e.target.value)} />;
}

// ✅ Fix 2 — Use Web Worker for heavy computation
// searchWorker.js
self.onmessage = ({ data: { query, items } }) => {
  const filtered = items.filter(item =>
    item.name.toLowerCase().includes(query.toLowerCase())
  );
  self.postMessage(filtered);
};

// SearchBar.jsx
const worker = new Worker('/searchWorker.js');
worker.onmessage = ({ data }) => setResults(data);

const handleSearch = (query) => {
  worker.postMessage({ query, items: hugeDataArray });
};
Enter fullscreen mode Exit fullscreen mode

Quick Wins Checklist

Before diving deep into optimization, run through these quick wins first:

  • [ ] Enable Gzip/Brotli compression in Express
  • [ ] Add Cache-Control headers for static assets
  • [ ] Use CDN for images (Cloudflare, Vercel Edge)
  • [ ] Enable HTTP/2 on your server
  • [ ] Remove unused npm packages (npm-check)
  • [ ] Enable tree shaking in Webpack/Vite config
  • [ ] Use React DevTools Profiler to find re-render bottlenecks

Enable Compression in Express (30 seconds)

npm install compression
Enter fullscreen mode Exit fullscreen mode
// server.js
import compression from 'compression';
app.use(compression()); // ← Add before routes
Enter fullscreen mode Exit fullscreen mode

This alone can reduce response size by 60–70%.


How to Measure Your Progress

Use these tools in order:

  1. Google Search Console → Core Web Vitals report (real user data)
  2. PageSpeed Insights → pagespeed.web.dev (lab + field data)
  3. Chrome DevTools → Performance tab (local profiling)
  4. Lighthouse CI → Automated testing in your CI/CD pipeline

Lighthouse CI Setup (GitHub Actions)

# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [push]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Run Lighthouse CI
        uses: treosh/lighthouse-ci-action@v10
        with:
          urls: |
            https://your-domain.com
          budgetPath: ./lighthouse-budget.json
          uploadArtifacts: true
Enter fullscreen mode Exit fullscreen mode
// lighthouse-budget.json
{
  "performance": 90,
  "accessibility": 95,
  "best-practices": 90,
  "seo": 95
}
Enter fullscreen mode Exit fullscreen mode

Now every PR automatically checks if your changes hurt performance. ✅


Summary — What to Fix First

Priority Fix Impact Time
🔴 High Code splitting with React.lazy LCP -2s 1 hour
🔴 High MongoDB indexes + Redis cache TTFB -700ms 2 hours
🟡 Medium next/image for all images LCP -30% 1 hour
🟡 Medium Reserve space for dynamic content CLS fix 30 min
🟢 Low Debounce heavy handlers INP fix 30 min
🟢 Low Gzip compression Size -60% 5 min

Final Thoughts

Core Web Vitals aren't just a Google ranking signal — they directly impact user experience, bounce rate, and conversions. A 1-second improvement in LCP can increase conversions by up to 8%.

The fixes above are the exact optimizations we apply when building and auditing MERN Stack applications. Start with code splitting and MongoDB indexing — those two alone will push most apps from failing to passing.

If you're building a production MERN app and want a free Technical SEO audit, feel free to check out what we do at Kraviona Tech Solutions.

Happy optimizing! 🚀


Got questions or found a better approach? Drop it in the comments — always happy to learn from the community.

Tags: #webdev #javascript #react #node #seo #performance

Top comments (0)