DEV Community

Cover image for How I Shaved 10 MB Off My Portfolio in One Command
Vicente G. Reyes
Vicente G. Reyes

Posted on • Originally published at vicentereyes.org

How I Shaved 10 MB Off My Portfolio in One Command

PageSpeed Insights had been staring at me for weeks. Desktop was holding at 91. Mobile was stuck at 63. I'd already fixed the obvious stuff — non-blocking fonts, preconnects, fetchpriority on the hero image. But there it was, every single run:

Improve image delivery — Est savings of 985 KiB

Nearly a megabyte of wasted transfer, just from six project screenshots. And that was just the images visible above the fold. The full list across all projects was worse.

The culprit: every image I'd ever uploaded through the Django admin was a PNG. Some of them were over 1 MB. WebP would have cut most of them by 80%. I knew this. I just hadn't done anything about it.

So I wrote a management command to fix the backlog, and then made the model auto-convert on every future upload so I'd never have to think about it again.


The Problem With PNGs in a Portfolio

When you're building a portfolio, you screenshot your work and drag it into the admin. That screenshot is usually a PNG — lossless, full-size, straight from your display. Nobody optimises it because the admin accepts it and it shows up fine in the browser.

But "shows up fine" isn't the same as "loads fast." A 1.4 MB PNG of a law firm homepage does not need to be 1.4 MB. Served as WebP at quality 85, it's 175 KB. Same visual result. Eight times smaller.

Multiply that across 28 projects and you're looking at tens of megabytes that mobile users on slow 4G are downloading just to scroll past thumbnails.


The One-Time Backlog Fix: A Management Command

First, I needed a way to convert everything that was already in S3. A management command was the right tool — it runs in the production container with full access to the Django ORM and the configured storage backend, so it can read and rewrite files without needing to know whether they're on S3, local disk, or anywhere else.

# backend/projects/management/commands/convert_images_to_webp.py

from io import BytesIO

from django.core.files.base import ContentFile
from django.core.management.base import BaseCommand
from PIL import Image

from backend.projects.models import AudioWork, Project


def _to_webp(field_file, quality: int = 85) -> tuple[ContentFile, str] | None:
    try:
        with field_file.open("rb") as f:
            img = Image.open(f)
            img.load()
    except OSError as exc:
        return None, str(exc)

    mode = "RGBA" if img.mode in ("RGBA", "LA", "P") else "RGB"
    img = img.convert(mode)

    buf = BytesIO()
    img.save(buf, format="WEBP", quality=quality, method=6)
    buf.seek(0)

    old_name = field_file.name
    new_name = old_name.rsplit(".", 1)[0] + ".webp"
    return ContentFile(buf.read(), name=new_name), new_name


class Command(BaseCommand):
    help = "Convert project and audio work images stored in S3 to WebP format."

    def add_arguments(self, parser):
        parser.add_argument("--quality", type=int, default=85)
        parser.add_argument("--dry-run", action="store_true")
        parser.add_argument("--skip-existing", action="store_true", default=True)

    def _convert_field(self, obj, field_name, quality, dry_run, skip_existing):
        field = getattr(obj, field_name)
        if not field:
            return "skip-empty"

        name = field.name or ""
        if skip_existing and name.lower().endswith(".webp"):
            return "skip-webp"

        webp_file, new_name = _to_webp(field, quality)
        if webp_file is None:
            self.stderr.write(f"    ERROR: {name}: {new_name}")
            return "error"

        old_size = field.size
        new_size = webp_file.size

        if dry_run:
            savings = old_size - new_size
            self.stdout.write(
                f"    [dry-run] {name}{new_name} "
                f"({old_size // 1024} KiB → {new_size // 1024} KiB, "
                f"saves {savings // 1024} KiB)"
            )
            return "would-convert"

        field.delete(save=False)
        getattr(obj, field_name).save(new_name.split("/")[-1], webp_file, save=True)
        self.stdout.write(
            self.style.SUCCESS(f"{name}{new_name}")
        )
        return "converted"

    def handle(self, *args, **options):
        quality = options["quality"]
        dry_run = options["dry_run"]
        skip_existing = options["skip_existing"]

        for project in Project.objects.exclude(image="").exclude(image__isnull=True):
            self.stdout.write(f"  {project.title}")
            self._convert_field(project, "image", quality, dry_run, skip_existing)

        for work in AudioWork.objects.exclude(cover_image="").exclude(cover_image__isnull=True):
            self.stdout.write(f"  {work.title}")
            self._convert_field(work, "cover_image", quality, dry_run, skip_existing)
Enter fullscreen mode Exit fullscreen mode

The logic is straightforward: open the file from storage, convert to WebP with Pillow, delete the original, save the new one. The --dry-run flag prints what would happen without touching anything — which I always run first in production.

The Pillow mode handling is worth noting

WebP supports transparency, so if the source image is RGBA (PNG with alpha channel) or has a palette with transparency, I keep it in RGBA mode. Everything else gets converted to RGB first. Trying to save a palette-mode image directly to WebP will throw an error.

mode = "RGBA" if img.mode in ("RGBA", "LA", "P") else "RGB"
img = img.convert(mode)
Enter fullscreen mode Exit fullscreen mode

Running it

# See what will happen without writing anything
docker compose -f docker-compose.production.yml run --rm django \
  python manage.py convert_images_to_webp --dry-run

# Run for real
docker compose -f docker-compose.production.yml run --rm django \
  python manage.py convert_images_to_webp
Enter fullscreen mode Exit fullscreen mode

The dry run output on my portfolio:

Project: Leagogo Law Firm
  [dry-run] projects/LAA_Law.png → projects/LAA_Law.webp (1423 KiB → 175 KiB, saves 1247 KiB)
Project: Flower Head Events
  [dry-run] projects/Flower_Head_Events.png → projects/Flower_Head_Events.webp (1187 KiB → 83 KiB, saves 1103 KiB)
Project: HSM Homes
  [dry-run] projects/HSM_Homes.png → projects/HSM_Homes.webp (1117 KiB → 92 KiB, saves 1024 KiB)
Project: Big Boy Shawarma
  [dry-run] projects/BigBoyShawarma.png → projects/BigBoyShawarma.webp (898 KiB → 43 KiB, saves 855 KiB)
...
Would convert: 30
Enter fullscreen mode Exit fullscreen mode

30 images. Thousands of KiB saved. One command.


The Permanent Fix: Auto-Convert on Upload

The command handles the backlog. But what about the next project I add? I don't want to run the command manually every time I upload a screenshot.

The fix is in the model's save() method. After the original file is written to storage, detect if it needs conversion and replace it in place:

# backend/projects/models.py

from io import BytesIO
from django.core.files.base import ContentFile
from django.db import models
from PIL import Image


def _convert_to_webp(field_file, quality: int = 85) -> ContentFile | None:
    try:
        with field_file.open("rb") as fh:
            img = Image.open(fh)
            img.load()
    except OSError:
        return None

    mode = "RGBA" if img.mode in ("RGBA", "LA", "P") else "RGB"
    img = img.convert(mode)

    buf = BytesIO()
    img.save(buf, format="WEBP", quality=quality, method=6)
    buf.seek(0)

    filename = field_file.name.rsplit("/", 1)[-1].rsplit(".", 1)[0] + ".webp"
    return ContentFile(buf.read(), name=filename)


def _save_as_webp(instance, field_name: str) -> None:
    field = getattr(instance, field_name)
    if not field or str(field.name).lower().endswith(".webp"):
        return
    webp = _convert_to_webp(field)
    if webp is None:
        return
    field.delete(save=False)
    getattr(instance, field_name).save(webp.name, webp, save=True)


class Project(models.Model):
    image = models.ImageField(upload_to="projects/", blank=True, null=True)
    # ... other fields

    def save(self, *args, **kwargs):
        updating_image = not kwargs.get("update_fields") or "image" in (
            kwargs.get("update_fields") or []
        )
        super().save(*args, **kwargs)
        if updating_image:
            _save_as_webp(self, "image")
Enter fullscreen mode Exit fullscreen mode

The important detail: super().save() runs first. That writes the original file to storage. Then _save_as_webp opens it, converts it, deletes the original, and saves the WebP. Yes, this means two writes to S3 per upload — but uploads happen rarely and through the admin, so the overhead is irrelevant. The read path, which runs on every page load, now always gets WebP.

The update_fields check prevents unnecessary conversion when saving unrelated fields. If someone updates just the project title from the admin, the image field is left alone.


The Result

After running the command and deploying the model change:

  • 30 existing images converted from PNG to WebP
  • Average reduction: ~80% per image
  • Largest single saving: 1,247 KiB (a 1.4 MB PNG → 175 KB WebP)
  • PageSpeed Insights no longer flags "Improve image delivery" for S3-hosted images
  • Every future upload auto-converts — no manual intervention needed

The mobile performance score still has room to improve (the mobile LCP is dominated by React's JS parse time, which is a different problem). But knocking nearly 10 MB of unnecessary image weight off the page is a clean, permanent win.


What I'd Do Differently

If I were starting fresh, I'd use an ImageField subclass that intercepts the file before it's written — cleaner than the post-save dance. But overriding save() is pragmatic and works perfectly fine with Django admin, DRF serializers, and anything else that goes through the ORM.

I'd also consider storing images at display dimensions rather than full screenshot resolution. Even as WebP, a 1137×651 image served at 399×190 is wasting pixels. A future create_thumbnail step alongside the WebP conversion would push things further. That's next.

Top comments (0)