Measured with CDP against a running build. Opening the emoji picker once, on a freshly started app:
before
picker open
after closing + forced GC
images in document
44
422
45
decoded bitmaps
7.3 MB
58.3 MB
—
renderer RSS
286 MB
570 MB
487 MB
JS heap
19.6 MB
19.6 MB
19.6 MB
Two things to read out of that:
opening the picker costs ~284 MB of renderer RSS, and closing it gives back only ~83 MB. The rest is held in Chromiums decoded-image cache, which is not reachable from JS — note the JS heap never moves, so this is not a leak in our code and no amount of useMemo will touch it.
that residue is why a session that has had the picker open a few times sits at ~470 MB. Measured on a 2-hour-old instance earlier: 469 MB.
Where it comes from. Decoded-image inventory with the picker open:
decoded
images
widest
host
53.8 MB
408
500px
files.kick.com
3.6 MB
9
816px
local (group icons)
0.1 MB
1
128px
cdn.discordapp.com
Kick emotes are fetched at full resolution and drawn at roughly 32px. One of them: files.kick.com/emotes/5769661/fullsize is 500x500, 1,048,490 bytes encoded, 1 MB decoded, for a single emote.
Discords path already does this correctly — discordEmojiUrl(e.id, 48) in EmojiPicker.tsx:425 asks for 48px and costs 0.1 MB across the whole picker.
Kick has no smaller variant. Probed directly; only fullsize exists:
fullsize 206 content-length 1048490
small 403
medium 403
thumbnail 403
64 403
128 403
So this cannot be fixed by picking a different URL, the way Discord is.
Bandwidth is the other half. 408 emotes at up to 1 MB each are fetched straight from files.kick.com, bypassing the daemons media cache entirely (Matrix, Discord and Sneedchat media all come back through moho-media://; Kick emotes do not). That is potentially hundreds of MB per session, re-fetched every time.
Two fixes, either of which helps, both of which are UX-neutral:
Virtualise the grid — render only the visible rows. Bounds concurrently decoded images to a screenful no matter how many emoji the account can send, and works for every service rather than just Kick. It is still one scrollable list with jump icons, so nothing about how it feels changes. This is the bigger win and the more general one.
Downscale and cache emotes in the daemon, serving them over moho-media:// at ~64px like other media. Fixes the bandwidth too. Careful with the hover preview (EmojiPicker.tsx:392), which deliberately shows a larger image — that one should keep the full-size source.
Worth noting what is not the problem, so nobody optimises it: the JS heap is 19.6 MB and flat, DOM nodes go 2,097 to 2,911, and message retention is already bounded. The renderers remaining ~260 MB is Chromiums own floor and is not app-addressable.
Measured with CDP against a running build. Opening the emoji picker once, on a freshly started app:
| | before | picker open | after closing + forced GC |
| --- | --- | --- | --- |
| images in document | 44 | 422 | 45 |
| decoded bitmaps | 7.3 MB | 58.3 MB | — |
| **renderer RSS** | **286 MB** | **570 MB** | **487 MB** |
| JS heap | 19.6 MB | 19.6 MB | 19.6 MB |
Two things to read out of that:
- opening the picker costs **~284 MB of renderer RSS**, and closing it gives back only ~83 MB. The rest is held in Chromiums decoded-image cache, which is not reachable from JS — note the JS heap never moves, so this is not a leak in our code and no amount of `useMemo` will touch it.
- that residue is why a session that has had the picker open a few times sits at ~470 MB. Measured on a 2-hour-old instance earlier: 469 MB.
**Where it comes from.** Decoded-image inventory with the picker open:
| decoded | images | widest | host |
| --- | --- | --- | --- |
| **53.8 MB** | 408 | 500px | `files.kick.com` |
| 3.6 MB | 9 | 816px | local (group icons) |
| 0.1 MB | 1 | 128px | `cdn.discordapp.com` |
Kick emotes are fetched at full resolution and drawn at roughly 32px. One of them: `files.kick.com/emotes/5769661/fullsize` is 500x500, **1,048,490 bytes encoded**, 1 MB decoded, for a single emote.
Discords path already does this correctly — `discordEmojiUrl(e.id, 48)` in `EmojiPicker.tsx:425` asks for 48px and costs 0.1 MB across the whole picker.
**Kick has no smaller variant.** Probed directly; only `fullsize` exists:
```
fullsize 206 content-length 1048490
small 403
medium 403
thumbnail 403
64 403
128 403
```
So this cannot be fixed by picking a different URL, the way Discord is.
**Bandwidth is the other half.** 408 emotes at up to 1 MB each are fetched straight from `files.kick.com`, bypassing the daemons media cache entirely (Matrix, Discord and Sneedchat media all come back through `moho-media://`; Kick emotes do not). That is potentially hundreds of MB per session, re-fetched every time.
Two fixes, either of which helps, both of which are UX-neutral:
1. **Virtualise the grid** — render only the visible rows. Bounds concurrently decoded images to a screenful no matter how many emoji the account can send, and works for every service rather than just Kick. It is still one scrollable list with jump icons, so nothing about how it feels changes. This is the bigger win and the more general one.
2. **Downscale and cache emotes in the daemon**, serving them over `moho-media://` at ~64px like other media. Fixes the bandwidth too. Careful with the hover preview (`EmojiPicker.tsx:392`), which deliberately shows a larger image — that one should keep the full-size source.
Worth noting what is *not* the problem, so nobody optimises it: the JS heap is 19.6 MB and flat, DOM nodes go 2,097 to 2,911, and message retention is already bounded. The renderers remaining ~260 MB is Chromiums own floor and is not app-addressable.
Salastil
added the bug label 2026-09-12 17:21:29 -04:00
A section now builds only the rows near the viewport. The outer div holds the sections full height whatever is inside it, so the sections below keep their place and the list is exactly as long as it was — 2046px before and after, on the same buffer.
A/B on the same buffer and the same app state, one renderer each, launched from the two AppImages back to back:
before opening
picker open
after scrolling the whole list
cells built — old
0
418
418
cells built — new
0
59
68
decoded image bytes — old
4.8 MB
55.2 MB
55.2 MB
decoded image bytes — new
4.8 MB
25.1 MB
6.4 MB
renderer RSS — old
227 MB
538 MB
renderer RSS — new
228 MB
374 MB
So the picker costs +146 MB instead of +311 MB, and the third column is the one that matters: scrolling the entire list used to leave every emote decoded, and now leaves a screenful. What the picker costs no longer scales with how many emoji the account can reach — a user in twenty Kick channels now pays the same as one in two.
Sizes are measured off the laid-out grid rather than copied from app.css, so there is no second copy of the cell height to drift.
Two things worth recording about getting here:
my first attempt still built a screenful per section, because clamping with max(0, -top) reads every off-screen section as though the viewport sat on its first row. Sections below the fold each built a screenful they never showed.
my first before/after was not like-for-like — the two runs had opened the picker on different buffers, and I only noticed because the list height differed (5389px vs 2046px). The numbers above are from stashing the change, rebuilding, and measuring both builds against the same buffer.
Left to do — keeping this open. The remaining +146 MB is the per-image cost: ~70 visible emotes that are still animated GIFs at 500x500, where Chromium holds decoded frame buffers. That is fix 2 in the description — downscaling and caching emotes in the daemon, which would also stop re-fetching up to 1 MB per emote from files.kick.com on every session. Bigger job than this one, because the emotes are animated and re-encoding has to preserve that.
First of the two fixes is in: the grid is windowed.
https://git.salastil.com/Salastil/moho/commit/0d764e2
A section now builds only the rows near the viewport. The outer div holds the sections full height whatever is inside it, so the sections below keep their place and the list is exactly as long as it was — 2046px before and after, on the same buffer.
A/B on the same buffer and the same app state, one renderer each, launched from the two AppImages back to back:
| | before opening | picker open | after scrolling the whole list |
| --- | --- | --- | --- |
| cells built — old | 0 | 418 | 418 |
| cells built — **new** | 0 | **59** | **68** |
| decoded image bytes — old | 4.8 MB | 55.2 MB | 55.2 MB |
| decoded image bytes — **new** | 4.8 MB | **25.1 MB** | **6.4 MB** |
| renderer RSS — old | 227 MB | | 538 MB |
| renderer RSS — **new** | 228 MB | | **374 MB** |
So the picker costs **+146 MB instead of +311 MB**, and the third column is the one that matters: scrolling the entire list used to leave every emote decoded, and now leaves a screenful. What the picker costs no longer scales with how many emoji the account can reach — a user in twenty Kick channels now pays the same as one in two.
Sizes are measured off the laid-out grid rather than copied from `app.css`, so there is no second copy of the cell height to drift.
Two things worth recording about getting here:
- my first attempt still built a screenful *per section*, because clamping with `max(0, -top)` reads every off-screen section as though the viewport sat on its first row. Sections below the fold each built a screenful they never showed.
- my first before/after was not like-for-like — the two runs had opened the picker on different buffers, and I only noticed because the list height differed (5389px vs 2046px). The numbers above are from stashing the change, rebuilding, and measuring both builds against the same buffer.
**Left to do — keeping this open.** The remaining +146 MB is the per-image cost: ~70 visible emotes that are still animated GIFs at 500x500, where Chromium holds decoded frame buffers. That is fix 2 in the description — downscaling and caching emotes in the daemon, which would also stop re-fetching up to 1 MB per emote from `files.kick.com` on every session. Bigger job than this one, because the emotes are animated and re-encoding has to preserve that.
Kick has no smaller URL to ask for, so the daemon fetches each emote once and shrinks it to 64px on its longest edge, animation intact. On real emotes:
72 ms is the release figure; debug is 2.8 s, which matters before trusting a number out of a test run.
Result, same buffer, one renderer, cost of opening the picker and scrolling the whole list:
picker cost (RSS)
decoded on open
original
+311 MB
55.2 MB
windowed only
+146 MB
25.1 MB
windowed + shrunk (warm cache)
+33 MB
6.1 MB
241 emotes take 21 MB shrunk, against roughly 120 MB at full size — so the bandwidth half is fixed too, and only on first sighting.
The rewrite happens in one place (lib/emotecache), which the formatter and the picker both go through, so messages get this as well as the picker — a busy Kick channel was decoding the same 500x500 GIFs in the timeline.
Two things I got wrong on the way, recorded so they are not repeated:
windowing made the picker mount short and grow to its 360px cap a frame later, while its placement was computed once against the short measurement. 134px of it ended up below the bottom of the screen, permanently. Fixed by making placement follow the element size and clamping the top at both ends. Reported by the user, not caught by me — and it would not have been caught by any check I had, since there is no renderer test runner in this project. Worth its own issue.
my first draft asked the daemon for every emote the picker could show, which on this account is several hundred megabytes of downloads to open a menu. Cells now ask for their own as they are built, which the windowing already makes viewport-shaped.
Second fix is in — emotes are shrunk daemon-side. #211 is done.
- daemon: https://git.salastil.com/Salastil/nobilis/commit/8cf3578
- window: https://git.salastil.com/Salastil/moho/commit/db802f0
- the positioning regression this caused on the way: https://git.salastil.com/Salastil/moho/commit/5d9ef82
Kick has no smaller URL to ask for, so the daemon fetches each emote once and shrinks it to 64px on its longest edge, animation intact. On real emotes:
```
500x500 35f 1023 KB -> 64x64 35f 82 KB 72 ms
224x128 29f 250 KB -> 64x36 29f 54 KB 12 ms
decoded frame buffers: 33.4 MB -> 0.55 MB
```
72 ms is the release figure; debug is 2.8 s, which matters before trusting a number out of a test run.
**Result, same buffer, one renderer, cost of opening the picker and scrolling the whole list:**
| | picker cost (RSS) | decoded on open |
| --- | --- | --- |
| original | +311 MB | 55.2 MB |
| windowed only | +146 MB | 25.1 MB |
| **windowed + shrunk (warm cache)** | **+33 MB** | **6.1 MB** |
241 emotes take 21 MB shrunk, against roughly 120 MB at full size — so the bandwidth half is fixed too, and only on first sighting.
The rewrite happens in one place (`lib/emotecache`), which the formatter and the picker both go through, so **messages get this as well as the picker** — a busy Kick channel was decoding the same 500x500 GIFs in the timeline.
Two things I got wrong on the way, recorded so they are not repeated:
- windowing made the picker mount short and grow to its 360px cap a frame later, while its placement was computed once against the short measurement. 134px of it ended up below the bottom of the screen, permanently. Fixed by making placement follow the element size and clamping the top at both ends. **Reported by the user, not caught by me** — and it would not have been caught by any check I had, since there is no renderer test runner in this project. Worth its own issue.
- my first draft asked the daemon for every emote the picker could show, which on this account is several hundred megabytes of downloads to open a menu. Cells now ask for their own as they are built, which the windowing already makes viewport-shaped.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Measured with CDP against a running build. Opening the emoji picker once, on a freshly started app:
Two things to read out of that:
useMemowill touch it.Where it comes from. Decoded-image inventory with the picker open:
files.kick.comcdn.discordapp.comKick emotes are fetched at full resolution and drawn at roughly 32px. One of them:
files.kick.com/emotes/5769661/fullsizeis 500x500, 1,048,490 bytes encoded, 1 MB decoded, for a single emote.Discords path already does this correctly —
discordEmojiUrl(e.id, 48)inEmojiPicker.tsx:425asks for 48px and costs 0.1 MB across the whole picker.Kick has no smaller variant. Probed directly; only
fullsizeexists:So this cannot be fixed by picking a different URL, the way Discord is.
Bandwidth is the other half. 408 emotes at up to 1 MB each are fetched straight from
files.kick.com, bypassing the daemons media cache entirely (Matrix, Discord and Sneedchat media all come back throughmoho-media://; Kick emotes do not). That is potentially hundreds of MB per session, re-fetched every time.Two fixes, either of which helps, both of which are UX-neutral:
moho-media://at ~64px like other media. Fixes the bandwidth too. Careful with the hover preview (EmojiPicker.tsx:392), which deliberately shows a larger image — that one should keep the full-size source.Worth noting what is not the problem, so nobody optimises it: the JS heap is 19.6 MB and flat, DOM nodes go 2,097 to 2,911, and message retention is already bounded. The renderers remaining ~260 MB is Chromiums own floor and is not app-addressable.
First of the two fixes is in: the grid is windowed.
https://git.salastil.com/Salastil/moho/commit/0d764e2
A section now builds only the rows near the viewport. The outer div holds the sections full height whatever is inside it, so the sections below keep their place and the list is exactly as long as it was — 2046px before and after, on the same buffer.
A/B on the same buffer and the same app state, one renderer each, launched from the two AppImages back to back:
So the picker costs +146 MB instead of +311 MB, and the third column is the one that matters: scrolling the entire list used to leave every emote decoded, and now leaves a screenful. What the picker costs no longer scales with how many emoji the account can reach — a user in twenty Kick channels now pays the same as one in two.
Sizes are measured off the laid-out grid rather than copied from
app.css, so there is no second copy of the cell height to drift.Two things worth recording about getting here:
max(0, -top)reads every off-screen section as though the viewport sat on its first row. Sections below the fold each built a screenful they never showed.Left to do — keeping this open. The remaining +146 MB is the per-image cost: ~70 visible emotes that are still animated GIFs at 500x500, where Chromium holds decoded frame buffers. That is fix 2 in the description — downscaling and caching emotes in the daemon, which would also stop re-fetching up to 1 MB per emote from
files.kick.comon every session. Bigger job than this one, because the emotes are animated and re-encoding has to preserve that.Second fix is in — emotes are shrunk daemon-side. #211 is done.
Kick has no smaller URL to ask for, so the daemon fetches each emote once and shrinks it to 64px on its longest edge, animation intact. On real emotes:
72 ms is the release figure; debug is 2.8 s, which matters before trusting a number out of a test run.
Result, same buffer, one renderer, cost of opening the picker and scrolling the whole list:
241 emotes take 21 MB shrunk, against roughly 120 MB at full size — so the bandwidth half is fixed too, and only on first sighting.
The rewrite happens in one place (
lib/emotecache), which the formatter and the picker both go through, so messages get this as well as the picker — a busy Kick channel was decoding the same 500x500 GIFs in the timeline.Two things I got wrong on the way, recorded so they are not repeated: