Skip to content

[Icons] Cache the rendered HTML of repeated icons in IconRenderer - #4047

Merged
Kocal merged 1 commit into
symfony:3.xfrom
Kocal:perf/icons-render-memoize
Oct 9, 2026
Merged

Kocal merged 1 commit into
symfony:3.xfrom
Kocal:perf/icons-render-memoize

Conversation

@Kocal

@Kocal Kocal commented Oct 6, 2026 •

Copy link
Copy Markdown
Member
Q A
Bug fix? no
New feature? no
Deprecations? no
Documentation? no
Issues -
License MIT

Pages render the same few icons over and over, for instance the same action icons in every table row. IconRenderer::renderIcon() redid all the work on each call: merging the default, icon set and passed attributes, validating them twice through Icon::withAttributes(), adding aria-hidden, then escaping every attribute in Icon::toHtml(), although the resulting HTML only depends on the icon name and the passed attributes.

IconRenderer now keeps the rendered HTML, keyed by serialize([$name, $attributes]), and returns it on the next identical call. The key keeps value types apart (true, '1' and 1 render differently). Calls with a non-scalar attribute value skip the cache, Icon::withAttributes() rejects them anyway. The cache is cleared on kernel reset and capped at 1,000 entries, so a process that never resets cannot grow it without bound.

  • standalone benchmark below (290 icons per request, 50 distinct, 3 interleaved runs each, identical output): 243-254 ms before, 72-76 ms after (3.3x)
  • replay of the 414 icons rendered by one request of an admin dashboard (90 distinct): 2.74-2.82 µs per icon before, 1.00-1.03 µs after
  • per request, Icon::toHtml() goes from 414 calls to 90 and Icon::withAttributes() from 528 to 122
  • on that page it saves about 0.7 ms per request (~1.5%)
<?php

// Run from src/Icons: php bench.php
use Symfony\UX\Icons\Icon;
use Symfony\UX\Icons\IconRenderer;
use Symfony\UX\Icons\Tests\Util\InMemoryIconRegistry;

require getcwd().'/vendor/autoload.php';

// A table of 25 rows with the same 10 action icons each, plus 40 icons rendered once: 290 renders, 50 distinct.
$calls = $icons = [];
foreach (range(1, 25) as $row) {
    foreach (['eye', 'pencil', 'copy', 'share', 'trash', 'star', 'clock', 'check', 'message', 'more'] as $name) {
        $calls[] = ['lucide:'.$name, ['class' => 'size-4', 'data-icon' => 'inline-start']];
    }
}
foreach (range(1, 40) as $i) {
    $calls[] = ['lucide:icon-'.$i, ['class' => 'size-4']];
}
foreach ($calls as [$name]) {
    $icons[$name] = new Icon('<path d="M12 2a10 10 0 1 0 0 20a10 10 0 1 0 0-20Zm0 4v6l4 2"/>', ['viewBox' => '0 0 24 24']);
}
$registry = new InMemoryIconRegistry($icons);

$html = '';
$times = [];
for ($run = 0; $run < 7; ++$run) {
    $start = hrtime(true);
    for ($request = 0; $request < 300; ++$request) {
        $renderer = new IconRenderer($registry, ['fill' => 'currentColor', 'height' => '1em', 'width' => '1em']);
        foreach ($calls as [$name, $attributes]) {
            $svg = $renderer->renderIcon($name, $attributes);
            if (0 === $run && 0 === $request) {
                $html .= $svg;
            }
        }
    }
    $times[] = (hrtime(true) - $start) / 1e6;
}
sort($times);
printf("300 requests of %d icons: median %.0f ms (min %.0f, max %.0f) | sha1 %s\n", count($calls), $times[3], $times[0], $times[6], sha1($html));

Blackfire profiles (replay of the dashboard's icons):

This dashboard is available at https://github.com/Kocal/sf-ux-perfs-twig-components.

@Kocal Kocal self-assigned this Oct 6, 2026
@carsonbot carsonbot added Icons Status: Needs Review Needs to be reviewed labels Oct 6, 2026
@Kocal
Kocal force-pushed the perf/icons-render-memoize branch 2 times, most recently from e61453d to 87fe874 Compare October 6, 2026 22:19
| Q              | A
| -------------- | ---
| Bug fix?       | no
| New feature?   | no
| Deprecations?  | no
| Documentation? | no
| Issues         | -
| License        | MIT

Pages render the same few icons over and over, for instance the same action icons in every table row. `IconRenderer::renderIcon()` redid all the work on each call: merging the default, icon set and passed attributes, validating them twice through `Icon::withAttributes()`, adding `aria-hidden`, then escaping every attribute in `Icon::toHtml()`, although the resulting HTML only depends on the icon name and the passed attributes.

`IconRenderer` now keeps the rendered HTML, keyed by `serialize([$name, $attributes])`, and returns it on the next identical call. The key keeps value types apart (`true`, `'1'` and `1` render differently). Calls with a non-scalar attribute value skip the cache, `Icon::withAttributes()` rejects them anyway. The cache is cleared on kernel reset and capped at 1,000 entries, so a process that never resets cannot grow it without bound.

- standalone benchmark below (290 icons per request, 50 distinct, 3 interleaved runs each, identical output): **243-254 ms** before, **72-76 ms** after (3.3x)
- replay of the 414 icons rendered by one request of an admin dashboard (90 distinct): **2.74-2.82 µs** per icon before, **1.00-1.03 µs** after
- per request, `Icon::toHtml()` goes from 414 calls to 90 and `Icon::withAttributes()` from 528 to 122
- on that page it saves about **0.7 ms** per request (~1.5%)

```php
<?php

// Run from src/Icons: php bench.php
use Symfony\UX\Icons\Icon;
use Symfony\UX\Icons\IconRenderer;
use Symfony\UX\Icons\Tests\Util\InMemoryIconRegistry;

require getcwd().'/vendor/autoload.php';

// A table of 25 rows with the same 10 action icons each, plus 40 icons rendered once: 290 renders, 50 distinct.
$calls = $icons = [];
foreach (range(1, 25) as $row) {
    foreach (['eye', 'pencil', 'copy', 'share', 'trash', 'star', 'clock', 'check', 'message', 'more'] as $name) {
        $calls[] = ['lucide:'.$name, ['class' => 'size-4', 'data-icon' => 'inline-start']];
    }
}
foreach (range(1, 40) as $i) {
    $calls[] = ['lucide:icon-'.$i, ['class' => 'size-4']];
}
foreach ($calls as [$name]) {
    $icons[$name] = new Icon('<path d="M12 2a10 10 0 1 0 0 20a10 10 0 1 0 0-20Zm0 4v6l4 2"/>', ['viewBox' => '0 0 24 24']);
}
$registry = new InMemoryIconRegistry($icons);

$html = '';
$times = [];
for ($run = 0; $run < 7; ++$run) {
    $start = hrtime(true);
    for ($request = 0; $request < 300; ++$request) {
        $renderer = new IconRenderer($registry, ['fill' => 'currentColor', 'height' => '1em', 'width' => '1em']);
        foreach ($calls as [$name, $attributes]) {
            $svg = $renderer->renderIcon($name, $attributes);
            if (0 === $run && 0 === $request) {
                $html .= $svg;
            }
        }
    }
    $times[] = (hrtime(true) - $start) / 1e6;
}
sort($times);
printf("300 requests of %d icons: median %.0f ms (min %.0f, max %.0f) | sha1 %s\n", count($calls), $times[3], $times[0], $times[6], sha1($html));
```

Blackfire profiles (replay of the dashboard's icons):
- before: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/998268ae-9de5-47ad-929e-e7ee986cb208/graph
- after: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/ecbc2344-85fc-4097-969c-257a4c823423/graph
- comparison: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/compare/998268ae-9de5-47ad-929e-e7ee986cb208...ecbc2344-85fc-4097-969c-257a4c823423/graph

This dashboard is available at https://github.com/Kocal/sf-ux-perfs-twig-components.
@Kocal
Kocal force-pushed the perf/icons-render-memoize branch from 87fe874 to 5f9ff05 Compare October 7, 2026 05:39
@Kocal
Kocal requested review from kbond and smnandre and removed request for smnandre October 8, 2026 05:36

@kbond kbond left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍🏻

@carsonbot carsonbot added Status: Reviewed Has been reviewed by a maintainer and removed Status: Needs Review Needs to be reviewed labels Oct 9, 2026
@Kocal
Kocal merged commit b51158e into symfony:3.x Oct 9, 2026
39 checks passed
@Kocal
Kocal deleted the perf/icons-render-memoize branch October 9, 2026 20:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Icons Performance Status: Reviewed Has been reviewed by a maintainer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants