For months, PageSpeed Insights highlighted a stubborn bottleneck in my portfolio: unoptimized images were wasting nearly a megabyte of bandwidth on every visit. Despite fixing fonts, preconnects, and hero images, the mobile score remained stuck at 63. The issue wasn’t the obvious culprits—it was the portfolio’s own screenshots, saved as bloated PNGs in the Django admin.
Desktop performance hovered at 91, but mobile users on slow 4G were downloading tens of megabytes just to scroll past thumbnails. Each PNG was a silent performance tax, especially when 28 projects multiplied the problem. The solution was simple in theory—switch to WebP—but automating the fix required a more thoughtful approach.
Why PNGs are quietly sabotaging your portfolio
When you screenshot a website for your portfolio, the default format is PNG. It’s lossless, preserves every pixel, and works everywhere. But "works" isn’t the same as "efficient." A 1.4 MB PNG of a law firm homepage might look identical at 175 KB in WebP at 85% quality. That’s an 88% reduction in file size—without any visible trade-off.
Multiply that across 30 images, and the savings add up fast. Instead of forcing mobile users to download 30 MB of screenshots, they get the same visual experience in under 4 MB. For visitors on metered data plans or spotty connections, that’s the difference between a frustrating wait and an instant load.
Building a one-command fix for your backlog
A management command was the right tool for the job. It runs directly in the production environment with full access to Django’s ORM and storage backend, allowing it to read and rewrite files without worrying about whether they’re stored locally or in S3. No external scripts, no manual downloads—just a single command that handles the entire backlog in one pass.
Here’s how the conversion logic works:
- Open the image file from storage.
- Convert it to WebP using Pillow, preserving transparency when needed.
- Delete the original file and save the WebP version in its place.
- Skip files that are already WebP or empty to avoid redundant work.
The command includes a --dry-run flag to preview changes before committing, which I always use in production first. Here’s the implementation:
# 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)Key considerations when converting images
WebP supports transparency, so the script preserves RGBA mode for PNGs with alpha channels. If the source image uses a palette with transparency, it’s converted to RGBA before saving to avoid errors. This ensures logos, overlays, and screenshots with transparent backgrounds remain intact.
The --quality flag lets you adjust compression, with 85 being a good balance between size and quality. Higher values yield better images but larger files, while lower values risk visible artifacts.
Running the command in production
Before making any changes, I ran a dry run to preview the impact:
docker compose -f docker-compose.production.yml run --rm django \
python manage.py convert_images_to_webp --dry-runThe output showed 30 images that would be converted, with savings ranging from 400 KB to 1.2 MB per file. For example:
- Leagogo Law Firm: 1,423 KiB → 175 KiB (saves 1,247 KiB)
- Flower Head Events: 1,187 KiB → 83 KiB (saves 1,103 KiB)
- HSM Homes: 1,117 KiB → 92 KiB (saves 1,024 KiB)
After confirming the results, I executed the command for real:
docker compose -f docker-compose.production.yml run --rm django \
python manage.py convert_images_to_webpThe process completed in minutes, converting every outdated PNG to WebP without manual intervention.
Automating the future: converting uploads in real time
The management command solved the backlog, but what about future uploads? Adding a custom save method to the model ensures every new image is converted automatically when uploaded. This way, the portfolio stays optimized without requiring periodic maintenance.
With this setup, the performance bottleneck is gone. Mobile scores improved significantly, and visitors no longer pay a bandwidth penalty just to view a portfolio. The lesson isn’t just about image formats—it’s about building systems that handle tedious optimizations so you don’t have to.
AI summary
Portföyünüzün hızını iyileştirmek için görsellerinizi WebP formatına dönüştürün ve kullanıcılarınızın daha iyi bir deneyim yaşamasını sağlayın