The moment user-uploaded images need to appear in more than one place (a thumbnail grid, a product preview, a hero banner) you need multiple sizes. The standard approach generates all variants at upload time, but this slows uploads, wastes disk space on sizes that are never requested, and forces a full library reprocess whenever your layout changes.
Most image sizes will never actually be requested. Generating them eagerly is wasteful.
The solution I landed on: store only the original, and use the web server's 404 handler to trigger resizing on demand. Once a size is generated, it's saved to disk and served as a static file forever. No application code is involved on repeat visits.
The Problem
The correct image size depends entirely on your UI, and those sizes change whenever your layout changes. The standard solution is responsive <picture> elements with srcset and sizes attributes:
<picture>
<source
srcset="/uploads/photos/480x480/my-image.webp 480w,
/uploads/photos/640x640/my-image.webp 640w,
/uploads/photos/1920x1920/my-image.webp 1920w"
sizes="(max-width: 576px) 480px, (max-width: 992px) 640px, 1920px"
type="image/webp">
<img src="/uploads/photos/640x640/my-image.webp"
width="640" height="640"
loading="lazy">
</picture>
This assumes that all those resized images already exist, which typically means generating them during upload.
But this creates two problems:
- Uploads become slower because multiple image sizes must be generated immediately.
- Many generated sizes are never actually used.
Pre-generating every possible size for every possible layout just isn't practical.
What I wanted instead was something like this: declare the sizes you need, and let them materialize on demand.
To make this work safely and efficiently, the system had to satisfy a few constraints:
- Uploads must remain fast
- Page loads must not block on image resizing
- Only requested sizes should be generated
- Arbitrary resize requests must be impossible
- Generated images must become permanent static files
The architecture that ended up satisfying all of these requirements is what I call lazy-load image resizing.
The Architecture: Lazy-Load Image Resizing
The core idea is simple: generate resized images only when they are first requested.
But instead of creating an explicit resize endpoint, the trigger is a missing file (404).
Here's the full lifecycle:
Browser requests:
/uploads/photos/480x480/my-image.webp?key=a1b2c3
Web server checks: Does this file exist?
├── YES → Serve static file
└── NO → Pass request to application
→ Validate resize pattern + security key
→ Locate original image
→ Generate resized version
→ Save to disk
→ 302 redirect to the now-existing file
The key property is this: after the first request, the application is never involved again. The web server serves the resized file directly like any other static asset.
Step 1: Smart URL Generation with Signed Keys
The first step is a helper that generates the URL where the resized image would exist, even if it hasn't been generated yet.
function img_url(string $path, int $width, int $height): string {
$resizedUrl =
dirname($path)
. "/{$width}x{$height}/"
. pathinfo($path, PATHINFO_FILENAME)
. '.webp';
$key = hash_hmac('sha256', $resizedUrl, $yourSecretKey);
return $resizedUrl . '?key=' . substr($key, 0, 16);
}
Example:
Original: /uploads/photos/my-image.jpg
Generated URL: /uploads/photos/480x480/my-image.webp?key=a1b2c3
Three important things happen here.
1. Size Encoded in the Path
/uploads/photos/480x480/my-image.webp
The directory contains the dimensions. This makes the filesystem naturally act as a cache.
2. Format Conversion
All resized images are saved as WebP, regardless of the original format. This typically reduces file size by 25–35% compared to JPEG.
3. Signed URLs
The HMAC signature prevents malicious resize requests. Without signing, someone could request:
/uploads/photos/99999x99999/image.webp
...and force your server to process huge images. Signing ensures:
- Only your application can generate valid resize URLs
- Width/height tampering invalidates the request
Step 2: The 404 Handler
When the browser requests an image that doesn't exist yet, the web server forwards the request to your application. Your 404 handler detects whether the request is a resize request.
function handle404(Request $request)
{
$path = $request->getPath();
$ext = pathinfo($path, PATHINFO_EXTENSION);
if (!in_array($ext, ['jpg','jpeg','png','webp'])) {
return show404();
}
if (!preg_match('/\/(\d+)x(\d+)\//', dirname($path), $matches)) {
return show404();
}
$expectedKey = hash_hmac('sha256', $path, $yourSecretKey);
if (substr($expectedKey, 0, 16) !== $request->getQuery('key')) {
return show404();
}
$width = (int) $matches[1];
$height = (int) $matches[2];
$originalPath = str_replace("/{$width}x{$height}", '', $path);
foreach (['jpg','jpeg','png','webp'] as $tryExt) {
$candidate = preg_replace('/\.\w+$/', ".{$tryExt}", $originalPath);
if (file_exists($candidate)) {
resize_image($candidate, $path, $width, $height);
return redirect($path);
}
}
return show404();
}
Key details:
- Invalid signatures return a normal 404
- Original format is automatically detected
- After resizing, the server issues a 302 redirect
Step 3: The Resize Function
The actual resizing logic can use any image library. Here's an example using Intervention Image:
function resize_image(
string $source,
string $destination,
int $width,
int $height
): void {
adjust_memory_for_image($source);
$img = ImageManager::read($source);
$img->scaleDown($width, $height);
@mkdir(dirname($destination), recursive: true);
$img
->toWebp(quality: 85)
->save($destination);
}
Key decisions:
-
scaleDown()prevents upscaling - Everything is converted to WebP
Step 4: Web Server Caching
Once generated, the resized file should behave like immutable static content.
Apache:
<filesMatch "\.(webp|jpg|jpeg|png|gif)$">
Header set Cache-Control "max-age=84600000, public, immutable"
</filesMatch>
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ index.php/$1 [L,QSA]
Nginx:
location ~* \.(webp|jpg|jpeg|png|gif)$ {
expires max;
add_header Cache-Control "public, immutable";
try_files $uri /index.php?$query_string;
}
The key directive is immutable. This tells the browser: this file will never change at this URL. So returning visitors often don't even make a network request.
The Complete Flow
First Request:
- Template outputs
srcsetURLs - Browser requests resized image
- File doesn't exist → application generates resized image
- 302 redirect → browser receives static file
Second Request:
- File exists → web server serves it directly
- No application code involved
Returning Visitor:
- Image already cached → no network request at all
Performance Comparison
| Aspect | Traditional | Lazy Resize |
|---|---|---|
| Upload time | Slow | Fast |
| First page load | Fast | One resize per size |
| Later page loads | Static | Static |
| Disk usage | All sizes | Only used sizes |
| Output format | Original | WebP |
| Security | Often open | Signed URLs |
Trade-offs
No solution is perfect.
Cache Cleanup: Replacing or deleting an image leaves resized variants on disk. Your upload/delete workflow should remove those directories.
First Request Penalty: The first visitor requesting a size triggers a resize. In practice this is usually sub-second, but it exists.
Disk Growth: Over time the cache directories accumulate generated images. A periodic cleanup job may help.
Key Takeaways
Let the web server do the heavy lifting. Once generated, images are served as static files.
404 handlers can be powerful. Missing files become a trigger for lazy-load generation.
Always sign resize URLs. Otherwise your resize endpoint becomes a DoS vector.
Convert images during resizing. This allows you to deliver modern formats like WebP automatically.
Keep uploads simple. Store the original image and generate everything else lazily.
In practice this pattern gives you the best of both worlds: fast uploads, minimal storage waste, and static-file performance after the first request. And the implementation is surprisingly small.
Top comments (0)