DEV Community

Szabo Istvan
Szabo Istvan

Posted on

How I Solved Lazy Image Resizing Without Slowing Down the Page Load

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>
Enter fullscreen mode Exit fullscreen mode

This assumes that all those resized images already exist, which typically means generating them during upload.

But this creates two problems:

  1. Uploads become slower because multiple image sizes must be generated immediately.
  2. 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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Example:

Original:      /uploads/photos/my-image.jpg
Generated URL: /uploads/photos/480x480/my-image.webp?key=a1b2c3
Enter fullscreen mode Exit fullscreen mode

Three important things happen here.

1. Size Encoded in the Path

/uploads/photos/480x480/my-image.webp
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

...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();
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

Nginx:

location ~* \.(webp|jpg|jpeg|png|gif)$ {
    expires max;
    add_header Cache-Control "public, immutable";
    try_files $uri /index.php?$query_string;
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Template outputs srcset URLs
  2. Browser requests resized image
  3. File doesn't exist → application generates resized image
  4. 302 redirect → browser receives static file

Second Request:

  1. File exists → web server serves it directly
  2. No application code involved

Returning Visitor:

  1. 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)