srcset-truth
Fixes the sizes attribute WordPress emits for images painted into a fixed-width slot, so phones stop downloading the 1024px candidate for a 372px box. Serves WebP alongside the original with a quality setting, and reports the before-and-after page weight.
Install
This is the repository archive. It installs, but the folder name will carry the branch suffix and updates will not flow.
Install straight from GitHub with WP-CLI:
wp plugin install https://github.com/indianopinion/srcset-truth/archive/refs/heads/main.zipWordPress tells the browser that every image is as wide as the screen, so a phone downloads the 1024 px file for a 372 px card. srcset-truth replaces that sizes value with the real width of the slot the image sits in, serves WebP copies next to the originals, and reports the before-and-after image weight of any page.
Measured result (Lighthouse, mobile, a news home page): 8,908 KiB total page weight, of which 6,898 KiB was recoverable by serving the right size in a modern format.
This project came out of the work on indianopinion.org and is written from scratch; it is independent of and not affiliated with any third-party plugin it integrates with.
Contents
Install
Requirements: PHP 8.1 or later, WordPress 6.4 or later. WebP creation needs Imagick built with WebP, or GD with WebP (static images only). Without either, sizes correction still works.
Without Composer
- Download the release zip, or
git clone https://github.com/IndianOpinion/srcset-truth.gitintowp-content/plugins/srcset-truth. - Activate srcset-truth under Plugins.
No Composer is needed at runtime: the plugin ships its own class loader.
With Composer
composer require indianopinion/srcset-truth
With composer/installers the package lands in wp-content/plugins/srcset-truth (type wordpress-plugin).
As a must-use plugin
Put the plugin folder anywhere WordPress can read (for example wp-content/mu-plugins/srcset-truth/), then add a one-line loader wp-content/mu-plugins/srcset-truth-loader.php:
<?php require WPMU_PLUGIN_DIR . '/srcset-truth/srcset-truth.php';
Must-use plugins never run activation hooks; the backfill schedules itself on the next init instead.
Quick start
Nothing changes until you tell the plugin how wide your slots are. With no width map, core's value is kept, because a guess that is too small makes images blurry.
- Find the real widths. Log in as an administrator, open a page, and click Probe image slots in the admin bar. The browser console lists every image's rendered width, device pixel ratio, downloaded width and the
sizesit carried, and marks images that downloaded more than 1.5 times what they needed. - Describe your slots in the theme (or a small site plugin):
add_filter('srcset_truth_width_map', function (array $map): array {
$map['default']['card'] = [[767, '92vw'], [1024, '46vw'], [null, '380px']];
$map['default']['hero'] = '(max-width: 767px) 100vw, (max-width: 1200px) 90vw, 1200px';
$map['single-post']['content'] = [[800, '100vw'], [null, '760px']];
return $map;
});
add_filter('srcset_truth_slot_rules', function (): array {
return [
['slot' => 'hero', 'min_width' => 1200],
['slot' => 'card', 'size' => ['medium', 'medium_large', 'large']],
];
});
- Or name the slot where you render it:
use function IndianOpinion\SrcsetTruth\with_slot;
echo with_slot('card', fn () => get_the_post_thumbnail(null, 'large'));
- Check the saving:
wp srcset-truth audit / --width=412.
For a typical card-grid theme, the Card grid preset on Settings, Media does steps 2 and 3 for you (see Presets).
More in examples/.
Configuration
Settings (Settings, Media)
| Setting | Default | What it does |
|---|---|---|
| Serve WebP siblings | On | Create siblings at upload and in the backfill, and serve them |
| WebP photo quality | 82 | 1 to 100. Static GIFs use 80 and animated GIFs 72 (filterable) |
| Mode | Filter | Filter: core image filters. Buffer: the whole HTML page, which also reaches theme-hard-coded tags and is needed for the LCP fix |
| LCP fix | Off | Buffer mode only. Makes the likely LCP image eager with high priority, lazy-loads the rest, preloads the hero |
| Width map preset | None | none or grid |
| Override authored sizes | Off | Also replace sizes values the theme wrote itself |
| Background backfill | On | Every 5 minutes, 45 s budget, newest images first |
| Debug log | Off | Writes [srcset-truth] lines through error_log, only when WP_DEBUG_LOG is set |
The constant SRCSET_TRUTH_DISABLE set to true in wp-config.php turns the whole plugin off. Nothing is hooked.
Filters
| Filter | Value | Extra arguments | Default |
|---|---|---|---|
srcset_truth_width_map |
array | template key | preset map or [] |
srcset_truth_slot_rules |
list | preset rules or [] |
|
srcset_truth_template |
string | computed template key | |
srcset_truth_slot |
?string | context array | null |
srcset_truth_should_correct |
bool | sizes value, context | true for the core default only |
srcset_truth_sizes |
string | context | the compiled value (validated again) |
srcset_truth_webp_enabled |
bool | the setting | |
srcset_truth_webp_quality |
int 1 to 100 | kind (photo, graphic, animated), MIME |
82, 80, 72 |
srcset_truth_webp_sizes |
list of size names | attachment ID | medium, medium_large, large |
srcset_truth_picture_wrap |
bool | true: wrap in <picture>; false rewrites candidates in place (drops browsers without WebP) |
|
srcset_truth_picture_css |
bool | true: print picture.srcset-truth{display:contents} when wrapping |
|
srcset_truth_should_wrap |
bool | tag HTML, context | true (gates WebP for one tag in either strategy) |
srcset_truth_skip_classes |
list | [] |
|
srcset_truth_url_to_path |
?string | URL | uploads mapping |
srcset_truth_has_sibling |
?bool | original path | null (check disk) |
srcset_truth_buffer_enabled |
bool | true in buffer mode | |
srcset_truth_lcp_enabled |
bool | the setting | |
srcset_truth_lcp_rules |
array | min width 600, ratio 1.2 to 2.2, markers logo, avatar, gravatar, icon |
|
srcset_truth_lcp_candidate |
?bool | tag attributes, index | null (use the rules) |
srcset_truth_backfill_enabled |
bool | the setting | |
srcset_truth_backfill_budget |
int seconds | 45 (never above 80% of max_execution_time) |
|
srcset_truth_backfill_interval |
int seconds | 300 | |
srcset_truth_limits |
array | 40 megapixels, 30 MB, 300 frames | |
srcset_truth_grid_card_width |
int px | 380 | |
srcset_truth_is_front_html |
bool | computed |
The context array has template, slot, attachment_id, size, image_width, src, class and source (content, thumbnail, attachment, buffer).
Actions
| Action | Arguments |
|---|---|
srcset_truth_sizes_corrected |
old value, new value, context |
srcset_truth_sibling_created |
original path, sibling path, original bytes, sibling bytes |
srcset_truth_sibling_failed |
original path, reason code |
srcset_truth_backfill_done |
files done, files failed, seconds, cursor |
Reason codes: no-encoder, unsupported-type, too-large, too-many-frames, not-beneficial, encode-error, write-error, colour-profile.
Slot API
All in the IndianOpinion\SrcsetTruth namespace:
| Function | Purpose |
|---|---|
with_slot(string $slot, callable $render): mixed |
Push, render, pop (also when $render throws) |
push_slot(string $slot), pop_slot() |
For templates that cannot wrap a callable |
current_slot(): ?string |
The innermost slot |
sibling_path(string $original): string |
photo.jpg gives photo.jpg.webp |
sibling_url(string $url): ?string |
The sibling URL if a fresh sibling exists |
compile_sizes(array\|string $spec, ?int $width = null): string |
Compile and validate; throws InvalidSizesSpec |
Slot names are 1 to 40 lower case letters, digits and hyphens; others throw InvalidSlotName.
Template keys
front, home, attachment, page, single-{post_type}, taxonomy-{taxonomy}, author, date, archive-{post_type}, search, 404, otherwise default. Lookup falls back: single-post, single, default; taxonomy-category, taxonomy, archive, default; archive-{type}, archive, default; anything else, default. * is an alias for default.
Presets
grid is for card-grid themes: one column on phones, two on tablets, three on desktop.
hero(images 1200 px or wider):(max-width: 767px) 100vw, (max-width: 1200px) 90vw, 1200pxcard(everything else):(max-width: 767px) 92vw, (max-width: 1024px) 46vw, 380pxcontent(images in post content on single posts and pages):(max-width: Wpx) 100vw, Wpx, where W is the theme's$content_width, or 1200 when the theme does not set it. On singular views cards are declared at W too, so a featured image painted wide is never under-declared.
How it works
- Sizes. On a front-end HTML request, every image passes one classifier, whichever way it arrives (content filter, post thumbnail, attachment attributes, or the page buffer). The classifier recognises core's default
(max-width: Wpx) 100vw, Wpx(with or withoutauto,). It resolves a template key and a slot, looks the pair up in the width map, compiles and validates the value, and caps the last length at the image's own width so a map written for big images never inflates small ones. Anything it is unsure of keeps core's value. - WebP siblings. On upload, the plugin writes
photo-768x432.jpg.webpnext tophoto-768x432.jpgfor the main file and themedium,medium_largeandlargesizes. Type comes from the file signature, not the name. Writes are atomic (temporary file, then rename). A sibling that is not smaller than its original is deleted and not tried again until the original changes. The backfill walks the whole library from the newest ID down, in a 45 s budget, one run at a time. - Serving. The plugin wraps the image in
<picture>with a<source type="image/webp">and leaves the<img>exactly as it was, so a browser without WebP falls back to the original. The<source>lists the same candidates in the same order with the same descriptors: each candidate that has a fresh sibling gets the sibling URL, and one without keeps the original URL, so the browser's width choice does not change. Only JPEG, PNG and GIF originals are mapped; WebP, AVIF and SVG candidates are left alone. Returningfalsefromsrcset_truth_picture_wraprewrites the<img>'s ownsrcsetandsrcin place instead, which adds no element but hands WebP to every browser: use it only if you accept that browsers without WebP (Safari before 14, older Android WebViews) show broken images. Pages never encode: they only check whether a sibling exists, once per file per request. - Caches. The HTML is the same for every visitor: no
Vary: Accept, no user-agent sniffing. Each format has its own URL, so a CDN caches.jpgand.jpg.webpseparately. The plugin never purges; hooksrcset_truth_sibling_createdif you want to. - LCP fix (buffer mode, off by default). The first image that looks like a content photo (600 px or wider, aspect ratio 1.2 to 2.2, no logo, avatar or icon marker) gets
fetchpriority="high"and noloading; every other image getsloading="lazy"; duplicate attributes are collapsed, and a matching<link rel="preload">goes before</head>. If no image qualifies, nothing changes.
Anything that syncs uploads to an object store or media host must include the .webp files, or pages will link to files the media host does not have.
If your CDN converts images itself (an edge image optimiser), turn off WebP serving and keep sizes correction.
Web servers must send .webp as image/webp. Current nginx and Apache do; Site Health checks it for you and reports a wrong type as critical.
WP-CLI
wp srcset-truth audit <path-or-url> [--width=412] [--dpr=2] [--format=table|json]
wp srcset-truth generate [--all] [--attachment=<id>] [--since=<date>] [--limit=<n>] [--dry-run]
wp srcset-truth status
wp srcset-truth map [--template=<key>]
wp srcset-truth remove-siblings --yes [--attachment=<id>]
audit fetches one page of this site (never another host, never a redirect to another host) and reports, per image, the candidate a browser would pick under core's sizes in the original format (before) and under the emitted sizes in WebP where offered (after), with bytes and flags. The pick rule is the specification's: the smallest candidate whose density reaches the device pixel ratio. Flags: unmapped, authored, invalid-spec, no-candidate-near-slot (with the missing width), no-sibling, not-beneficial, lazy-lcp, unsized. It exits 1 when the page cannot be fetched or is not HTML.
The audit sends a short-lived signed header (HMAC-SHA256 keyed from the site's salts, valid for at most ten minutes, compared in constant time), so only WP-CLI on the same site can ask for annotations; a visitor cannot. When the page is rendered fresh, images carry a data-srcset-truth note with template, slot and status, and the response is marked private, no-store and defines DONOTCACHEPAGE, so neither a page-cache plugin nor a header-respecting proxy keeps it. If your proxy cache ignores Cache-Control and does not key on request headers, exclude requests carrying X-Srcset-Truth-Audit from it. A page served from a full-page cache has no notes; the audit then infers what it can.
JSON shape:
{
"url": "https://example.com/",
"viewport": {"width": 412, "dpr": 2},
"images": [
{"index": 0, "src": "...", "template": "front", "slot": "card", "sizes": "...",
"before": {"url": "...", "width": 1024, "bytes": 81234, "format": "jpeg"},
"after": {"url": "...", "width": 768, "bytes": 23110, "format": "webp"},
"flags": [], "missing_width": null, "unknown": false}
],
"totals": {"images": 1, "counted": 1, "before_bytes": 81234, "after_bytes": 23110, "saving_kib": 56.7, "saving_percent": 71.6}
}
Limitations
- It does not create or regenerate sub-sizes. If the right width is missing from
srcset, the audit says so (no-candidate-near-slot). - No AVIF in 0.1.0.
- WebP is served through
<picture>, which changes the element tree around the image (see the CSS question below). The in-place alternative avoids that but breaks images in browsers without WebP. - In filter mode, images a theme prints with its own
<img>markup are not reached; buffer mode reaches them. Images printed withwp_get_attachment_image()are wrapped through its output filter. - Uploads offloaded to an object store fail the file check, so originals are served unless you answer
srcset_truth_has_siblingfrom your own index. - Image CDNs that resize on the fly are left alone.
FAQ
Nothing changed after activating it. That is by design. Supply a width map (or pick the grid preset); without one, core's value is the safest answer.
Will it break my theme's CSS? Usually not. The plugin prints picture.srcset-truth{display:contents} (filter srcset_truth_picture_css), so the <picture> generates no box and flex and grid layouts treat the <img> as a direct child. Child selectors such as .card > img and :first-child rules no longer match the <img>; update them to .card img, or return false from srcset_truth_picture_wrap if you accept losing browsers without WebP.
Does it slow pages down? No encoding ever happens on a page request. Pages do at most one file check per candidate per request.
How do I remove everything? Purge page and CDN caches, run wp srcset-truth remove-siblings --yes, then delete the plugin. Uninstall removes settings and state but leaves sibling files, because cached pages may still point at them.
Another plugin already serves WebP. Turn off WebP serving here and keep sizes correction. Tags already inside <picture> are never touched.
Contributing
See CONTRIBUTING.md for setup, tests and pull request rules.
Licence
MIT. See LICENSE.