Nobody asked me to do this.
I maintain a WooCommerce store bolted to LearnDash courses on a Divi theme — eight years of content, a few thousand images. Page weight had been nagging at me for a while, so I put "convert the images to WebP" on my own list. No ticket, no client request, just a maintenance item I thought would take an afternoon.
I almost installed ShortPixel and called it a day.
Instead I spent twenty minutes auditing first, and found that WebP was maybe the fourth most important thing wrong with how that site served images. This is what the audit turned up, and why "just install an optimizer plugin" would have made things worse.
Always inventory before you optimize
The site runs on a 4GB VPS that had already OOM-killed MySQL twice that quarter. Bulk image transcoding is CPU and memory heavy. So before touching anything:
cd wp-content/uploads
du -sh .
echo "JPEG: $(find . -type f \( -iname '*.jpg' -o -iname '*.jpeg' \) | wc -l)"
echo "PNG: $(find . -type f -iname '*.png' | wc -l)"
echo "WebP: $(find . -type f -iname '*.webp' | wc -l)"
The output stopped me cold:
2.1G .
JPEG: 3245
PNG: 1443
WebP: 12301
Twelve thousand WebP files on a site that supposedly had no WebP.
Finding 1: 241MB of files nothing was serving
The naming told the story — image.jpg.webp, sidecar files in the same directory as the original. That's EWWW Image Optimizer's convention.
EWWW was not installed. Somebody had run it, then removed the plugin.
When EWWW generates sidecars, delivery depends on either an .htaccess rewrite doing Accept-header negotiation, or a <picture> element filter. Both live in the plugin. Remove the plugin and the files become inert — 241MB of dead weight, still being scanned by the server's malware scanner on every pass, still counted in every backup.
Verified they were genuinely unreferenced before deleting:
# no content references
wp db query "SELECT COUNT(*) FROM wp_posts WHERE post_content LIKE '%.jpg.webp%';"
# no attachment metadata references
wp db query "SELECT COUNT(*) FROM wp_postmeta WHERE meta_value LIKE '%.jpg.webp%';"
# no rewrite rule anywhere
grep -in webp .htaccess ~/conf/*
Three zeros. Deleted, 241MB back.
Lesson: image optimizer plugins leave their output behind when uninstalled. If you inherit a site, grep the uploads directory for orphaned formats before you add another optimizer to the pile.
Finding 2: the size ladder had been demolished
This is the one that mattered.
wp eval 'print_r( get_intermediate_image_sizes() );'
Array
(
[3] => large
[16] => gform-image-choice-sm
[17] => gform-image-choice-md
[18] => gform-image-choice-lg
)
Four registered sizes. No thumbnail, no medium, no medium_large. And critically — no woocommerce_thumbnail, no woocommerce_gallery_thumbnail, none of Divi's.
Look at the array keys. They're non-sequential: 3, 16, 17, 18. The array originally held 0 through 18 and something had unset() entries without reindexing. That's a filter stripping sizes at runtime, not sizes that were never registered.
The culprit was a plugin called disable-thumbnails-and-threshold, hooking intermediate_image_sizes at priority 100, with eighteen sizes checked off in its settings. A previous dev had installed it to fight disk pressure. Understandable instinct, catastrophic execution.
Why this is worse than it sounds
WooCommerce still believed it had a 300×300 thumbnail size:
wp eval 'print_r( wc_get_image_size("woocommerce_thumbnail") );'
# => width 300, height 300, crop 1
The size was registered in WooCommerce's config, but the files were never generated. WooCommerce ships WC_Regenerate_Images, which hooks wp_get_attachment_image_src and resizes on the fly when a requested size is missing.
I counted actual coverage:
find wp-content/uploads -type f \( -iname '*-300x300.jpg' -o -iname '*-300x300.png' \) | wc -l
# => 141
141 files out of 2,216 attachments. Six percent coverage.
Meaning: for roughly 94% of product image requests, WooCommerce was doing an on-the-fly Imagick resize. On a box the host had already flagged for CPU saturation.
And the srcset consequence: with only large (1024px) registered, every responsive image had exactly one candidate. A phone loading a product grid pulled 1024px files into 300px slots. WebP shaves ~30% off a file. Serving the right dimensions shaves 90%.
The format was never the problem. The missing size ladder was.
Finding 3: the images weren't the disk hog
Adding up the raster formats: 705MB PNG + 242MB WebP + 192MB JPEG = 1,139MB. Total uploads: 2.1GB.
Where was the other gigabyte?
find . -type f -printf '%f\n' | awk -F. 'NF>1{print tolower($NF)}' | sort | uniq -c | sort -rn | head
122 PDFs, 12 MP4s, 2 Keynote files. Course manuals and lesson videos — two videos alone were 170MB, and one training manual existed in four copies across two editions for 185MB.
If I'd gone in with "let's reclaim disk by optimizing images," I'd have spent a day of CPU to reclaim less than the duplicate PDFs were wasting.
The fix: three filters, no new plugins
I considered ShortPixel and Imagify. Both are good. Both were wrong here:
- They'd add ~17,000 API credits of cost for 2,216 attachments across 8 sizes
- They write sidecar files and need a delivery mechanism — the exact pattern that left 241MB of orphans when EWWW was removed
- They'd be plugin #50 on a site already carrying too many WordPress core has done this natively since 6.x. Three snippets, no dependencies.
1. Restore the size ladder
Rather than deactivating the disable-thumbnails plugin — which would swing from "no sizes" to "all nineteen sizes" and regenerate every unused Divi variant — I unchecked exactly five and left thirteen stripped:
$o = get_option( 'dtat_disablethumbnails_option_name', [] );
foreach ( [ 'thumbnail', 'medium', 'medium_large',
'woocommerce_thumbnail', 'woocommerce_gallery_thumbnail' ] as $k ) {
unset( $o[ $k ] );
}
update_option( 'dtat_disablethumbnails_option_name', $o );
Nine registered sizes instead of four or nineteen. The Divi et-pb-* sizes stay stripped — the theme's own bloat wasn't worth reintroducing.
2. WebP output for generated subsizes
add_filter( 'image_editor_output_format', function ( $formats, $filename, $mime_type ) {
if ( in_array( $mime_type, [ 'image/jpeg', 'image/png' ], true ) ) {
$formats[ $mime_type ] = 'image/webp';
}
return $formats;
}, 10, 3 );
add_filter( 'wp_editor_set_quality', function ( $quality, $mime_type ) {
return ( 'image/webp' === $mime_type ) ? 82 : $quality;
}, 10, 2 );
The critical property here: WordPress preserves the source file as original_image in attachment metadata. Every future regeneration encodes from the lossless master, not from the WebP. Verify it before you commit to a bulk run:
wp eval '$m = wp_get_attachment_metadata( 123 ); var_dump( $m["original_image"] ?? "ABSENT" );'
If that returns ABSENT, stop. You'd be setting up cumulative quality loss — every regeneration re-encoding lossy from lossy, degrading invisibly over months until it's baked into everything.
3. Rewrite hardcoded URLs at render time
This is the gotcha that nearly shipped broken.
image_editor_output_format governs what WordPress generates. It does nothing about URLs already written into post_content. A Gutenberg wp-block-image figure has the path baked into the block markup. So does a Divi shortcode. Regeneration doesn't touch stored markup.
I measured the exposure:
SELECT post_type, COUNT(*) AS n
FROM wp_posts
WHERE post_status IN ('publish','draft','inherit')
AND post_content REGEXP 'wp-content/uploads/[^"]+[.](png|jpe?g)'
GROUP BY post_type ORDER BY n DESC;
190 non-revision posts — including 25 Divi Library items and 3 Theme Builder layouts, which inject into many pages each. Left alone, most of the site's content images would have kept serving PNG while their WebP siblings sat unused on disk.
Fixed at render time rather than with a database search-replace:
add_filter( 'wp_content_img_tag', function ( $html, $context, $attachment_id ) {
if ( false === strpos( $html, '/wp-content/uploads/' ) ) {
return $html;
}
$dir = wp_get_upload_dir();
// Original filename -> current primary file, for -scaled attachments.
$orig_map = [];
if ( $attachment_id ) {
$meta = wp_get_attachment_metadata( $attachment_id );
if ( ! empty( $meta['original_image'] ) && ! empty( $meta['file'] ) ) {
$orig_map[ $meta['original_image'] ] = wp_basename( $meta['file'] );
}
}
static $cache = [];
return preg_replace_callback(
'#(/wp-content/uploads/[^\s"\']+?)\.(png|jpe?g)#i',
function ( $m ) use ( $dir, $orig_map, &$cache ) {
$key = $m[0];
if ( ! isset( $cache[ $key ] ) ) {
$rel = substr( $m[1], strlen( '/wp-content/uploads' ) );
$cache[ $key ] = file_exists( $dir['basedir'] . $rel . '.webp' );
}
if ( $cache[ $key ] ) {
return $m[1] . '.webp';
}
$base = wp_basename( $m[0] );
if ( isset( $orig_map[ $base ] ) ) {
return dirname( $m[1] ) . '/' . $orig_map[ $base ];
}
return $m[0];
},
$html
);
}, 20, 3 );
The -scaled trap
My first version of that filter was ten lines shorter — just the extension swap and the
file_exists() guard. It tested clean and shipped, and then I found this in a Divi module:
<img src=".../DrEdythe_7.2024.png"
srcset=".../DrEdythe_7.2024-scaled.webp 1831w,
.../DrEdythe_7.2024-215x300.webp 215w, ...">
Every srcset candidate WebP. The src still a 9.9MB PNG — the largest file on the site.
WordPress scales any upload over big_image_size_threshold (2560px by default) and makes the
scaled copy the primary file, keeping the upload as original_image. With WebP output enabled
you get foo-scaled.webp as primary and foo.png as the preserved master. There is no
foo.webp. So the naive swap looks for a file that will never exist, finds nothing, and
leaves the PNG in place — silently, on exactly the biggest images you most wanted to fix.
The fix is to stop guessing filenames. wp_content_img_tag passes the attachment ID, so
metadata can tell you the real current filename. Just remember , 20, 3 on the add_filter
call — without the argument count the ID never arrives and the branch is dead code.
Why render-time over wp search-replace:
- No database mutation. Rollback is deactivating a snippet.
-
It's conditional. It only rewrites when a real target exists — a
.webpsibling on disk, or a primary file named in attachment metadata. A blind.png→.webpsearch-replace would have broken every reference to the ~54 icons that were correctly never converted. -
It covers everything. Gutenberg blocks, Divi modules, classic editor content — all pass through
the_content. The per-request static cache keepsfile_exists()negligible, and page caching absorbs the rest.
Running the regeneration
The site had Regenerate Thumbnails installed with a perfectly good UI. I used WP-CLI anyway, for two reasons.
Disk visibility. The plugin runs until done or until the volume fills. The box was at 84%. Batching from CLI let me check headroom between chunks:
wp post list --post_type=attachment --post_mime_type=image/jpeg,image/png \
--format=ids --posts_per_page=-1 | tr ' ' '\n' > /tmp/ids.txt
split -l 200 /tmp/ids.txt /tmp/idb_
for b in /tmp/idb_*; do
nice -n 19 wp media regenerate $(tr '\n' ' ' < "$b") --only-missing --yes
du -sm wp-content/uploads
free -m | head -2
sleep 30
done
Action Scheduler. The site had a chronic Action Scheduler backlog the host had already flagged as a CPU source. Admin-driven bulk tools queue work there. WP-CLI is synchronous and bypasses it entirely.
I also blocked WooCommerce's own background regeneration for the duration, since re-registering woocommerce_thumbnail can trigger it:
add_filter( 'woocommerce_background_image_regeneration', '__return_false' );
nice -n 19 matters more than it looks. It means the kernel gives live traffic priority over the migration on every single scheduling decision. CPU sat around 69% during the run and the site stayed responsive, because the 69% was work nobody else wanted.
Measured throughput: 66 seconds per 200 attachments. The full run took under twenty minutes.
Rolling it out to production
Staging and production were separate applications on the same server. I wrote the whole thing up as a runbook and replayed it, and it went nearly identically — which is the point of rehearsing.
Nearly. One thing bit me.
The host gives each application a private /tmp namespace. My batch files, ID list, and log had all been written to /tmp during the rehearsal, owned by the staging user. On production, every /tmp write failed:
bash: /tmp/ids.txt: Permission denied
rm: cannot remove '/tmp/idb_aa': Operation not permitted
split: /tmp/idb_aa: Permission denied
Except the reads didn't fail. wc -l /tmp/ids.txt cheerfully returned 2216 — staging's file. My first production batch ran against the rehearsal environment's ID list. It happened to work, because the two databases share attachment IDs, but that was luck. The background script never started at all, and I only noticed because the log line count sat at zero.
Moved everything to a private directory outside the web root and it ran clean.
The lesson isn't "check permissions." It's that a partially-failed setup is more dangerous than a fully-failed one. If /tmp had been readable and unwritable in a consistent way I'd have caught it instantly. Instead the writes failed loudly and the reads succeeded silently, and the silent success is what nearly shipped a run against the wrong data.
Final numbers matched the rehearsal exactly: 2,097 of 2,216 converted, 55 sub-threshold icons untouched, 64 attachments whose files were already missing, and every oversized -scaled image passing the URL-rewrite check.
Results
| Metric | Before | After |
|---|---|---|
| WooCommerce thumbnail coverage | 141 / 2,216 (6%) | complete |
srcset candidates per image |
1 (1024px) | 4 (100/150/300/768) |
| Attachments with WebP primary | 18 | 2,097 |
| Uploads directory | ~2,100MB | ~1,900MB |
| Sample: 1.33MB PNG hero | 1,332,273 B | 27,634 B |
| Sample: 322KB JPEG headshot | 322,419 B | 37,214 B |
The uploads directory got smaller. Adding roughly 11,000 WebP files while deleting 12,283 orphaned sidecars nets out negative. Disk was never the constraint I'd braced for.
The biggest win isn't in that table. It's that WooCommerce stopped resizing images on demand for 94% of product requests. That was real CPU on every uncached page load, and it's simply gone now.
What's still wrong
I'd have called this done, except the first Divi page I checked afterward served this:
<img src=".../DrEdythe_7.2024-scaled.webp"
srcset=".../DrEdythe_7.2024-scaled.webp 1831w,
.../DrEdythe_7.2024-732x1024.webp 732w, ..."
sizes="(max-width: 1831px) 100vw, 1831px">
Correct format, correct src, four srcset candidates. And still wrong.
Read the sizes attribute. It tells the browser the image occupies the full viewport width up to 1831px. The module actually renders in a column a third of that. So the browser dutifully picks the 1831w candidate — 574KB — when the 732w file at 99KB would have covered the slot.
Which is the same failure as the thumbnail ladder, wearing a different hat. I spent a session making sure the right files existed and the right URLs pointed at them, and the browser is still downloading 6× more than it needs because one attribute lies about the layout.
That one's fixable through wp_calculate_image_sizes, but it needs measuring against real module widths rather than guessed — and it's a bigger remaining win than the format change was.
There's also a cleanup I deliberately didn't do. Superseded originals are still on disk: DrEdythe_7.2024-732x1024.png at 1.36MB sitting inert next to its 99KB WebP, and hundreds more like it. Tempting to sweep. But while the render-time filter is doing delivery, those files are the rollback path — deactivating the snippet reverts URLs to .png, which only works if the .png is still there. That deletion waits until WebP has been boring in production for a month.
What I'd tell past-me
Audit before you optimize. The task I'd written down for myself was "convert images to WebP." The actual problem was a stripped size ladder causing on-the-fly resizing and 3× mobile payloads. Twenty minutes of find and wp eval reordered the entire job — and turned a cosmetic improvement into a real one.
Non-sequential array keys are a fingerprint. [3], [16], [17], [18] told me a filter was unsetting entries at runtime, not that sizes were never registered. That one detail pointed straight at the plugin responsible.
Check what the previous optimizer left behind. Uninstalling an image plugin doesn't remove its output. Grep for orphaned formats before adding another.
original_image is the check that prevents slow-motion disaster. Without it you get lossy-from-lossy regeneration compounding over years, and nobody notices until every image on the site is soft.
Check the biggest images last, not first. My URL-rewrite filter passed every test I wrote
and still left the single largest file on the site unconverted, because -scaled originals
don't have the filename the naive swap looks for. Verify against your worst-case asset, not a
representative one.
A verification query can measure itself. Those 64 missing files bothered me, so I wrote a
check to find out whether anything referenced them. It reported 64 out of 64 referenced —
alarming, and wrong. It was counting each attachment's own _wp_attached_file and
_wp_attachment_metadata rows as evidence that something else pointed at it, and matching a
filename stem like back against every post containing the word "back." Every row came back
"used" because the query couldn't tell a reference from a reflection. Walking the actual pages
in a browser had already given the right answer; I just trusted the SQL more because it had a
number in it.
Generating the right sizes beats changing the format — and telling the browser the truth beats both. WebP is maybe 30% off a file. Serving a 300px image instead of a 1024px one is 90%. And an honest sizes attribute is what makes the browser actually choose the 300px one. Format, dimensions, declaration — in ascending order of how much they matter and descending order of how much attention they get.
Notes: written up from a real migration on a WooCommerce + LearnDash + Divi site I maintain. Proactive maintenance work, not a client request. Site details anonymized — swap in specifics if you have sign-off. Table prefix shown as wp_ throughout; use $(wp db prefix) on real sites.
Top comments (0)