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.

An image pipelineUpload originalstored onceCelery tasksizes + WebP/AVIFCDNcached variantssrcset in HTMLright size per screenResize outside requests, serve modern formats from a CDN, and let browsers pick the size.
  1. Store originals in object storage; never resize inside the request that serves a page.
  2. Generate fixed renditions (for example 320, 640, 1280 px wide) in a Celery task after upload.
  3. Encode WebP (and optionally AVIF) alongside JPEG fallbacks; strip metadata and apply EXIF orientation.
  4. Render responsive srcset and sizes so browsers download the right width; set width and height to avoid layout shift.
  5. Serve media through a CDN with long cache lifetimes and versioned filenames.
  6. 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).

bytes per card image (illustrative)original phone JPEG3800 KB640 px JPEG q8095 KB640 px WebP q8060 KB320 px WebP via srcset22 KB0 KB2000 KB4000 KBRight-sized modern formats cut image weight by two orders of magnitudedjango-deployment.com
Illustrative sizes; actual savings depend on the images.

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.

fast images from DjangoRenditions built in a background tasknever in the page requestWebP or AVIF with JPEG fallbackmetadata stripped, EXIF rotatedsrcset, sizes, width, heightright size, no layout shiftCDN with immutable cache headersversioned filenamesHero image not lazy-loadedhelps LCPImages are usually the heaviest part of a page; fixing them moves LCP mostdjango-deployment.com
Check the result with Lighthouse or WebPageTest on a mobile profile.

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

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.

Keep going