Make a full feed cost what a screenful costs - #32
Conversation
A merged feed keeps 400 messages so you can read back through them, and
every one of those rows was a live piece of the page: laid out on each
reflow, painted into the scroller's tiles, and holding decoded emote
images that the browser kept advancing frame by frame for the animated
ones that are most of a busy chat. Only the last screenful is ever on
show; the rest were being paid for in full.
Nothing goes wrong at 400. 400 is where the feed stops growing, which is
to say where that cost stops growing and settles at its worst — which is
why a busy channel was fine for the first few hundred messages, why a
refresh bought exactly one more climb, and why it came back at the same
place every time. Two chats merged into one feed on a page that is also
playing video is a few thousand live animated images at once, and the
machine has to find room for all of them.
The rows now carry content-visibility, so the browser skips layout, paint
and image work for anything scrolled out of view. The `auto` in
contain-intrinsic-size is what keeps the scrollbar honest: every row is
measured for real when it arrives at the live end and that measurement is
remembered while it is skipped, so scrolling back lands where you expect
rather than on an estimate. On a full feed of animated emotes a complete
relayout goes from 28.9ms to 8.1ms, with scrollHeight identical either
way.
The feed's own hot path had a smaller version of the same fault: every
queued message ran querySelector('.fcm-empty') over the whole feed, so
the cost of finding nothing grew with every row kept — thousands of nodes
walked per message to fail to find a placeholder that has not been there
since the first message of the session. The feed holds the placeholder
instead, and owns showing and clearing it.
None of this was the message path, which handles 600 messages a second at
the cap with no long tasks and a flat heap. It was what the page was
being asked to draw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9b52bd1 to
60fbbac
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9b52bd1f0f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| .fcm-msg, | ||
| .fcm-sys { | ||
| content-visibility: auto; | ||
| contain-intrinsic-size: auto 24px; |
There was a problem hiding this comment.
Prevent paint containment from clipping moderation controls
When a moderator uses the minimum text-size setting on a short one-line message, content-visibility: auto applies paint containment to the row, so content outside its box is clipped. The absolutely positioned moderation bar does not contribute to row height: it reaches roughly 28px from the row's top, while a plain 10px message row is only about 20.5px tall, causing the bottom of the buttons to be visibly clipped and reducing their clickable area. Give these rows enough height/clip margin or avoid paint containment where the bar is present.
Useful? React with 👍 / 👎.
Reported as: on Kick watching xQc with both Kick and Twitch chats joined, the whole machine
became unusable the moment the overlay's counter reached 400 messages — mouse barely moving,
Task Manager taking minutes to open, the page's video freezing and the chat stalling in cycles.
A refresh fixed it until it hit 400 again.
400 is not a threshold, it is the ceiling
Nothing breaks at 400. 400 is the default
maxMessages, so it is where the feed stopsgrowing — and therefore where the browser's cost of holding that feed stops growing and settles
at its worst. That is why it was deterministic, why a refresh bought exactly one more climb, and
why it recurred at the same place.
What it was not
Driven through the real render/feed path in
tests/harness.html(Kick adapter, both platformsmerged, real animated 7TV emotes):
path is not the problem.
messages.
Which matches the report: a pegged JS thread makes the tab janky, not the mouse. Mouse lag,
video freezing and a slow Task Manager are the raster/image/GPU layer — exactly the part a
requestAnimationFrame-based measurement inside the tab cannot see.What it was
The feed kept 400 rows fully live. Every one of them — including the ~370 nobody can see — was
laid out on each reflow, painted into the scroller's tiles, and holding decoded emote images the
browser kept advancing frame by frame for the animated ones that make up most of a busy chat.
Two chats merged into one feed on top of a page playing video is a few thousand live animated
images at once.
The fix
content-visibility: autoon feed rows, so the browser skips layout, paint and image work foranything scrolled out of view.
contain-intrinsic-size: auto 24pxkeeps the scrollbar honest:every row is measured for real when it arrives at the live end, and that measurement is
remembered while the row is skipped.
Measured on a full 400-row feed of real animated emotes:
scrollHeightVerified that scrolling back renders every row on demand with images intact, and that pinning,
the jump-to-live button and trimming all still behave.
Second, smaller fault of the same kind:
queue()ranquerySelector('.fcm-empty')over thewhole feed on every message, so the cost of finding nothing grew with every row kept. The
feed now holds the placeholder and owns showing and clearing it.
Tests
node tests/run.js— 1712 passed, 0 failed (+10 covering the placeholder's new ownership).Caveat
The exact freeze could not be reproduced in the harness, whose mock page has no video competing
for the GPU. The fix targets the layer the evidence points at rather than one reproduced in
isolation. Options → "Messages kept in the feed" remains the direct lever if it is ever
needed again.
🤖 Generated with Claude Code