Skip to content
Home About Services Work Notes Contact Start a project
Web performance & SEO

Core Web Vitals for Server-Rendered Django Sites

Server-rendered sites start with an advantage on Core Web Vitals and then throw it away on fonts, hero images and a carousel nobody asked for.

Core Web VitalsSEODjangoPerformance

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

MetricGoodWhat it measures
LCP< 2.5sWhen the largest element finishes rendering
CLS< 0.1How much the layout jumps while loading
INP< 200msHow 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 width and height; 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_related for forward foreign keys, prefetch_related for reverse and many-to-many. Install django-debug-toolbar locally and look at the query count on your heaviest template — the number is usually a surprise.
  • Never run with DEBUG = True in 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 on only covers text/html by default — CSS and JS go uncompressed unless you set gzip_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 www and the apex both return 200, every page exists twice.
  • A sitemap with accurate lastmod. Google ignores changefreq and priority entirely, and it ignores lastmod too 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 in robots.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.

Next step

Need this built properly?

Tell me what you are trying to ship. You get an approach, a timeline and a realistic estimate — usually within a working day.