Improve Page Speed & Core Web Vitals — Part 2

Blog / Django · August 20, 2018 · Updated June 10, 2026 · 9 min read
Improve Page Speed & Core Web Vitals — Part 2

To improve page speed in 2026 you stop chasing the old PageSpeed score and instead pass Core Web VitalsLCP (loading), INP (responsiveness, which replaced FID in March 2024) and CLS (visual stability) — by cutting server response time on the back end and shipping fewer, smaller bytes on the front end.

Part 1 of this series covered what the metrics are, the lab-vs-field-data difference, and the first round of fixes. This Part 2 goes deeper on the advanced, back-end and asset-pipeline optimizations that squeeze out the last seconds — database query tuning, server-side caching, smarter compression, CDNs, modern image formats and HTTP/3 — framed for a Django or server-rendered site.

Key takeaways

  • Core Web Vitals, not a score: target LCP ≤ 2.5 s, INP ≤ 200 ms and CLS ≤ 0.1 at the 75th percentile of real users — INP replaced FID on 12 March 2024.
  • Most LCP time is server time: kill N+1 queries with select_related/prefetch_related, fetch only the columns you render, and use .exists() only for a boolean check.
  • Cache aggressively: Redis or Memcached for query and page caching, plus per-view and template-fragment caching, slashes TTFB on repeat hits.
  • Ship fewer bytes: bundle, tree-shake and code-split JS/CSS, defer non-critical assets, and serve AVIF/WebP images with responsive srcset.
  • Let the network do the work: Brotli compression, a CDN with immutable hashed assets, and HTTP/2 or HTTP/3 cut transfer time everywhere.
  • Measure with field data: verify every change in PageSpeed Insights / CrUX and Search Console, not just a single Lighthouse run.

How do you cut server response time (TTFB) on a Django site?

A slow Time to First Byte (TTFB) delays everything after it, including LCP. On a server-rendered Django app the usual culprit is the database — specifically redundant and N+1 queries.

  • Don't evaluate the same queryset twice. A common anti-pattern is calling .exists() and then re-running the same filter to fetch the rows — that is two database round-trips. Use .exists() only when you need a boolean and won't touch the rows; otherwise evaluate the queryset once and reuse its cached result.
  • Kill N+1 queries. Use select_related() for foreign-key / one-to-one relations (a SQL JOIN) and prefetch_related() for many-to-many / reverse relations. This collapses hundreds of per-row queries into one or two.
  • Fetch only what you render with .only() / .defer(), and aggregate in the database with .count(), .aggregate() and annotate() instead of looping in Python.
  • Add indexes on the columns you filter and order by, and confirm they are used with EXPLAIN ANALYZE.
  • Profile before optimizing. django-debug-toolbar and django-silk show the exact query count and duration per request, so you fix the real bottleneck instead of guessing.
# Anti-pattern: the same queryset is evaluated twice -> two DB round-trips
if Assessment.objects.filter(subject_id=subject_id).exists():
    assessments = list(Assessment.objects.filter(subject_id=subject_id))

# Better: evaluate once and reuse the cached result
assessments = list(Assessment.objects.filter(subject_id=subject_id))
if assessments:
    use(assessments)

# Kill N+1 queries by fetching related rows in the same trip
enrolments = (
    Enrolment.objects
    .select_related("student", "subject")          # FK / one-to-one -> SQL JOIN
    .prefetch_related("assessments")               # M2M / reverse FK -> 2nd query
    .only("id", "student__name", "subject__title") # fetch only rendered columns
)

How does server-side caching speed up pages?

The fastest query is the one you never run. Django ships a full cache framework; back it with Redis or Memcached (the modern replacement for the long-abandoned django-memcached package) and you can serve repeat requests from memory in microseconds.

Layer caching from coarse to fine:

  • Per-view caching with @cache_page for pages that rarely change — lists, landing pages, public articles.
  • Template-fragment caching ({% cache %}) for expensive partials like sidebars, navs or rendered markdown.
  • Low-level cache API (cache.get / cache.set) for individual expensive querysets or computed values.
  • cached_property to memoize per-request computations within a single response.

Pick a sensible TTL and invalidate on write (Django signals or an explicit cache.delete) so users never see stale data. For pages that are identical for every visitor, a reverse-proxy or CDN cache in front of Django is faster still.

# settings.py — Django 4.0+ ships a native Redis backend (no extra package)
CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": "redis://127.0.0.1:6379/1",
    }
}

# Per-view caching for pages that change rarely
from django.views.decorators.cache import cache_page

@cache_page(60 * 15)  # 15 minutes
def article_list(request):
    ...

# Low-level cache for one expensive queryset
from django.core.cache import cache

def popular_tags():
    tags = cache.get("popular_tags")
    if tags is None:
        tags = list(Tag.objects.with_counts()[:20])
        cache.set("popular_tags", tags, 60 * 60)  # 1 hour
    return tags

How do you optimize CSS and JavaScript delivery?

Unused and render-blocking CSS/JS inflate both LCP and INP. The goal is to ship the minimum needed for the first paint and defer the rest.

  • Bundle and minify your assets, and let the build fingerprint filenames for cache-busting. Django's ManifestStaticFilesStorage hashes static files automatically, while a bundler like Vite or esbuild — or django-webpacker to bundle and compress your CSS/JS — handles the build step.
  • Tree-shake and code-split. Drop unused exports and load route- or interaction-specific JavaScript on demand instead of one giant bundle — heavy hydration is a leading INP killer.
  • Inline critical CSS for above-the-fold content and load the rest asynchronously, so the first paint is never blocked.
  • Defer non-critical JavaScript with defer / async, and load third-party tags (analytics, chat, A/B tools) lazily.
  • Self-host and subset fonts, serve WOFF2, add font-display: swap, and preload the one or two fonts used above the fold to avoid invisible text and layout shift.

Which image formats cut the most bytes?

Images are usually the heaviest thing on a page, so format choice has the biggest payoff for LCP and total transfer size. Encode to a modern format, serve it responsively with srcset, and always set explicit width/height so the box is reserved — which also protects CLS.

Format Best for Typical size vs JPEG 2026 browser support
JPEG Photos (legacy fallback) baseline Universal
PNG Transparency, sharp UI / line art larger than JPEG for photos Universal
WebP Photos + transparency, safe default ~25–35% smaller than JPEG All modern browsers
AVIF Best compression for photos / hero images ~50% smaller than JPEG Chrome, Edge, Firefox, Safari 16+

Serve AVIF with a WebP and JPEG/PNG fallback inside a <picture> element, generate several widths for srcset, lazy-load below-the-fold images with loading="lazy", and never lazy-load the LCP image. Pair right-sized images with a CDN (next section) and you cut both bytes and round-trip distance at once.

Should you use gzip or Brotli, and how do CDNs help?

Text assets (HTML, CSS, JS, SVG, JSON) compress extremely well, and a CDN puts both your compressed text and your images physically closer to users.

Aspect gzip Brotli
Typical text savings baseline ~15–25% smaller than gzip
Compression speed fast slower at max level — pre-compress static files at build time
Best use dynamic responses, universal fallback static CSS/JS/SVG/HTML served pre-compressed
2026 support universal all modern browsers over HTTPS

Use Brotli for static assets (pre-compressed at build time so there is no per-request CPU cost) with gzip as the dynamic-response fallback. Then put a CDN in front:

  • Cache-Control with immutable on fingerprinted/hashed files (max-age=31536000, immutable) so browsers and edge nodes never re-fetch them; keep a short TTL on HTML.
  • Edge caching serves repeat requests from a nearby point of presence instead of your origin, cutting TTFB worldwide.
  • For Django, push static and media to object storage behind a CDN — see implementing Amazon S3 and CloudFront for faster loading.

Note that the old django-storages AWS_HEADERS setting is deprecated — set cache headers via AWS_S3_OBJECT_PARAMETERS instead, as shown below.

# Fingerprinted assets never change -> cache forever and mark immutable
location ~* \.[0-9a-f]{8,}\.(?:css|js|woff2|avif|webp|jpg|png|svg)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
}

# Short cache for HTML so new deploys go live immediately
location / {
    add_header Cache-Control "public, max-age=0, must-revalidate";
}

# Serve pre-compressed Brotli/gzip files directly (no CPU at request time)
brotli_static on;
gzip_static on;
# settings.py — django-storages on S3 / CloudFront.
# AWS_HEADERS is long deprecated; use AWS_S3_OBJECT_PARAMETERS for cache headers.
AWS_S3_OBJECT_PARAMETERS = {
    "CacheControl": "public, max-age=31536000, immutable",
}

STORAGES = {
    "default": {"BACKEND": "storages.backends.s3.S3Storage"},
    "staticfiles": {"BACKEND": "storages.backends.s3.S3Storage"},
}

Do HTTP/2 and HTTP/3 still matter?

Yes — the transport layer affects every byte. Make sure your CDN or origin serves modern protocols:

  • HTTP/2 multiplexes many requests over a single connection, removing the head-of-line blocking that once made "bundle everything" mandatory on HTTP/1.1.
  • HTTP/3 (QUIC) runs over UDP and recovers from packet loss and network changes far better, which helps real users on mobile — exactly the field conditions CrUX measures.
  • preconnect / dns-prefetch to critical third-party origins (fonts, analytics, payments) so the TLS handshake is already done when the resource is requested.
  • Reduce redirects and TLS round-trips; every extra hop adds latency before the first byte arrives.

Putting it all together

Page speed is a loop, not a one-off: measure with field data, fix the worst-offending metric, then re-measure. Work back-to-front — cut TTFB with query tuning and caching first (it improves every downstream metric), then shrink and defer your assets, then let Brotli, a CDN and HTTP/3 carry the rest. Speed is also just one ranking signal among many, so pair it with the rest of your on-page SEO factors, and revisit Part 1 of this guide for the fundamentals.

If you would rather hand it off, our team has spent 12+ years and 50+ projects shipping fast, Core-Web-Vitals-friendly web applications and Django builds — from database tuning all the way to CDN delivery.

Frequently Asked Questions

How do I reduce Time to First Byte on a Django site?

Cut database work first: eliminate N+1 queries with select_related and prefetch_related, fetch only the columns you render with .only(), add indexes on filtered columns, and cache rendered pages or expensive querysets in Redis or Memcached. Profiling with django-debug-toolbar shows which queries to fix first.

When should I use .exists() instead of fetching the queryset?

Use .exists() only when you need a yes/no answer and will not use the rows — it runs an efficient EXISTS query. If you are going to use the records, evaluate the queryset once and reuse it; calling .exists() and then re-running the same filter is two database round-trips for no benefit.

Is AVIF or WebP better for page speed?

AVIF gives the best compression — often around 50% smaller than JPEG — while WebP is roughly 25–35% smaller with slightly broader legacy support. Serve AVIF first with a WebP and JPEG fallback in a <picture> element so modern browsers get the smallest file and older ones still work.

Should I use Brotli or gzip?

Use both. Serve Brotli for static text assets (CSS, JS, SVG, HTML) pre-compressed at build time, since it is about 15–25% smaller than gzip but slower to compress on the fly. Keep gzip as the fallback for dynamic responses and any client that does not advertise Brotli support.

How does a CDN improve Core Web Vitals?

A CDN caches assets at edge locations near your users, cutting round-trip latency and TTFB, which directly improves LCP. Combined with immutable Cache-Control on hashed files, repeat visits load static assets from cache instantly instead of hitting your origin server.

Does server-side caching help Core Web Vitals?

Yes — caching reduces TTFB, and TTFB is part of LCP. Per-view caching, template-fragment caching and a low-level cache for expensive queries let Django answer repeat requests from memory in microseconds instead of rebuilding the page and re-querying the database every time.

Share this article