litespeedtech / litespeedtech/lscache_wp

WebP/AVIF replacement is applied without comparing file sizes, so a next-gen image larger than its source is served

Open
#1,049 1 comment 0 reactions 0 assignees View on GitHub
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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.