Skip to content

Server components: a markup hole that re-emits re-renders every row of its list on every sweep #4056

Closed

Description

@ryansolid

A markup hole that emits more than once re-renders its whole value on every sweep. This applies to live addresses, plain responses reading a standing source, and the document face's sc:live ops. Server <For> / mapArray re-runs mapFn for every row on every pass, which is deliberate today (packages/solid/src/server/signals.ts, "never re-runs in isolation"). The sweep then ships the hole's full HTML unless it is byte-identical (packages/web/src/server.ts, sink.hole).

The result is that a list inside a streamed hole costs O(list) server render per write. Over a stream, that is O(list × writes).

Measured on examples/chat

Setup: simulated model, 12 KB replies, about 258 writes per reply, one tab, client CPU at 4x. The local instrumentation is a small patch, not committed.

prior replies server CPU per reply of which markdown wire (brotli q4) client script / style+layout
0 1.5 s 0.7 s 43 KB 266 / 403 ms
1 2.5 s 1.8 s 27 KB 196 / 328 ms
3 5.1 s 4.2 s 28 KB 262 / 416 ms
5 7.4 s 6.4 s 27 KB 339 / 348 ms
  • Each finished reply adds about 1.2 s of server CPU to every later reply. The finished-messages hole re-renders all of the history on every write, including markdown and highlighting, and then discards the result as identical. Each open tab pays this separately.
  • The wire stays flat because the streaming brotli window absorbs re-sent prefixes. That only holds within one response and with a large window. With gzip's 32 KB window, a 60 KB hole re-sent per write measured 11.3 MB, versus 49 KB for an append-only stream.

Direction

Make server For behave like the client's in renders that can re-emit a hole:

  • Keep rows by key across passes and reuse the last HTML of unchanged rows. Only new and changed rows render.
  • Data needs stable identity across snapshots. For example, reconcile each snapshot into a store or projection, as the client does. Today watchThread clones every message on each write.
  • The wire can then carry per-row ops (insert, replace, remove, move) instead of the whole hole. That is the "diff payload", with no string diffing needed.
  • One-shot document SSR keeps today's path.

A growing single row (the message being written) still re-renders in full. Splitting it into keyed blocks would reuse the same mechanism.

Activity

  1. added 2 commits that reference this issue on Oct 14, 2026
  2. ryansolid commented on Oct 14, 2026

    @ryansolid
    MemberAuthor

    Closing. #4057 fixed the cost this issue measured: a live-hole sweep's server For re-rendered every row, and server CPU per chat reply now stays flat as history grows instead of climbing.

    What's left is client cost, and it's tracked separately:

    • The whole-frame walk after every hole apply: its own PR, in progress.
    • Row ops (sending only the changed rows): on chat the wire stays flat at about 27 KB br per reply under streaming brotli, so bytes aren't the issue there. But the client still parses and morphs every unchanged row in a re-sent hole. On a page that is one big hole ticking (the server DBmon example), that parse may be what costs most. I'll measure it there once the walk fix lands, and open a new issue for row ops if it shows up.
    • An append patch for chat's growing message: later.

    Generated by Claude Code

  3. ryansolid commented on Oct 14, 2026

    @ryansolid
    MemberAuthor

    The DBmon check came back: row diffs are the lever there (the client morph of unchanged rows, not bytes). Tracked in #4064.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions