#4806·electric

TypeScript client: SSE up-to-date offset `${lsn}_0` regresses behind header `electric-offset` when the LSN carries multiple ops, causing an immediate-replay loop

Author: the-company-company-staging[bot]Created Sep 15, 2026Updated Sep 15, 2026

Summary

In SSE live mode the TypeScript client (1.5.27 and 1.5.28, main today) replaces the offset it received in the electric-offset response header (LSN_op) with ${global_last_seen_lsn}_0 derived from the up-to-date control message (helpers.ts getOffset, applied in shape-stream-state.ts ActiveState.handleMessageBatch: if (input.isSse && input.upToDateOffset) offset = input.upToDateOffset). Whenever the log holds more than one operation at that LSN, the derived offset is behind the header offset, so the next live request re-requests operations the client already holds. The server answers immediately with the same operations plus up-to-date, the derived offset is again LSN_0, and the client loops at network RTT for as long as the shape stays on SSE.

This is the concrete failure mode behind #2870 (deriving the offset from the LSN is a backend implementation detail). It is not theoretical: any multi-row transaction produces multiple ops at one LSN, and on Electric 1.8 shapes with subquery where clauses record move-in batches at LSN 1 (1_0, 1_1, …, 1_285 in our case).

Reproduction (Electric 1.8.0 server, client 1.5.27)

Shape: a table filtered by a subquery where, ~100 rows, move-ins recorded at LSN 1.

  1. Initial request ?offset=-1electric-offset: 1_285, body contains 96 rows and 97 move-in messages.
  2. Live request ?offset=1_0&live=true&live_sse=true&handle=… (what the client sends after deriving 1_0 from global_last_seen_lsn: "1") → responds immediately (~0.3 s): the same 96 rows, header electric-offset: 1_285, up-to-date with global_last_seen_lsn: "1". Repeating the request gives the identical response every time.
  3. Live request ?offset=1_285&live=true&live_sse=true&handle=… → holds the SSE connection open (~25 s, keep-alives), as expected.

Observed in one browser tab over 28 s: three such shapes re-requested 47, 44 and 39 times (~65 KB per response, ~1 MB total), each response re-applied on the client as ~96 identical updates.

Why the client's own fallback only sometimes hides it

ShapeStream counts SSE connections shorter than 1 s and falls back to long polling after 3 (#minSseConnectionDuration, #maxShortSseConnections). Long polling takes the offset from the header, so the loop stops after three iterations, with the side effect that the shape is permanently downgraded to long polling. Any consumer that relaxes that heuristic (we did, to keep hot shapes on SSE) turns the loop permanent. The fallback is masking a correctness bug in offset handling.

Proposed fix (client)

handleMessageBatch should never move the offset backwards: accept upToDateOffset only when it is strictly ahead of the offset already parsed from the electric-offset header (compare LSN, then op position; -1/now are always superseded). When the LSN is equal and the op position is lower, keep the header offset. PR follows.

Protocol question (extends #2870)

The deeper fix is the one @samwillis proposed in #2870: carry the real offset (LSN_op, not just the LSN) on the up-to-date control message, at least in SSE mode where no per-message header exists. Without it the client cannot know the op index after a batch and must trust the last header it saw; with it getOffset disappears. I would add the field on both transports for symmetry, but the SSE case is the one that currently breaks.

Environment

  • @electric-sql/client 1.5.27 (same code in 1.5.28 and main: shape-stream-state.ts:331, helpers.ts:68-71, client.ts:700-701)
  • Electric server 1.8.0, Postgres 17
  • Browser (Chromium) and Node 24 clients, both reproduce