DEV Community

Cover image for Make WordPress do less: offloading media to S3 and the edge
Jaafar Abazid for ManTek Technologies

Posted on • Originally published at mantek.io

Make WordPress do less: offloading media to S3 and the edge

A default WordPress install does a surprising amount of work every time someone
uploads an image. It generates a stack of resized copies on the spot (thumbnail,
medium, medium_large, large, a couple of extra-large sizes, plus whatever the
theme registers), writes every one of them to disk, and records them in the
attachment metadata. Most are never requested. The CMS we replaced for one
newsroom generated more than twenty versions of every single image and stored
all of them. Multiply that across an archive of hundreds of thousands of photos
and you are paying, in storage and in upload-time CPU, for work nobody asked for.

Video is the opposite extreme. A single original broadcast clip can run to several
gigabytes, and pushing that upload through the web server (PHP memory limits,
request timeouts, a reverse proxy in the middle) is a reliable way to fall over.

The media pipeline we run for NUZ takes both problems off the server. On
the way in, the browser uploads large files straight to S3. On the way out, the
edge produces exactly the image sizes that are actually requested, and nothing
else. WordPress goes back to doing what it is good at, which is editorial, not
image processing. This is the same WordPress-on-AWS work we have written about
surviving breaking-news spikes
with, viewed from the storage side.

The shape of it

Two ideas carry the whole design:

  1. Store little, derive on demand. For images, keep two copies in S3: the pristine original in cold storage, and one working master in hot storage. Every size a visitor sees is produced on the fly at the CDN and lives only in the CDN cache, never in S3.
  2. Move the heavy lifting to the ends. Large uploads go from the browser directly to S3. Transformations happen at the edge. The origin server stays out of the byte path in both directions.

Everything below is an application of those two rules, plus the WordPress glue
that makes them invisible to editors.

The media pipeline: one upload, two copies, every size from the edge
One upload becomes two stored objects: a cold original you keep, and a hot working master the edge resizes on demand. Every size a visitor sees is produced by the Image Handler and cached at the CDN, never stored in S3.

Stop WordPress from making thumbnails

This is the change that surprises people, so it goes first: we turn off
WordPress's intermediate image generation entirely. If the edge can produce any
size on request, pre-generating a fixed menu of sizes on upload is pure waste:
CPU and I/O at the worst possible moment (while the editor waits), and a pile of
files that mostly never get served.

In the NUZ theme it is a couple of filters:

// Stop WordPress generating intermediate sizes on upload.
add_filter( 'intermediate_image_sizes_advanced', '__return_empty_array' );

// Keep the true original: don't let WordPress downscale large uploads to 2560px.
add_filter( 'big_image_size_threshold', '__return_false' );
Enter fullscreen mode Exit fullscreen mode

The one catch worth planning for: once WordPress stops creating named sizes,
any code that asks for the_post_thumbnail( 'medium' ) or a registered size has
to resolve to the edge instead. So the same theme layer maps each requested size
to an edge URL with the right dimensions. Skip that step and you get broken
images where a medium used to be. Handle it once, in the hooks that build image
URLs, and editors never notice the difference.

Resize at the edge, not on upload

The hot working master is the only image S3 serves to the transformer, and the
transformer is the AWS Serverless Image
Handler
:
CloudFront in front of a Lambda that runs Sharp. A request describes the
transformation it wants in the URL, the Lambda produces it once, and CloudFront
caches the result. The URL form is configurable; the Thumbor-style path is easy
to read:

https://cdn.example.com/fit-in/800x0/filters:format(webp):quality(80)/20250727-1753632511534.jpg
Enter fullscreen mode Exit fullscreen mode

Three things matter here:

  • Format negotiation. Ask for format(auto) and modern browsers get AVIF or WebP by their Accept header, older ones get JPEG, from one master.
  • Compute only on a miss. The Lambda runs the first time a given size is requested. After that CloudFront serves it. Sizes nobody requests cost nothing.
  • Nothing extra in S3. Derivatives live in the CDN cache, not the bucket. The bucket holds two objects per image, forever.

Two copies, two storage classes

The original and the master have different jobs, so they get different homes:

Copy Storage tier Why
Original (full-resolution, as shot) Cold ยท S3 Glacier Instant Retrieval Rarely read, but you may need it fast one day; millisecond retrieval at a fraction of Standard's price
Working master (high-quality, max served dimension) Hot ยท S3 Standard The transformer's source; read often, downscaled to every served size

A lifecycle policy moves originals to the cold class automatically. We avoid
Glacier Deep Archive precisely because retrieval there takes hours, and the whole
point of keeping the original is to be able to re-derive from it. The master is not the raw original. It is a web-optimised copy: sized to the
largest dimension you will ever serve (no 4K masters when nothing on the page ever
renders past, say, 2048px) and stored in an efficient format (WebP for images,
WebM for video). Optimise the dimensions and the format, but keep the quality
near-lossless, so every derivative downscales from a clean source rather than
inheriting compression artefacts.

Clean keys, by generation not by accident

We never store a file under the name the user uploaded. On upload we generate an
ASCII-safe, time-based key with a millisecond stamp and a short random suffix,
something like:

20250727-1753632511534
Enter fullscreen mode Exit fullscreen mode

The generator is small: a UTC date, a millisecond timestamp, and a short random
suffix against same-millisecond collisions, with the extension carried over:

function nuz_media_key( $ext ) {
    $stamp = gmdate( 'Ymd' ) . '-' . (int) round( microtime( true ) * 1000 );
    return $stamp . '-' . wp_generate_password( 6, false, false ) . '.' . strtolower( $ext );
}
// e.g. 20250727-1753632511534-h7k2qp.jpg
Enter fullscreen mode Exit fullscreen mode

For an Arabic newsroom this is not cosmetic. Original filenames arrive full of
Arabic characters and spaces, which become percent-encoded mojibake the moment
they enter a URL or an object key, the same class of encoding trap we dissected
in the 200-byte slug problem.
Generating the key sidesteps all of it: keys stay short, ASCII, and predictable.

There is a quiet bonus. Because every upload gets a unique key, replacing an
asset produces a new URL
, so there is no stale-cache problem to solve at the
CDN. You never have to invalidate; the old object simply stops being referenced.
Clean keys and cache-busting turn out to be the same decision.

The WordPress glue

None of this should leak into the editing experience, and the seam that keeps it
invisible is URL generation. The NUZ theme overrides WordPress's default asset
handling with hooks rather than rewriting the database, so the content stays
portable and the behaviour is reversible. The functions that build image URLs
(wp_get_attachment_url, wp_get_attachment_image_src,
wp_calculate_image_srcset and friends) return edge URLs at render time. In
practice that is one filter per entry point, each mapping the requested size to a
transformation the edge understands:

// Point a requested size at the Serverless Image Handler instead of a local file.
add_filter( 'wp_get_attachment_image_src', function ( $image, $id, $size ) {
    if ( ! $image ) {
        return $image;
    }
    [ $w, $h ] = nuz_dimensions_for( $size );     // 'medium' => [800, 0], etc.
    $key = get_post_meta( $id, '_wp_attached_file', true );
    $image[0] = sprintf(
        'https://cdn.example.com/fit-in/%dx%d/filters:format(auto):quality(80)/%s',
        $w, $h, $key
    );
    return $image;
}, 10, 3 );
Enter fullscreen mode Exit fullscreen mode

๐Ÿ“ฆ Prefer it ready-made? The image-delivery half of this (disabling intermediate sizes and rewriting image URLs to the Image Handler, with a synthesized srcset) is open-source as mantekio/wp-edge-images, or composer require mantekio/wp-edge-images. It is URL layer only, so it pairs with any S3 offload.

If you are migrating an existing library, one caveat is worth stating plainly:
the URLs that actually serve images live in post_content (embedded when an
image is inserted) and are generated from the _wp_attached_file meta, not in
the guid column. The guid looks like the file URL but is an identifier, and
WordPress documentation is explicit that you should not rewrite it. So a one-time
migration targets post_content; ongoing serving is best handled with the
runtime hooks above.

Big video: let the browser do the upload

Images are small and cheap to transform, so we generate them on demand. Video is
neither, so it plays by opposite rules.

The problem is the upload itself. Original television files are large, and they
should not travel through WordPress at all. So the editor works in a custom
upload screen that asks our admin endpoint for a short-lived presigned S3 URL,
then sends the file from the browser straight to the bucket:

// 1) ask the server for a presigned URL, scoped to one key + content type, short-lived
const { url, key } = await fetch('/nuz/presign?type=video/mp4').then(r => r.json());

// 2) PUT the file directly to S3 from the browser, with a real progress bar
await putWithProgress(url, file, onProgress); // XHR upload events drive the bar
Enter fullscreen mode Exit fullscreen mode

On the server the presign is a few lines with the AWS SDK, scoped to one key, one
content type, and a short expiry:

$s3  = new Aws\S3\S3Client( [ 'region' => 'me-central-1', 'version' => 'latest' ] );
$key = 'videos/' . nuz_media_key( 'mp4' );
$req = $s3->createPresignedRequest(
    $s3->getCommand( 'PutObject', [
        'Bucket'      => 'nuz-media-hot',
        'Key'         => $key,
        'ContentType' => 'video/mp4',
    ] ),
    '+15 minutes'
);
wp_send_json( [ 'url' => (string) $req->getUri(), 'key' => $key ] );
Enter fullscreen mode Exit fullscreen mode

The server signs a tightly scoped, expiring URL and never touches the bytes.
The browser handles the transfer, the upload screen shows a progress bar (a
multi-gigabyte broadcast file can take a while, and editors need to see it
moving), and the same screen collects the title, the cover image, and the rest of
the metadata. From there, one click distributes the video to YouTube, Dailymotion
or Vimeo, streamed directly from S3 to each platform's resumable-upload API. The
editor never uploads again: the file already lives in S3, so distribution is a
server-to-server transfer, not a second upload of the file. The newsroom publishes
once and distributes everywhere, without a gigabyte ever passing through PHP.

For on-site playback you extend the same pattern: an S3 upload event triggers
AWS Elemental MediaConvert, which transcodes the source into adaptive-bitrate
renditions (HLS or DASH) plus a poster frame, stores the renditions in the hot
bucket, and serves them through CloudFront to an hls.js player. Note the
inversion from images: video transformation is expensive, so you do it once on
upload and store the result
, rather than per request. Storing little works for
images precisely because resizing is cheap; storing renditions works for video
precisely because transcoding is not.

Images and video play by opposite rules
Images are cheap to resize, so the edge generates each size on demand and stores nothing extra. Video is expensive to transcode, so you do it once on upload and store the renditions. Opposite strategies, same bucket.

What it saves

No hard numbers to share here, but the shape of the win is consistent:

  • Storage. Two image objects instead of twenty-plus. Originals on a cold class that costs a fraction of Standard.
  • Server load. No thumbnail jobs competing for CPU on every upload, and no multi-gigabyte uploads occupying PHP workers, memory and connections.
  • Delivery. Variants are produced once at the edge and cached, so origin and Lambda costs scale with unique sizes requested, not with traffic.

The pattern is "let each layer do what it is best at": the browser moves bytes,
S3 stores them, the edge transforms them, MediaConvert handles the expensive
video work, and WordPress orchestrates editorial without carrying the files.

Gotchas worth knowing

  • Lock the buckets. Neither bucket is public. CloudFront reads via Origin Access Control; the transformer reads via its execution role. Public buckets are how media libraries leak.
  • Presigned uploads need guardrails. Scope each URL to a single key, a content type and a short expiry, and configure S3 CORS for the admin origin. A presigned URL is a capability; keep it narrow and short-lived.
  • Don't compound compression. A lossy working master makes every derivative worse. Keep the master near-lossless.
  • Mind the named-size mapping. The day you disable WordPress sizes, audit the theme and plugins for hard-coded size names and make sure they resolve to the edge.

The deeper point

The instinct with a media library is to make the CMS responsible for everything:
storing, resizing, naming, serving. The leaner design is to make the CMS
responsible for almost none of it, and to let purpose-built layers each take one
job. WordPress stays fast and boring, which is exactly what you want from the
system your newsroom logs into every day.

This is the kind of architecture we build and run on
WordPress + AWS. If you are wrestling a heavy media library on
WordPress, let's talk.

Further reading

Top comments (0)