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:
- JavaScript-heavy React bundles slow down First Contentful Paint
- MongoDB queries with no indexing destroy Time to First Byte (TTFB)
- Unoptimized images blow up Largest Contentful Paint (LCP)
- 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>
);
}
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>
);
}
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);
});
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);
});
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%' }}
/>
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
/>
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' }}
/>
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>
);
}
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 */
}
Also add font preloading in your <head>:
<link
rel="preload"
href="/fonts/inter.woff2"
as="font"
type="font/woff2"
crossorigin
/>
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 });
};
Quick Wins Checklist
Before diving deep into optimization, run through these quick wins first:
- [ ] Enable Gzip/Brotli compression in Express
- [ ] Add
Cache-Controlheaders 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
// server.js
import compression from 'compression';
app.use(compression()); // ← Add before routes
This alone can reduce response size by 60–70%.
How to Measure Your Progress
Use these tools in order:
- Google Search Console → Core Web Vitals report (real user data)
- PageSpeed Insights → pagespeed.web.dev (lab + field data)
- Chrome DevTools → Performance tab (local profiling)
- 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
// lighthouse-budget.json
{
"performance": 90,
"accessibility": 95,
"best-practices": 90,
"seo": 95
}
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)