To improve page speed in 2026 you stop chasing the old PageSpeed score and instead pass Core Web Vitals — LCP (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) andprefetch_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()andannotate()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-toolbaranddjango-silkshow 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_pagefor 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_propertyto 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 tagsHow 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
ManifestStaticFilesStoragehashes 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, andpreloadthe 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-Controlwithimmutableon 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-prefetchto 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.