Skip to content

webui transport: SSE vs WebSocket — what each costs in the current architecture #24

Description

@weekbin

Summary

A request came in to move the webui state stream from SSE to WebSocket on the
grounds that "SSE cannot be bidirectional, and /goal / /permission need the
client to push requests to the Agent."

After reading the current implementation, that premise does not hold for those
two features
: the upstream half of the conversation is already HTTP POST, and
it stays HTTP POST under WebSocket. This issue documents where SSE actually
costs us, where it does not, what a WebSocket migration would buy, what it would
cost, and what evidence would settle the question.

No code change is proposed here. This is a decision record, so the next person to
look at the transport does not have to re-derive it.

The architecture as it actually is

/api/events is a snapshot channel, not an event log.

packages/webui/server/lib/state-bus.js:

  • _schedulePush (line 421) diffs each payload against the last one written to
    that client and skips the write when nothing changed.
  • Writes inside STATE_PUSH_THROTTLE_MS (line 380, env-tunable) coalesce — N
    broadcasts collapse to one write, last snapshot wins.
  • When the stored res differs from the current one, the diff cache is
    discarded and a full snapshot is sent (line 430ff). That is the
    fresh-connection and the reconnect path.
  • makeClientState (line 35) carries workspace, model, context, usage,
    permissions, session title, online count, read-only and token flags.

The message transcript reaches the same snapshot, but it is sourced by a
server-side poll of the runtime SQLite DB, not by the ACP event stream:
server/lib/transcript-sync.js defaults to DEFAULT_TRANSCRIPT_SYNC_MS = 4000
and transcriptChanged uses a length + last-line comparison specifically to
avoid hashing 200KB every tick (its comment at line 40).

There are two long-lived SSE connections: /api/events (state) and
/api/alerts (anomaly channel). The client's frame contract lives in
webapp/lib/sse.ts, deliberately separated from the React store so it is
testable without a DOM.

Where the "bidirectional" concern actually lands

The client-to-server half is 26 endpoints in server/app.js — 22 GET plus 26
POST/PUT/DELETE:

POST /api/send                    POST /api/permissions
POST /api/stop                    POST /api/auth/decision
POST /api/cmd                     POST /api/answer
POST /api/set-model               POST /api/protocol/set-mode
POST /api/protocol/cancel         POST /api/protocol/set-config-option
POST /api/sessions/*              POST /api/fs/mkdir
POST /api/upload                  POST /api/workspace[/pick]

The permission flow is already a complete request/response round trip. From
server/lib/authorize.js:1-7:

authorize(action, ctx, opts) blocks on user confirmation; the UI pops a modal
listening for the needs_authorization SSE event. The user accepts or
declines; the server resolves the pending promise via POST
/api/auth/decision.

The server holds the pending promise keyed by request id, SSE pushes the
request down, the client POSTs the decision back, the server resolves. That is
the standard "downstream push + upstream POST" shape, and it does not use the SSE
connection for the reply. A WebSocket does not change it.

One auth detail that is often cited in WebSocket's favour does not survive
contact with the browser.
server/lib/auth.js:10 explains that the token
travels as ?token=<value>:

?token=<value> query string (for SSE EventSource — browsers cannot set
headers on EventSource)

The browser WebSocket constructor cannot set an Authorization header
either. The existing withClientQuery() helper is reused unchanged, and the
token-in-URL exposure is identical. WebSocket buys nothing on auth.

What SSE genuinely costs

  1. A three-place tax per new control flow. Every "server asks the client"
    flow needs a named SSE event added to the event table, a POST endpoint, and a
    pending-promise map. needs_authorization → /api/auth/decision is one
    instance; the next one pays the same toll. It is an ergonomics cost, not a
    capability gap, but it compounds as control flows multiply.

  2. No cross-transport ordering. A POST response and an SSE snapshot travel
    on different connections, so the client can observe them in either order. In
    principle a client can see a decision take effect in its own UI while the
    pushed state still reflects the pre-decision value. A single WebSocket
    connection gives strict per-connection ordering. I have not reproduced a
    user-visible instance of this — it is a structural argument, not a filed bug.

  3. Two of six HTTP/1.1 per-origin connections are held open by
    /api/events and /api/alerts. Plenty of headroom for a local desktop app,
    but it is a constraint rather than a non-issue, and it does not exist if the
    server is ever fronted by HTTP/2.

  4. No server-initiated request primitive. If the server ever needs to ask
    the client something and await the answer on the same channel, today that
    must be modelled as a named event plus a POST. More awkward than a
    request/response frame pair, though not impossible.

What WebSocket would actually buy

  • One connection instead of two-plus-N. Fewer sockets and less per-request
    header overhead; relevant only at high event volume.
  • Strict ordering between command results and pushed state (point 2 above).
  • A request/response frame pair on one channel, removing the three-place tax
    for future control flows.
  • Binary framing, if the payload ever needs it. It does not today — the
    stream is JSON snapshots.

What WebSocket would cost

The migration has already been attempted and reverted, which is the most
load-bearing fact in this issue.

  • f1677c8 (merged via feat(webui): ws event stream, worker embed transport, capability negotiation #20) was titled refactor(webui): remove SSE, switch to WebSocket /api/stream + REST. Note the shape: WebSocket for the stream,
    REST for commands
    — the same upstream/downstream split as today, only the
    downstream transport changed.
  • 8698ee0 (Feat/webui nextjs framework #18) deleted the implementation 7 hours later:
    server/lib/ws-frame.js (399 lines), server/lib/ws-server.js (288 lines),
    and three test files (1140 lines). /api/stream does not exist on main
    today; the state stream is SSE.
  • server/app.js documents the remaining direction as incremental: the SSE
    channel "is not migrated yet", with SSE migration marked P2.

So the cost is a measured one: ~1800 lines of framing, ring buffer and tests,
to be hand-written and then hand-maintained.
The compensation is that
EventSource gives reconnection, retry:, and header handling for free, while a
WebSocket client must implement backoff and reconnection itself.

The resume argument does not survive this design

The WebSocket work included seq-based resume with a ring buffer and
resume-underrun rollback. That machinery is well built, but it buys nothing
for a snapshot channel.
Because /api/events sends the current full state on
every (re)connect, a dropped connection is already recovered by the next
snapshot — there is no missed-delta log to replay. Frame-level resume would only
matter if the stream became an event log, which is a different product
decision, not a transport change.

The thinking effort precedent

Worth recording as a design-intent data point: the feat/webui-react branch
shipped a five-option thinking-effort selector whose setThinking only updated a
local slice and re-posted the unchanged model
(core/services/model-service.ts:228-239) — the value never reached the server
or the engine. A control affordance whose return path is not wired is worse than
no affordance, and it is easy to ship one by accident. Any transport change should
be held to the same standard: every frame must have a verified consumer.

Recommendation

Do not migrate the transport to WebSocket to unblock /goal or
/permission.
Both already have working upstream paths, and switching the
stream transport would not change them.

If /goal is the actual need, the gap is a missing endpoint, not a missing
transport: cs.goal is written in exactly one place today,
server/routes/debug.js:23-25, behind a DEBUG_INJECT !== "1" gate whose stated
purpose is mocking state for browser UI work. routes/protocol.js:77 passes
goal_mode through without recording it. A first-class POST /api/goal shaped
like authorize — hold a promise, apply, pushStateFor — would give goal a
real round trip on the current transport, at a fraction of the cost.

A transport migration is worth reopening only if a concrete requirement shows
up that the snapshot-plus-POST shape cannot meet. Before that, the two questions
worth answering are:

  1. Was Feat/webui nextjs framework #18's deletion of feat(webui): ws event stream, worker embed transport, capability negotiation #20 deliberate? No commit message or issue records
    the reason. If the WS removal was accidental rather than a considered
    decision, this issue should be rewritten around restoring feat(webui): ws event stream, worker embed transport, capability negotiation #20's work; if it
    was considered, the reasoning should be recorded so it does not get
    re-proposed.
  2. How many server-initiated control flows are expected next? The
    three-place tax is the strongest real argument for WebSocket, and its weight
    scales with that count. Today there is one (needs_authorization).

What I did not measure

Stated plainly so this is not over-read:

  • No load test comparing SSE snapshot throughput against WebSocket frame
    throughput at realistic event rates.
  • No measurement of current reconnect behaviour under induced drops, so the
    practical cost of a gap during a reconnect is unknown.
  • Point 2 of "what SSE costs" is structural; I have not observed it failing.
  • Live-service and cross-platform acceptance are out of scope for this
    repository's offline verification.

References

Activity

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