Serve Optimised Images and Thumbnails From Django
Serve fast, right-sized images from a Django app: generate thumbnails in background tasks, use WebP and AVIF, responsive srcset, CDN or image service resizing, cache headers for media, and avoid resizing images inside web requests.
Problem statement
Users upload 6 MB phone photos, and product pages display them in 300-pixel cards, so browsers download megabytes per page and Largest Contentful Paint suffers. Generating thumbnails on the fly in templates (as some libraries do on first access) makes the first visitor wait while Pillow resizes, and can exhaust worker memory. A production image pipeline stores originals once, generates the sizes you need ahead of time or at the edge, serves modern formats, and caches aggressively.
- Store originals in object storage; never resize inside the request that serves a page.
- Generate fixed renditions (for example 320, 640, 1280 px wide) in a Celery task after upload.
- Encode WebP (and optionally AVIF) alongside JPEG fallbacks; strip metadata and apply EXIF orientation.
- Render responsive
srcsetandsizesso browsers download the right width; setwidthandheightto avoid layout shift. - Serve media through a CDN with long cache lifetimes and versioned filenames.
- Alternatively, use an image CDN (Cloudflare Images, imgix, Cloudinary, CloudFront plus Lambda) that resizes by URL parameters.
Step-by-step solution
1. Generate renditions in the background
# media/tasks.py
from io import BytesIO
from PIL import Image, ImageOps
from celery import shared_task
from django.core.files.base import ContentFile
SIZES = [320, 640, 1280]
@shared_task(autoretry_for=(OSError,), max_retries=3)
def build_renditions(photo_id):
photo = Photo.objects.get(pk=photo_id)
with photo.original.open("rb") as f:
img = ImageOps.exif_transpose(Image.open(f))
img = img.convert("RGB")
for width in SIZES:
if img.width < width:
continue
resized = img.copy()
resized.thumbnail((width, width * 10))
for fmt, ext in (("WEBP", "webp"), ("JPEG", "jpg")):
buf = BytesIO()
resized.save(buf, fmt, quality=80, optimize=True)
Rendition.objects.update_or_create(
photo=photo, width=width, format=ext,
defaults={"file": ContentFile(buf.getvalue(), name=f"{photo.pk}-{width}.{ext}")},
)
Queue it with transaction.on_commit after the upload is saved. Large images use a lot of memory during decoding, so run these tasks on a dedicated queue with low concurrency and max_tasks_per_child.
2. Render responsive images
<picture>
<source type="image/webp" srcset="{% for r in photo.webp %}{{ r.file.url }} {{ r.width }}w{% if not forloop.last %}, {% endif %}{% endfor %}"
sizes="(min-width: 960px) 320px, 50vw">
<img src="{{ photo.jpeg_640.file.url }}"
srcset="{% for r in photo.jpeg %}{{ r.file.url }} {{ r.width }}w{% if not forloop.last %}, {% endif %}{% endfor %}"
sizes="(min-width: 960px) 320px, 50vw"
width="{{ photo.width_640 }}" height="{{ photo.height_640 }}" loading="lazy" decoding="async"
alt="{{ photo.alt_text }}">
</picture>
The browser chooses the smallest file that fits the displayed size and screen density. Explicit width and height reserve space and prevent layout shift; loading="lazy" defers off-screen images (but not the main hero image, which should load eagerly).
3. Serve from a CDN with long caching
Rendition filenames include the photo ID and width; if an image can be replaced, add a version or hash to the name so you can cache forever:
"object_parameters": {"CacheControl": "public, max-age=31536000, immutable"}
in django-storages options, with a CDN (CloudFront, Cloudflare) in front of the bucket.
4. Or let an image CDN resize
Image services generate variants from the original by URL, for example https://images.example.com/cdn-cgi/image/width=640,format=auto/photos/123.jpg with Cloudflare Image Resizing. format=auto serves AVIF or WebP when supported. Django only builds URLs, and no rendition models are needed. Restrict allowed sizes to prevent abuse and runaway costs.
5. Libraries
easy-thumbnails, sorl-thumbnail, and django-imagekit generate thumbnails with template tags; configure them to pre-generate on upload (or via a task) rather than lazily on first page view, and to store results in the same object storage.
Verification check: in browser dev tools, product images load as WebP at widths close to their displayed size, responses show cache-control: ... immutable and a CDN hit header, and Lighthouse reports no "properly size images" or "serve images in next-gen formats" warnings.
Explanation
Image weight dominates most pages. Serving a correctly sized, efficiently encoded image for each display context reduces bytes by one or two orders of magnitude, which directly improves load time and Core Web Vitals. Resizing is CPU and memory intensive, so doing it in background workers (or at an image CDN) keeps web workers responsive. Responsive image markup lets the browser, which knows the screen size and density, choose the best file, and immutable caching means each variant is downloaded once.
Edge cases / notes
- Animated GIFs are large; convert to MP4 or animated WebP.
- SVG uploads need sanitising and should not be processed by Pillow.
- Decompression bombs. Keep Pillow's pixel limit and validate dimensions on upload.
- Private images need signed URLs with short lifetimes; CDN caching must respect them.
Next steps
- Make uploads robust with Fix Pillow Errors on Django Image Uploads.
- Store originals properly with Store Django Media Files on S3.
FAQ
How should Django generate thumbnails in production?
In background tasks after upload, or through an image CDN, not on first page view.
Should I serve WebP or AVIF?
Both are much smaller than JPEG. Serve WebP widely and AVIF where supported, with a JPEG fallback.
What is srcset for?
It lists image files at different widths so the browser can choose the right one for the screen.
Can a CDN resize images for me?
Yes. Image CDNs resize and convert formats from URL parameters, so Django only stores originals.