Skip to content

Add new experimental ChatRoom-React workload - #583

Draft
canova wants to merge 13 commits into
WebKit:mainfrom
canova:chat-room-workload
Draft

Add new experimental ChatRoom-React workload#583
canova wants to merge 13 commits into
WebKit:mainfrom
canova:chat-room-workload

Conversation

@canova

@canova canova commented Aug 11, 2026

Copy link
Copy Markdown

Hey everyone! This PR adds a new workload called ChatRoom-React. It's similar to chat room applications like Slack/Element/Discord etc. The workload exercises room switching. It's built using React + Vite. It contains 40 rooms of 1500 deterministically generated messages each.

There are 3 times steps:

  • ScrollTimeline reads back through history a page at a time (rows are recycled and cached heights reused), then covers it in long strides the way dragging the scrollbar does (windows replaced outright, correction runs against estimates).
  • SwitchRooms clicks through the sidebar 30 times. Each timeline is keyed by room id, so a switch fully unmounts the old one and mounts the new one.
  • LoadOlderMessages scrolls past the start of the loaded history 10 times, which fetches the next page and inserts it above the viewport. That's the case browsers ship scroll anchoring for, and nothing else in Speedometer exercises it.

Note that I captured some profiles of this workload vs Slack vs Element(Matrix) and it looks like they are similar in terms of the things that they spend time on.

@netlify

netlify Bot commented Aug 11, 2026

Copy link
Copy Markdown

Deploy Preview for webkit-speedometer-preview ready!

Name Link
🔨 Latest commit 9581719
🔍 Latest deploy log https://app.netlify.com/projects/webkit-speedometer-preview/deploys/6a7c9d7c6f3f9a00087e7b64
😎 Deploy Preview https://deploy-preview-583--webkit-speedometer-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@canova
canova force-pushed the chat-room-workload branch from b37263a to de21677 Compare August 11, 2026 16:50
canova added 13 commits August 12, 2026 18:21
Exercises chat-room switching in a two-pane chat UI, so timeline
teardown/rebuild cost can be profiled. Original React + Vite code with
deterministic, server-free fixtures and no third-party assets. Experimental, so
it is off by default and does not affect the official score.

SwitchRooms uses flushSync so each programmatic click commits one room switch.
flushSync stopped React 18 batching a burst of programmatic clicks into one
deferred render, but it is not what a real app does and it bypasses the
concurrent scheduler this workload exists to exercise. The switch is a plain
state update now, with the step yielding a task between clicks, which makes the
suite async.

The yield is a MessageChannel round-trip: an async step is awaited inside the
measured window, so setTimeout(0)'s clamp would be reported as workload time,
and a microtask would keep all 30 switches in one task. Driving 30 switches:
microtask 329ms, flushSync 348ms, MessageChannel 397ms, setTimeout(0) 427ms.
Chat clients open a room at its most recent message; the timeline mounted at
offset 0. A layout effect now assigns scrollTop = scrollHeight after commit, so
SwitchRooms measures mount plus the scroll to bottom.

Not flex-direction: column-reverse, which inverts scrollTop semantics in ways
browsers have disagreed on, and later steps drive scrollTop explicitly.
…ctive

Bodies were a single plain sentence, so rows were near-uniform in height and a
windowing timeline built on that would inherit an unrealistically easy height
model. They are now structured blocks -- paragraphs, code, blockquotes, lists,
with inline code, links and @mention/#room pills -- giving 48 distinct row
heights over 150 rows. Generation moves to a Math.imul integer hash so fields
derive independently from the seed.

Reaction pills, room pills and reply quotes become real controls, with state in
components so the fixtures stay immutable across iterations. The reply jump
assigns scrollTop from measured rects rather than scrollIntoView, keeping
smooth scrolling out of a timed step.

Also fixes senders never repeating, so grouping never triggered, and room pills
slugifying 20 names against 40 rooms.

SwitchRooms grows from ~245ms to ~450ms, the cost of mounting 150 rich rows.
The app was a read-only viewer. There is now a text input and a Send button
below the timeline; sending appends to the end and scrolls to bottom.

Sent messages live in Timeline state rather than the fixtures, so the generated
data stays immutable and rooms do not grow across iterations. Timestamps
continue from the room's last message rather than reading the clock. Enter is
an explicit onKeyDown, since a dispatched keydown triggers no default action.

No new step: SwitchRooms is unchanged, so this is app surface only.
Message bodies gain an image attachment and an unfurl card block, covering
about 11% of messages. Artwork is SVG data URIs built from a palette and shape
variant by a new graphics module, so the workload still ships no image assets.
The URIs are memoized on a reduced palette/variant key -- with the raw hashes
every message got a distinct gradient id, so the browser decoded 652 images
instead of 127.

Images carry explicit width and height, or a row would resize once the image
decoded and invalidate the height cache. Also fixes bullet lists repeating a
line about 13% of the time.

Row heights now span 24px to 278px with 58 distinct values, up from 229px and
48. SwitchRooms grows from ~367ms to ~442ms.
Speedometer has almost no scroll coverage: across the 32 default suites the
only scrolling inside a timed step is a single scrollIntoView. A chat timeline
is the natural vehicle for that, but only once it is windowed.

Rooms grow from 150 to 1500 messages and the timeline mounts ~15 rows. The
virtualizer is hand-rolled, since a generic one assumes a height it can know up
front and a chat row's depends on how its text wraps. The new height model
estimates, measures, caches and corrects, keeping prefix sums so an offset can
be turned back into a row index without touching the DOM. Three constraints it
has to respect -- the scroller's height lagging the model by a commit, the row
estimate having to sit under the real mean, and overflow-anchor having to be
off -- are documented at their sites.

ScrollTimeline is the new step, driving explicit offsets back through history
and then in long strides. SwitchRooms is unchanged but costs ~4.5x less.
PageElement gains scrollTop/scrollHeight/clientHeight, since scrollIntoView
cannot reach an unmounted row.
Preserving the scroll position while content is inserted above the viewport is
the chat and feed pattern nothing else in Speedometer exercises, and the one
real clients visibly get wrong. A room now opens on the newest 300 of its 1500
messages and fetches 120 more whenever a scroll lands within a viewport of the
top.

The height model grows at the front as well as the end: a row keeps its index
when older history arrives, so measured heights stay valid and only the offsets
shift, uniformly, which makes the correction exact. Anchored by hand rather
than with the browser's scroll anchoring, which would apply a second correction
for the same insertion.

LoadOlderMessages is the new step; ScrollTimeline stops one stride short of the
top so the two stay independent. Following a reply quote can now load history
too.
A profile shows a ~14ms nursery collection landing inside a timed step in three
of the thirty measured windows, and those three are the slowest iteration of
their step. The fixture build is not itself measured, but it pushes ~117MB of
permanently-live data through the nursery per iteration, and a minor GC costs
what it has to promote.

The fixture now shares what it can: bodies come from a pool of 2048 rather than
one per message, reaction rows collapse to the nine distinct values they always
had, and timestamps and initials stop being rebuilt 60,000 times. The pool is
drawn from the same seed space, so the spread of row heights is unchanged. The
per-message preview string is gone; nothing rendered it.

Building the fixtures drops from 197ms to 31ms and retains 14MB instead of
117MB, taking the unmeasured prepare phase from 335ms to 48ms per iteration.
Turning a message id back into a row index is only needed when a reply quote is
clicked, and neither a scroll nor a room switch clicks one. Building it during
render rebuilt a map over the room's whole history on every switch, inside the
timed step, where the profile puts it at 8.8% of SwitchRooms -- the largest
single piece of the app's own JavaScript in the run.

With the fixture sharing beneath it, this takes the content process from 1.04GB
of net allocation to 459MB, minor collections from 77 to 16, and the ones inside
a timed step from 14.1ms to 7.2ms on average. What is left is React's own
reconciliation filling the nursery, which is the work the steps exist to
measure.
Per-render chrome that chat clients carry and this workload had none of: a
labelled rule where the conversation crosses a day, and a marker above the
first message the reader has not seen.

Neither is a row of its own -- a row here is one message, and the height cache,
spacers and offset search are all indexed by message index -- so both are drawn
inside the row they belong to, keyed off the message array rather than the
mounted window.

Day lengths now vary between 8 and 56 messages. At a fixed size the boundary
always landed just above the newest page, so no room opened with a separator in
view; twenty of the forty now do.

Cost is noise: 100.65ms -> 105.00ms over ten Firefox iterations against a
+/-20ms spread.
Output of `npm run build` in suites-experimental/chat-room. Build artifacts
only, so it can be dropped and regenerated whenever the source commits below it
change.
@canova
canova force-pushed the chat-room-workload branch from de21677 to 9581719 Compare August 12, 2026 16:21
@canova

canova commented Aug 12, 2026

Copy link
Copy Markdown
Author

@camillobruni Hey, wanted to point out something and make sure we're on the same page, since I'm doing something a bit differently in this workload. I added a yieldTask helper here and I'm using it to yield a task between the interactions inside a measured step (for example here), so React commits the pending update before the next click or scroll is driven.

The other async workload "Responsive-Design" just calls page.layout() after each click, but that isn't a substitute here. it's a layout barrier, not a scheduler flush. When I dispatch the scroll, React's handler runs synchronously and calls setState, but the render and commit get handed off to React's scheduler as a posted task. So the new rows aren't in the DOM yet, and forcing layout would just re-layout the tree that was already there. Without the yield all 20 channel switches batch into a single render at the end, and the step measures 1-2ms instead.

It's a MessageChannel round-trip because a posted task is the cheapest way to get past the end of the current task, which a microtask can't do at all. setTimeout(0) and rAF work too, but these steps are async, so the timer's clamp or a full frame lands inside the measured window and gets reported as ChatRoom's time. 30 channel switches I measured 397ms with MessageChannel vs 427ms with setTimeout(0).

Let me know what you think! I also lowered some of the iteration counts in the steps, so they're a bit more on par with the other workloads.

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