Skip to content

Realtime: stop rows popping out and back in - #16

Merged
HomemadeToast57 merged 2 commits into
mainfrom
jack/realtime-suppress-reload-race
Jul 27, 2026
Merged

HomemadeToast57 merged 2 commits into
mainfrom
jack/realtime-suppress-reload-race

Conversation

@HomemadeToast57

Copy link
Copy Markdown
Collaborator

The bug

Add several todos quickly and some vanish for a moment, then come back. Measured on a live app — a row the user had already added was absent from the screen for 200ms:

351ms  n=4  [rapid-3, rapid-2, rapid-1, wire-test-A]   all visible
451ms  n=3  [rapid-2, rapid-1, wire-test-A]            rapid-3 VANISHES
651ms  n=4  [rapid-3, rapid-2, rapid-1, wire-test-A]   back, 200ms later

It's a lost-update race, not latency. The doorbell says "something changed" without saying which row, so the app reloads its whole list on every ping. A reload issued while the app's own inserts are still in flight comes back without them, and replaces what's on screen with a snapshot missing rows the user already added.

An instantaneous network wouldn't help — the snapshot legitimately lacks uncommitted rows. Speed cannot fix this.

The fix

The SDK is the one place that sees both sides of the race: it issues every write and owns the broadcast handler. So it withholds the reload signal while this client's writes are landing, and fires once when they drain.

Your three-add case now resolves as: three writes in flight → every ping held → last write settles → one reload, which returns [A,B,C] and matches what's already on screen. Nothing moves.

Plus a short trailing coalesce, since the trigger fires per changed row — a bulk write produces N pings for one logical refresh. And a ceiling on the hold, so continuous local editing can't starve the app of other users' changes.

Why this approach

Three ways to fix this were on the table. This one was chosen because it's the only one that fixes apps that already exist.

needs new app code? reaches existing apps?
this — hold the reload no yes, on next publish
id in payload + merge-by-id yes no
live-list store / hook yes no

No new API, no prompt rewrite, no server change, no version gating. Existing apps running subscribe(() => load()) simply stop flickering when they next publish.

What it does NOT fix

A ping still triggers a full table reload — just never a racing one. The wasted bandwidth is untouched, and that's what the id-in-payload and live-store work address. Those stay worth doing; they're tracked separately and are additive to this.

Implementation note

Writes are counted in proxyFetch, not in the entities layer, because both styles we teach — supabase.from(...).insert(...) and bool.entities.x.create(...) — funnel through that fetch and only one goes via entities. There's a test for the raw-supabase path specifically.

Tests

9 new tests that make the race deterministic: writes park until the test releases them, so "in flight" is decided rather than timed. Covers the three-rapid-writes case, burst coalescing, a failed write not leaking the counter, reads never holding anything back, unsubscribe cancelling a pending reload, and the raw-supabase write path.

117 pass, typecheck clean.

🤖 Generated with Claude Code

HomemadeToast57 and others added 2 commits July 27, 2026 13:43
Fixes rows popping out and back in when you add several quickly. Measured on a
live app: a row the user had already added was absent from the screen for 200ms
before reappearing.

It is a lost-update race, not latency. The doorbell says "something changed"
without saying which row, so an app reloads its whole list on every ping. A
reload issued while the app's own inserts are still in flight comes back WITHOUT
them, and replaces what is on screen with a snapshot missing rows the user has
already added. An instantaneous network would not help: the snapshot legitimately
lacks uncommitted rows, so speed cannot fix it.

The SDK is the one place that sees both sides of the race — it issues every write
and it owns the broadcast handler. So it now withholds the reload signal while
this client's writes are landing and fires once when they drain, plus a short
trailing coalesce so a burst collapses into one reload (the trigger fires per
changed ROW, so a bulk write produces N pings for one logical refresh). A ceiling
bounds the hold, otherwise continuous local editing would starve the app of other
users' changes.

Chosen over the alternatives because it needs no new API, no prompt rewrite, and
no server change — which means it fixes apps that ALREADY EXIST, on their next
publish. Every other approach (a row id in the payload, a merge-by-id helper, a
live-list store) requires new app code, so it only helps newly generated apps.
Those remain worth doing for the wasted-bandwidth problem, which this does not
address: a ping still triggers a full table reload, just never a racing one.

Writes are counted in proxyFetch rather than in the entities layer because both
styles we teach — supabase.from(...).insert(...) and entities.x.create(...) —
funnel through that fetch, and only one of them goes through entities. A test
covers the raw-supabase path for exactly that reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cuts the release carrying the reload-race fix. Version 0.2.0-next.18 was bumped in
the repo but never published (npm's `next` tag is still at next.17), so releasing
next.19 ships both it and this change.

The version string is what routes the publish: because it contains a hyphen,
.github/workflows/publish.yml derives dist-tag `next` from it, so this lands on the
canary channel only — invisible to `latest` and to caret ranges, so no production
app can pull it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@HomemadeToast57
HomemadeToast57 merged commit 5aee87d into main Jul 27, 2026
3 checks passed
@HomemadeToast57
HomemadeToast57 deleted the jack/realtime-suppress-reload-race branch July 27, 2026 18:53
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