Skip to content

perf(icons): load SVG payloads on demand, and add a benchmark to prove it - #42

Merged
herewithme merged 6 commits into
developfrom
perf/lazy-icon-loading
Sep 10, 2026
Merged

herewithme merged 6 commits into
developfrom
perf/lazy-icon-loading

Conversation

@herewithme

@herewithme herewithme commented Sep 10, 2026 •

Copy link
Copy Markdown
Member

Why

A client site on WordPress VIP with 300+ icons, several SVGs above 1 MB, was unusable. The plugin loaded every icon's content on every request — front-end pages with no icon block included — and the object cache could not store the result, so nothing ever warmed up.

Two VIP specifics turn "wasteful" into "fatal" there: media-library files live on VIP Files (every read is a network fetch), and the object cache is memcached (silently refuses items over 1 MB).

What changed

A performance protocol first (tests/perf/)

Before touching the plugin, a rig that reproduces the problem so the fix can be measured rather than asserted. It builds an isolated instance on port 8899 with 500 SVG icons — 200 in a theme folder, 300 contributed through the media library, sized ~11 KB → ~1.95 MB with 25 above 1 MB — behind an object cache that refuses items over 1 MB, and routes media reads through a bpifs:// stream wrapper that counts them and can charge VIP-like latency.

npm run perf:setup
npm run perf:bench -- --label=baseline

Then the three fixes

  1. Payloads load on demand. Registering a collection builds a lightweight index: CollectionItem holds a small serializable descriptor (['type' => 'file', 'path' => …]) instead of the SVG, and ContentLoader reads the bytes the first time content() is called — one icon to render a block, one page's worth to list a collection in the editor. The indexes stay small: 108 KB for the 200-icon folder collection, 138 KB for 300 media attachments, against a 900 KB ceiling.
  2. Payloads are cached individually, with a 900 KB ceiling. Memcached refuses items over 1 MB through a return value nothing checks, so oversized entries were rebuilt and refused forever. Adjustable via blockparty_icons_cache_max_item_bytes.
  3. New attachments collection type for media-library SVGs: one query, one cached index salted on the posts cache's last_changed. Replaces the loop the README used to recommend, which cost a query plus a cache round trip per icon per request.

Results

Baseline first, because it sets the scale: at PHP's common 128 MB limit the plugin does not run at all against this dataset — it fatals while serialize()-ing a collection for the cache. Everything below runs at 512 MB, matching VIP.

Front-end page containing no icon block, median of 4 runs:

before after
SVG resident in memory 101.7 MB 0.015 MB
object-cache gets 301 2
cache writes refused (>1 MB) 16 0
blockparty_icons_init 602 ms 2.3 ms
peak memory 154 MB 12 MB

Charging 2 ms per media read to emulate VIP Files:

scenario before after
front page, no icon, warm cache 618 ms 25 ms
front page, no icon, cold cache 4806 ms 422 ms
front page, one icon, warm cache 547 ms 30 ms

Response bodies are unchanged where the payload is genuinely wanted — the same icons are still delivered byte for byte.

For the reviewer

  • The upgrade path was the subtle part. A version salt cannot protect against a class-shape change, because wp_cache_get_salted() unserializes the stored value before it compares salts — the payload is already rebuilt into the new class shape by the time anything could reject it. Cached index keys therefore carry a format revision (CACHE_FORMAT), and typed properties gained defaults. Verified by warming the cache with 1.1.1 (908 entries written), then loading the new code over it.
  • CollectionItem has no __serialize()/__unserialize(). An earlier revision of this branch added them to keep resolved payloads out of a cached index. They were dropped after checking that no call path needs them: every factory caches its index before anything calls content(). Default PHP serialization is enough, and the measured metrics are identical without them. Worth knowing if you wonder why nothing stops a resolved item from being serialized — the answer is call order, not a guard.
  • resident is measured by reflection, not through content() — calling the getter would force every icon to resolve and destroy the thing being measured.
  • The rig's cache is file-backed, not memcached. It reproduces the semantics exactly but not the latency, so operation counts are the transferable metric and wall time is indicative. Documented in tests/perf/README.md along with the other limits.
  • phpcs.xml.dist now excludes ./tests/: grumphp passes changed files to phpcs explicitly, overriding the ruleset's file list, and a cache drop-in must assign $GLOBALS['wp_object_cache'] and declare both a class and the wp_cache_* functions in one file. The harness is already kept out of releases by .distignore.
  • The demo theme and README were updated to stop recommending the per-attachment loop.

Not addressed

The editor still bootstraps ~22 MB. block_editor_rest_api_preload_paths inlines the first 50 icons of every collection with their content; a single REST page is 12 MB. Fixing that means changing what the editor asks for — dropping content from the view context, or having the picker fetch payloads only for icons it draws. Deliberately left out of this PR.

Verification

  • phpcs clean (the only PHP check in CI)
  • Psalm 242 vs 221 before, same Mixed* noise at errorLevel 1 as the existing codebase; not gated anywhere
  • Functional checks pass for sprite, folder, attachments, lazy resolution, memoization, oversized-icon rendering, and the cache guard
  • Front-end rendering verified byte-identical to baseline (20 476 / 39 935 / 3 597 704 bytes for the no-icon, one-icon and twenty-icon pages)
  • Serialization round trip checked: an item stays unresolved through cache, and still resolves afterwards

One caution for anyone re-running the benchmark: at these magnitudes wall time is dominated by container jitter. A run right after the __serialize() removal showed the init hook at 14.97 ms against 2.33 ms before, which looks like a regression and is not — two further runs of the same code gave 1.83 ms and 2.05 ms. Read the operation counts (resident, cache get, refused), which did not move at all.

🤖 Generated with Claude Code


Note

Medium Risk
Core icon loading, caching, and registration paths change for all collection types; behavior should be equivalent when icons are rendered but cache/index shape and memory profile differ, so sites with large media-library sets need validation.

Overview
Icon collections no longer load every SVG at registration. CollectionItem keeps a serializable source descriptor and resolves markup on first content() via new ContentLoader, with per-payload object-cache entries. Folder/file factory paths build lightweight indexes (empty files skipped via filesize only).

Object-cache behavior is hardened for memcached-style 1 MB limits: Cache::set_cache refuses payloads over a default 900 KB ceiling (blockparty_icons_cache_max_item_bytes), and collection indexes use format-scoped keys (CACHE_FORMAT v3) plus plugin version in salts so old serialized shapes are not reused.

Media-library SVGs get a first-class attachments type (Collection::from_attachments / CollectionItemsFactory::from_attachments): one WP_Query, one cached index salted with wp_cache_get_last_changed('posts'), optional query args. Registration API and demo theme drop the per-attachment from_file loop; README documents the pattern.

A reproducible perf harness lives under tests/perf/ (fixtures, wp-env on 8899, instrumented object cache, bench/report npm scripts), with gitignored fixtures/results and phpcs exclusion for harness code.

Reviewed by Cursor Bugbot for commit d423c1f. Bugbot is set up for automated code reviews on this repo. Configure here.

herewithme and others added 5 commits September 10, 2026 22:27
Stands up an isolated WordPress instance (port 8899, so it runs alongside the
normal dev env) holding 500 SVG icons: 200 in a theme folder and 300 contributed
through the media library, sized from ~11 KB to ~1.95 MB with 25 above 1 MB.

The rig exists to produce comparable before/after numbers rather than
assertions. It reproduces the three things that make WordPress VIP hurt:

- a persistent object cache that refuses items over 1 MB the way memcached does,
  silently returning false, which is exactly what no caller checks;
- media-library reads routed through a bpifs:// stream wrapper, so they are
  counted exactly and can be charged VIP Files-like latency;
- collections registered on every request, front end included.

Metrics land as one JSON record per request and are reduced to medians.
`resident` -- the SVG bytes actually held in memory -- is read by reflecting on
CollectionItem's private properties rather than through content(), so the
measurement stays honest once loading becomes lazy.

The object cache is file-backed rather than memcached: it reproduces the
semantics exactly but not the latency, so operation counts are the transferable
metric and wall time only indicative. That and the other limits are written up
in tests/perf/README.md.

phpcs now excludes ./tests/: grumphp passes changed files to phpcs explicitly,
which overrides the ruleset's file list, and the harness has to break WordPress
conventions to do its job -- a cache drop-in must assign
$GLOBALS['wp_object_cache'] and declare both a class and the wp_cache_*
functions in one file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With 500 icons totalling ~102 MB, every request held all of it in memory --
including a front-end page containing no icon block at all. At PHP's common
128 MB limit the plugin did not merely run slowly, it fatalled while serializing
a collection for the cache.

Three changes, measured with tests/perf against 500 icons on a cache that
refuses items over 1 MB:

1. Registering a collection now builds a lightweight index. CollectionItem holds
   a small serializable descriptor saying where its bytes live, and ContentLoader
   reads them the first time content() is called -- one icon to render a block,
   one page's worth to list a collection in the editor. __serialize() keeps
   resolved payloads out of the cached index so it cannot grow with use.

2. Payloads are cached individually, and Cache::set_cache() declines anything
   over 900 KB. Memcached (VIP and most managed hosts) refuses items above 1 MB
   and signals it only through a return value nothing checks, so an oversized
   entry was rebuilt, refused and rebuilt again forever. Sixteen entries were in
   that state in the baseline, including the whole 200-icon folder collection as
   a single entry, which meant re-reading all 200 files on every request. The
   ceiling is adjustable with the blockparty_icons_cache_max_item_bytes filter.

3. New `attachments` collection type for SVGs contributed through the media
   library: one query and one cached index, salted on the posts cache's
   last_changed so a newly uploaded icon appears at once. It replaces the pattern
   the README used to recommend -- a WP_Query plus a from_file() call per
   attachment -- which cost a query and a cache round trip per icon per request.

Cached index keys carry a format revision. A salt cannot do that job:
wp_cache_get_salted() unserializes the stored value before it compares salts, so
an entry written by an earlier release would be unserialized into the new class
shape before anything could reject it. Typed properties also gained defaults so
such an entry cannot leave one uninitialised.

Measured on 500 icons (median of 4 runs), front-end page with no icon block:
resident SVG 101.7 MB -> 0.015 MB, object-cache gets 301 -> 2, refused writes
16 -> 0, blockparty_icons_init 602 ms -> 2.3 ms. Charging 2 ms per media read to
emulate VIP Files, the same page on a cold cache goes 4806 ms -> 422 ms.

Not addressed here: the editor still bootstraps ~22 MB because
block_editor_rest_api_preload_paths inlines the first 50 icons of each
collection with their content.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous comment read as though it were fixing something the code does
today. It is not: the factories cache their index before anything calls
content(), so no resolved payload can currently reach the cache. It is a
structural guard, and the comment now says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
They guarded against a resolved payload reaching a cached index, but no call
path does that: every factory caches its index before anything calls content().
Default PHP serialization is enough, and two fewer magic methods is two fewer
things to reason about.

Cached indexes stay comfortably cacheable without them -- 108 KB for the
200-icon folder collection, 138 KB for 300 media attachments, against a 900 KB
ceiling. Measured metrics are unchanged: resident SVG, object-cache operations,
refused writes and response bytes are all identical, and the timing difference
is container jitter (repeat runs of this same code give 1.8-2.3 ms for the init
hook, matching the previous commit).

CACHE_FORMAT goes to v3 because the on-disk shape changed: an entry written by
__serialize() would otherwise be unserialized into public dynamic properties,
leaving the real typed ones uninitialised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The exclusion was added for tests/perf, whose object-cache drop-in has to assign
$GLOBALS['wp_object_cache'] and declare a class alongside the wp_cache_*
functions. Written as ./tests/ it now also covers the PHPUnit suite that landed
on develop, which is phpcs-clean and should stay linted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@herewithme
herewithme force-pushed the perf/lazy-icon-loading branch from d9d47d7 to f638db8 Compare September 10, 2026 20:31
Both columns re-taken in one session on the same machine and the same 500 icons,
the "before" side by checking the original classes back into the working tree
rather than reusing an older run. Every operation count is unchanged — resident
SVG 101.7 MB to 0.015 MB, 301 cache gets to 2, 16 refused writes to none. Wall
times shifted by a few tens of percent, which is what wall times do; the table
now reports what was actually measured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@herewithme
herewithme merged commit fea62dc into develop Sep 10, 2026
7 checks passed
@herewithme
herewithme deleted the perf/lazy-icon-loading branch September 24, 2026 09:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant