Server-rendered sites start with a structural advantage on Core Web Vitals. The HTML arrives complete; there is no loading spinner, no hydration, no waiting for a bundle to decide what the page says.
Then most of them give the advantage away on three things: a font that blocks text, a hero image nobody sized, and JavaScript that was not needed.
The three metrics, and what actually moves them
| Metric | Good | What it measures |
|---|---|---|
| LCP | < 2.5s | When the largest element finishes rendering |
| CLS | < 0.1 | How much the layout jumps while loading |
| INP | < 200ms | How fast the page responds to interaction |
All three are measured on real visitors, at the 75th percentile. A perfect Lighthouse score on your laptop says very little about a mid-range Android phone on a congested tower, which is what most of your traffic actually is.
LCP: find the element before optimising anything
The LCP element is usually a hero image or the first heading. Measure it rather than guessing:
new PerformanceObserver((list) => {
const entry = list.getEntries().at(-1);
console.log('LCP', entry.startTime, entry.element);
}).observe({ type: 'largest-contentful-paint', buffered: true });
If it is an image, three things matter and they are all one-liners:
<img src="/media/hero.webp"
width="900" height="1094" <!-- reserves space: fixes CLS -->
fetchpriority="high" <!-- jump the queue -->
alt="..."> <!-- no lazy loading above the fold -->
loading="lazy" on the LCP image is the most common own-goal in this list. It
delays the one image the metric is measuring. Lazy-load everything below the fold
and nothing above it.
Serve WebP or AVIF, and serve it at the size it renders. A 2400px-wide image displayed at 900px is roughly seven times the bytes for no visible benefit.
Fonts block text, which blocks LCP
If your LCP element is a heading, the web font is on the critical path. Without
font-display, browsers hide text for up to three seconds waiting for the file:
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="...&display=swap" rel="stylesheet">
display=swap renders immediately in the fallback and swaps when the font
arrives. That swap is itself a layout shift unless the fallback metrics are close
— which is what size-adjust and ascent-override on an @font-face fallback
are for. Self-hosting the font removes a whole connection from the critical path
and is usually the bigger win.
CLS: reserve space for everything
Layout shift comes from content that arrives after first paint and pushes things down. The causes are predictable:
- Images without dimensions. Always set
widthandheight; CSS can still resize them. The attributes give the browser the aspect ratio before the bytes arrive. - Embeds and iframes. Wrap in a container with a fixed
aspect-ratio. - Banners injected at the top. Cookie notices and announcement bars that push the page down are pure CLS. Overlay them, or reserve the space.
- Late-loading fonts with mismatched fallback metrics.
INP: the metric server-rendered sites still fail
INP replaced First Input Delay, and it is stricter: it measures the full interaction, including rendering the response, across the whole page visit.
A Django site with three jQuery plugins and a carousel can fail INP while scoring well on everything else. The fix is usually deletion. Before that, two habits:
// Debounce work triggered by typing — without this, every
// keystroke runs a filter pass over the whole list
let t;
input.addEventListener('input', () => {
clearTimeout(t);
t = setTimeout(applyFilter, 120);
});
// Yield to the browser between chunks of long work so the
// main thread can paint and respond in between
async function processAll(items) {
for (let i = 0; i < items.length; i++) {
process(items[i]);
if (i % 50 === 0) await new Promise(r => setTimeout(r, 0));
}
}
And load non-critical scripts with defer. A synchronous <script> in the head
blocks parsing, which delays everything behind it.
Django-side wins
Time to first byte is the floor under LCP: you cannot render fast if the HTML arrives slowly.
- Kill N+1 queries.
select_relatedfor forward foreign keys,prefetch_relatedfor reverse and many-to-many. Installdjango-debug-toolbarlocally and look at the query count on your heaviest template — the number is usually a surprise. - Never run with
DEBUG = Truein production. It disables template caching and retains every query in memory. It is a correctness and security problem first, but it is a performance problem too. - Cache fragments that are expensive and shared, not per-user panels.
- Enable compression for the right types. nginx's
gzip ononly coverstext/htmlby default — CSS and JS go uncompressed unless you setgzip_types, which is a common and invisible miss.
The technical SEO that has to be right first
Core Web Vitals is a tiebreaker. It will not rescue a site Google cannot crawl or understand. Before performance work, confirm:
- One self-referencing
<link rel="canonical">per URL, absolute, on the host you actually want indexed. - One host serving the site. If
wwwand the apex both return 200, every page exists twice. - A sitemap with accurate
lastmod. Google ignoreschangefreqandpriorityentirely, and it ignoreslastmodtoo if you set it to "now" on every fetch. - Exactly one
<h1>, and a title and meta description unique to the page. - Thin or private pages carrying
noindex— and not also disallowed inrobots.txt, because a page that cannot be crawled cannot be seen to be noindex.
Measure real users, not your laptop
Lighthouse is a lab tool: useful for finding causes, useless for knowing whether you passed. The field data in Search Console's Core Web Vitals report is what Google actually uses, and it lags changes by weeks because it is a 28-day rolling window.
Deploy the fix, then wait. The temptation to keep changing things while the window catches up is how you lose track of which change did what.