litespeedtech / litespeedtech/lscache_wp
WebP/AVIF replacement is applied without comparing file sizes, so a next-gen image larger than its source is served
- Dominant language
- PHP
- Stars
- 257
- Forks
- 123
- PR merge metrics
- No merged PRs in 30d
Description
### Summary
`Media::replace_webp()` substitutes a `.webp`/`.avif` file whenever that file **exists**. It never
checks whether the next-gen file is actually smaller than the image it replaces. Where the
conversion came back larger, the larger file is served on every page load, so Image WebP
Replacement makes those pages heavier rather than lighter.
The image optimizer does not guard against it either: `img-optm-pull.trait.php` downloads
whatever the optimization server returns and publishes it, with no size comparison
(`filesize` does not appear in that file at all).
### Environment
| | |
|---|---|
| LiteSpeed Cache | **7.9.1** |
| WordPress | 7.1 |
| PHP | 8.3.33 |
| Server | LiteSpeed Enterprise 6.3.7 |
| Settings | Image Optimization on, `Image WebP Replacement` on, `Optimize Original Images` on |
### Where it happens
`src/media.cls.php`, `replace_webp()` (line 1417). At line 1441-1444:
```php
$ori_check = apply_filters( 'litespeed_media_check_ori', Utility::is_internal_file( $url ), $url );
if ( $ori_check ) {
// check if has webp/avif file.
$has_next = apply_filters( 'litespeed_media_check_webp', Utility::is_internal_file( $url, $this->_sys_format ), $url );
if ( $has_next ) {
$url .= '.' . $this->_sys_format;
} else {
```
`Utility::is_internal_file()` returns `[ $file_path, (int) filesize( $file_path ) ]`
(`src/utility.cls.php:865`), so at this point **both file sizes are already in scope** -
`$ori_check[1]` for the source and `$has_next[1]` for the next-gen file. The size is fetched
and then discarded.
On the generation side, `src/img-optm-pull.trait.php:298-345` iterates
`[ 'webp', 'avif', 'ori' ]`, fetches each and calls `Img::publish()` (line 340) unconditionally.
### Steps to reproduce
1. Enable Image Optimization and Image WebP Replacement.
2. Optimize a media library containing large flat-colour PNG logos (a square brand logo at
1024px or above is reliable).
3. Compare each generated `.png.webp` against `.png` on disk.
4. Load a page that uses one of the larger ones and inspect the served URL and transferred size.
### Measured impact
Across 17 WordPress sites on one server, **7,260 of 165,961 `.webp` files are larger than their
source** (4.4%, 49.8 MB on disk). It is strongly format-dependent:
| source | `.webp` files | larger than source | % |
|---|---|---|---|
| **png** | 5,954 | **2,891** | **48.6%** |
| jpeg | 5,920 | 407 | 6.9% |
| jpg | 154,087 | 3,962 | 2.6% |
Nearly half of PNG conversions lose, which is expected: large flat-colour logos compress better
as lossless PNG than as lossy WebP. The worst cases are square brand logos, several of which
roughly double.
Served examples, measured over HTTP with `Accept: image/webp`:
| served file | source | served `.webp` | change |
|---|---|---|---|
| site header logo (PNG, 500px) | 4,636 B | **10,068 B** | **+117%** |
| page hero (JPEG, 1571x1001) | 253,585 B | **315,174 B** | **+24%** |
| header logo (PNG, 250px) | 2,460 B | 3,718 B | +51% |
Each of those is transferred on every page load of the pages that use them.
### Suggested fix
Both sizes are already available in `replace_webp()`, so the check costs no extra I/O:
```php
$has_next = apply_filters( 'litespeed_media_check_webp', Utility::is_internal_file( $url, $this->_sys_format ), $url );
if ( $has_next && ! empty( $ori_check[1] ) && (int) $has_next[1] >= (int) $ori_check[1] ) {
self::debug2( '-next-gen file is not smaller than the original, bypassed' );
return false;
}
if ( $has_next ) {
```
A matching comparison before `Img::publish()` in the pull would also stop the larger file being
stored at all, though the serve-side check is the one that protects existing installs.
If a size comparison is not wanted by default, a setting such as *"Only use WebP/AVIF when it is
smaller"* would work equally well - but it seems a reasonable default, since serving a larger
file is never the intent of an optimization feature.
### Workaround for anyone hitting this
The `litespeed_media_check_webp` filter is sufficient, and needs no core change:
```php
add_filter( 'litespeed_media_check_webp', function ( $has_next, $url ) {
if ( empty( $has_next ) || ! is_array( $has_next ) || ! isset( $has_next[1] ) ) {
return $has_next;
}
$ori = \LiteSpeed\Utility::is_internal_file( $url );
if ( is_array( $ori ) && ! empty( $ori[1] ) && (int) $has_next[1] >= (int) $ori[1] ) {
return false; // next-gen file is not smaller - keep the original
}
return $has_next;
}, 10, 2 );
```
Deployed across 14 live sites, this left JPEG WebP substitution untouched where it
wins (one page still serves 176 `.jpg.webp` and 52 `.png.webp`) and declined only the files that
were genuinely larger - verified file by file.
### Note
`Media::replace_webp()` handles AVIF through the same `$this->_sys_format` path, so the same
issue applies when AVIF is selected.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start in src/media.cls.php at Media::replace_webp() and trace the sizes returned by Utility::is_internal_file() in src/utility.cls.php. Then review src/img-optm-pull.trait.php around lines 298-345 to understand publication of generated formats. Done means next-gen images are used or published only when they are smaller than the source, including the AVIF path.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- php, wordpress
- Domain
- backend, performance
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 76/100