Dialogue · Design notes · Perceived speed
← Design notes

Perceived speed & the slow-network playbook

To our users we're a chat app, judged against ChatGPT · WhatsApp · WeChat — but our server sits in Singapore and most users are in China, behind a ~200 ms wire. This note is one place: why the app felt slow (measured), what WeChat & WhatsApp do to stay responsive on bad networks (sourced from their engineering), a scorecard of our app against that playbook, and what's left. It folds together the former Perceived speed study and the Slow-network playbook.

status · shipped 2026-06-20The whole local-first stack is LIVE in production (chat.xbbapp.com, v179–v187). The app no longer waits on the wire: a one-round-trip cold open (/api/boot), a persistent per-user cache painted before any network, optimistic mutations + new-chat, prefetch, live-turn recache, access-loss eviction, a reliable send outbox (retry + tap-to-retry, idempotent /say), delta /replay sync, and value-aware/byte-budgeted cache sizing. The one frontier that remains is pure transport — the ~1.4 s cold TLS handshake — see §6 What's left.
It was never the server — it's the wire. A 1.5 KB file takes 1.4 seconds to fetch from the box; uvicorn does ≈ 0 ms of work. Every "slow" moment was the app waiting on a China→Singapore round-trip it didn't need to wait on. The fix was never "make the wire fast" — it's stop waiting on the wire: paint from a local copy first, reconcile in the background.

① The diagnosis — it was the wire

Timed against the live box (a client on the China path), each request, cold:

Metricchat.xbbapp.com (SG)localhost:8011
TCP connect (1 round-trip)~205 ms~0 ms
Cold request incl. TLS handshake~1.40 s~2 ms
Fetching a 1.5 KB static file1.38 sinstant
Server compute (uvicorn)~0 ms~0 ms

A 1.5 KB manifest taking 1.38 s proves the cost is round-trip latency + TLS setup, not payload and not the server. So the only thing that matters is how many times, and how synchronously, the app touches the wire. The original cold open chained ~5 serial round-trips before the first room painted — and fetched /api/rooms twice:

Cold open, BEFORE · each bar is a serial wait on the wire first room paints ≈ 2.3 s GET /api/me TLS + 1st round-trip ≈ 1.4 s GET /api/config GET /api/rooms GET /api/rooms ← fetched twice GET /api/notes fire-and-forget — doesn't gate paint /state + /replay parallel 0 1 s 2 s
Now collapsed to ONE round trip (/api/boot) painted over a warm cache — see §4. On localhost the same six calls totalled ≈ 6 ms; only the wire ever changed.

The symptoms (all now fixed)

What you feltWhat it waited onThe fix (shipped)
Chat rows load slowrefreshChats() wiped the list, then waited a round-trippersistent list cache painted instantly, deduped + parallel boot
Skeleton → bubblesswitchTo/state + /replay, in-memory cache lost on reloadpersistent room cache + prefetch → cached rooms open instantly
Notes load slowloadRoomNotes → blank pane until the round-tripnotes SWR — cached clips paint at once
Delete-note lagdeleteNote awaited the DELETE before removing the cardoptimistic — vanishes on tap, reconciles behind
"Caches gone after an update"in-memory Map + no-store GETs; SW cached the shell but bypassed /api/*data layer mirrored to localStorage per user; survives a reload + deploy

② The principle — answer to intent, not to the server

The same tap, two architectures. The old UI was frozen until the wire answered. Local-first, the UI answers the user's intent immediately and lets the server catch up — this is the shape of every fix below.

Same tap (open a chat / delete a note) — two architectures Was network-gated tap frozen — waiting on Singapore card vanishes / bubbles paint Now local-first · shipped tap updates instantly ↻ server confirms (background) 0 0.5 s 1 s ~1.4 s
Local-first puts the result at t = 0 and reconciles with the server later — the user never sees the wire. It started with the optimistic message echo; we extended the same pattern everywhere.

③ The playbook — what WeChat & WhatsApp do

Both apps run on hundreds of millions of bad connections. Strip away the branding and they converge on the same moves; this is the model we built toward.

A local store that is the source of truth (read it, don't wait on the wire); an optimistic UI where the user always knows where their message is; a durable outbound queue (a message is never lost, only delayed); and a warm, persistent connection with a cheap reconnect. The network becomes something the app reconciles with in the background — never something the user waits on.

1 · Local-first store

WhatsApp keeps every message in a local SQLite DB — the UI renders from it, works fully offline, reconciles in the background (local-first design). History is an append-only log; ordering is resolved by server timestamps (offline & sync). WeChat's local store plays the same role behind its Mars stack.

2 · Optimistic UI + a status lifecycle

The message appears instantly with a local id and a “pending” status, before any confirmation; then WhatsApp's ticks — the server has it, ✓✓ delivered, blue ✓✓ read — so you're never left guessing on a crawling link. The general pattern (Simon Hearne): animate on tap, run the request in parallel, and on failure queue a retry — only undo after several failures.

A message's life on a slow network — the user always knows where it is you type → send optimistic echo ⏱ pending in the outbox ✓ sent server has it ✓✓ delivered · read (ticks — WhatsApp) ✗ failed tap to retry — text KEPT auto-retry on reconnect us ✓ us ✓ (v185) we deliberately skip sent/delivered/read ticks — failure-only
We built the optimistic echo + the failure → tap-to-retry spine (v185) and chose not to show sent/delivered/read ticks — the only thing a user needs is to know when something didn't send.

3 · A durable outbound queue · 4 · a warm connection · 5 · cheap setup · 6 · payload discipline

When offline, WhatsApp writes the message to a pending queue and drains it in order on reconnect, deduped by a local id (store-and-forward). WeChat's Mars/STN keeps a long-link (persistent socket) for push + a short-link (HTTP) for big requests, with a smart heartbeat (smart_heartbeat.cc) and delta sync on reconnect. The cold-start tax is attacked with 1-RTT/0-RTT handshakes (mmtls / QUICCitizen Lab) and “complex connect” IP-racing. And media is decoupled from text — thumbnails first, download-on-tap via CDN, so a big file never blocks message sync.

④ The scorecard — us vs. the playbook

done shipped to production partial there, but incomplete to build applicable, not yet done n/a not applicable yet
TechniqueWhat they doUsWhere we stand
1 · LOCAL-FIRST STORE
Local store = source of truthread local, render now, reconcile in backgrounddoneper-user localStorage + roomCache, painted before any network on boot; recacheRoom keeps it live on every turn. The core, and it's solid.
Append-only + server authorityappend-only log, server timestamps order itdone/replay is append-only; a no-shrink guard stops a stale read shrinking the cache; the server is the clock.
Evict on access-loss(the mirror of caching — un-show what you can't see)doneevictRoom + a removed event + a 403/404 guard + a reconcile to the authoritative list (v184).
Cache sizing by real limitshold a lot; don't blow the devicedonevalue-aware eviction (opened rooms protected from prefetch); 64 in-memory; persisted by a ~3.5 MB byte budget (v186–v187).
2 · OPTIMISTIC UI + STATUS
Optimistic echo + actionsmessage/edit appears instantly, before the serverdoneuser echo + the conferring “egg”; note add/delete, rename, archive/trash, member add/remove, new-chat — all optimistic with rollback.
Pre-emptive loadingprefetch on hover / touchstartdonerow hover/touch prefetch plus a capped idle prefetch of the most-recent rooms.
Send status + retrypending → sent; failed = a tappable retrydonev185: auto-retry with backoff → a tappable red “!”, text kept. By choice, no sent/read ticks — failure-only.
“Only undo after N failures”retry-queue an action before revertingdonesends auto-retry before showing the “!”; the durable outbox re-drains on reconnect.
3 · DURABLE DELIVERY
Outbox: queue + drain on reconnectoffline sends persist, flush when backdonev185: a failed send is persisted (survives reload, re-painted with its “!”) and auto-resends on the online event + user-stream re-open.
Idempotency / dedupmessage ids so a retry can't duplicatedone/say is idempotent on the nonce (server-side) → recorded exactly once across retries + drain; lines carry a stable lid.
Server store-and-forwardhold messages for an offline clientdoneturns persist server-side; a missed turn replays via /replay on return (and reconcileRoom on stream re-open).
4 · CONNECTION
Persistent push connectionlong-link socket for real-time pushdoneSSE — a per-room stream + an always-on per-user stream (unread dots, removed, activity).
Delta sync on reconnectfetch only what was misseddonev185: /replay?since=<total> returns only new turns; the client merges onto the cached base (full-fallback if the base diverged). Cuts the payload on long rooms.
0-RTT setup + connection racingmmtls/QUIC 1-RTT, parallel-IP “complex connect”to buildthe ~1.4 s China→SG TLS cold handshake — the one frontier left. HTTP/2 + h3 advertised but unconfirmed. See §6.
Smart / adaptive heartbeatlongest ping that keeps the link aliven/afixed 20 s SSE ping; fine at our scale. Revisit only if battery/idle-drop becomes a real complaint.
5 · PAYLOAD & MEDIA
Small payloads, compression, batchingprotobuf, gzip/brotli, batch small msgspartialturns are batched; JSON (fine for our size); gzip only. Quick win: enable brotli in Caddy — free shrink on text. See §6.
Bandwidth-aware behaviourback off on 2G / Save-Data / meteredto buildQuick win: gate the idle prefetch on navigator.connection.effectiveType / saveData so we don't warm rooms on a 2G link.
Media decoupled from textthumbnails first, download-on-tap, CDNn/ano heavy media yet — text + client-rendered diagrams (small, already off the text path). Apply the rule when images/files land.
6 · PERCEIVED POLISH
Skeletons / interstitialstructure shows while the body loadsdoneshimmer skeleton for an uncached room body; the “egg” as a live placeholder.
Clear failure + retry affordancea red “!” you can tap to resenddonethe tappable “!” on a failed send (v185).

⑤ Cache consistency — the local-store model

A fast cache is worthless if it's wrong. Two consistency problems came with the speed, and both are now closed.

The cache isn't a screenshot you take on the way out — it's the live store, kept current on every send and receive, and evicted the moment the server says you've lost access. Re-opening a chat reads a copy that's instant, complete, and never forbidden.

Staying fresh — updated on every event

A reply that arrives while you're in the room used to render but never reach the cache, so switching away and back showed a stale snapshot until /replay caught up. Now recacheRoom(rid) re-snapshots from the authoritative /replay (delta, via ?since=) on every content event — for the open room and for an already-cached background room when its activity ping arrives — so the cache is never behind what you've seen.

The other half — eviction when you LOSE access

An owner removes you from a room, but your client still had it cached — so the chat lingered, opened from the snapshot, then went empty / “New chat” the moment the server started 403-ing. Fixed with three layers:

EventServerYour client
Owner removes youremoved → your user streamevict from list + cache; bounce out if open + toast
Room deletedremoved → each member (captured before the cascade)same eviction, live
Rename / archive / cast / modelactivity → all memberslist title/status + open view refresh live
Any room GET → 403/404the membership gategetRoomJSON throws accessLost → evict
Offline removal / missed eventreconcileCachesToList: a fresh /api/rooms prunes any cached room it no longer lists

Defence in depth: a live push (instant), a 403/404 guard on every room fetch (catches a click racing the push), and a reconcile against the authoritative list on every refresh (catches anything missed while the tab was closed).

⑥ What's left — the road ahead

The local-first half of the playbook is done and live. What remains is pure transport plus a couple of quick wins — none of it changes the architecture, all of it chips at the raw wire.

1 · Attack the ~1.4 s cold handshakethe frontier · infra

The biggest remaining cost is the first packet. Two levers: confirm HTTP/3 (QUIC) is actually used (the box advertises alt-svc: h3=":443", but the China path may block UDP/443 — if it falls back to h2 we never get QUIC's 0-/1-RTT + loss recovery); and front the box with an edge/CDN that terminates TLS near the user and pools a warm long-haul connection to Singapore. WeChat solves this with mmtls 0-RTT + IP-racing; an edge is our pragmatic equivalent. (Mind the cert / grey-cloud caveat in deployment topology.)

2 · Quick winslow effort

Brotli in Caddy (free shrink on all JSON/HTML over the wire); bandwidth-aware prefetch — gate the idle room-warming on navigator.connection / saveData so a 2G or metered link isn't flooded.

3 · When they become relevantdeferred · n/a today

Media discipline (thumbnails, download-on-tap, CDN) the moment images/files land — today we're text + small client-rendered diagrams. Adaptive heartbeat only if idle-drop / battery becomes a real complaint; the fixed 20 s SSE ping is fine at this scale.

the takeawayWe shipped both halves of what WeChat and WhatsApp do for slow networks — the local-first store, optimistic UI, push connection, correct eviction, a durable retrying outbox so a flaky link can never swallow a user's words, and delta sync so a long room doesn't re-pull its whole transcript. The app now feels local even over a 200 ms wire. What's left is the raw wire itself — the ~1.4 s cold handshake — which only an edge/CDN or confirmed HTTP/3 can move.

Sources

Tencent — Mars (WeChat's cross-platform network component) · STN smart heartbeat · long-link
Citizen Lab — Security analysis of WeChat's MMTLS encryption protocol (1-RTT handshake; “move to QUIC”)
WhatsApp local-first — Your phone is a data center · How WhatsApp works without internet
Optimistic UI & queues — Simon Hearne — Optimistic UI patterns · Message-queue best practices (idempotency, retry)

Dialogue · perceived-speed & slow-network playbook · 2026-06-19, updated & merged 2026-06-20 · v179–v187 SHIPPED to production (chat.xbbapp.com) · grounded in lib/room-ui.html, lib/run_room.py, lib/sw.js — see also Rendering & speed · Deployment topology · Roadmap