Optimistic Mutation with Echo-Suppressed Streaming
Wordloop's core data architecture — REST mutations with optimistic UI, WebSocket streaming for real-time reads, and echo suppression for multi-device synchronization.
Optimistic Mutation with Echo-Suppressed Streaming
This is Wordloop's core data architecture for all user-initiated CRUD operations. The pattern separates writes (REST) from reads (WebSocket) to achieve perceived zero-latency mutations with real-time multi-device synchronization.
Canonical Reference
This pattern governs all entity-level operations — notes, tasks, topics, meeting metadata, and any future entity types. Audio streaming and ML-generated events use different pipelines documented in System Workflows.
Why This Design
Traditional request/response flows force the user to wait for the server round-trip before seeing results. Polling-based updates miss state changes between intervals. Full event sourcing introduces operational complexity that isn't justified for Wordloop's entity CRUD workloads.
This pattern sits in the pragmatic middle:
| Concern | Approach |
|---|---|
| Write path | REST — transactional, idempotent, familiar error handling. The server is the single source of truth. |
| Read path | WebSocket — server pushes complete entity payloads on every state change. No polling, no stale cache windows. |
| Perceived latency | Optimistic updates — the client applies the change locally before the REST response. The UI responds in under 16ms. |
| Multi-device sync | All connected clients for a user receive every state change via WebSocket. No refresh required. |
| Echo prevention | Source-aware events — the originating client ignores its own echo by matching the clientId on the WebSocket event. |
The Five-Step Data Loop
Every mutation follows this exact sequence:
Step-by-Step Breakdown
Step 1 — Optimistic Update
When a user performs an action (add note, edit title, delete task), the client applies the change to local state immediately, before the network request fires. Three things happen:
- The change is applied to the UI. The user sees the result instantly.
- A rollback snapshot is stored. If the server rejects the mutation, the client reverts to this snapshot.
- A pending indicator is shown. Optimistic entities render with a subtle visual cue (reduced opacity, syncing badge, or a small spinner) so the user understands the change is not yet confirmed. The indicator is removed when the REST response arrives.
For entity creation, the client generates a temporary ID (a UUID prefixed with temp_) so the new entity can appear in the UI and be referenced before the server assigns a permanent ID.
Step 2 — REST Mutation
The mutation is sent to the appropriate REST endpoint with two critical headers:
POST /api/v1/notes HTTP/1.1
Authorization: Bearer <jwt>
X-Client-Id: abc-123
Content-Type: application/json
{
"meetingId": "mtg_01J...",
"content": "Follow up with the design team"
}| Header | Purpose |
|---|---|
Authorization | User identity (JWT from Clerk). Determines who is performing the action. |
X-Client-Id | Client instance identity. Determines which device/tab initiated the action. Used exclusively for echo suppression. |
The REST response returns the complete server-authoritative entity — including the server-assigned id, createdAt, updatedAt, and version fields. The client uses this response to replace its temporary optimistic state with the confirmed server state.
Step 3 — Event Broadcast
After the database write succeeds, Core publishes a WebSocket event to all connected clients within the event's scope. The event uses the CloudEvents envelope and carries the full entity payload:
{
"specversion": "1.0",
"type": "note.created",
"source": "wordloop-core",
"id": "evt_01J...",
"data": {
"id": "note_01J...",
"meetingId": "mtg_01J...",
"content": "Follow up with the design team",
"createdAt": "2026-04-17T20:00:00Z",
"updatedAt": "2026-04-17T20:00:00Z",
"version": 1
},
"sourceClientId": "abc-123"
}Complete Payloads, Not Diffs
Events carry the full entity state, not a delta. This keeps client logic simple — the receiving client replaces its local copy of the entity directly without applying patch operations or maintaining a change log. The trade-off is larger payloads, which is acceptable for Wordloop's entity sizes.
Step 4 — Echo Suppression
The originating client receives the WebSocket event and compares sourceClientId against its own client ID:
Incoming event sourceClientId: "abc-123"
My clientId: "abc-123"
→ Match. Discard event (UI already reflects this from the optimistic update).Without echo suppression, the originating client would render the change twice — once from the optimistic update and once from the WebSocket event — causing visual flicker and duplicate list entries.
Step 5 — Cross-Device Sync
Other clients connected for the same user receive the identical WebSocket event. Since their clientId does not match the sourceClientId, they apply the entity payload directly to their local UI state:
Incoming event sourceClientId: "abc-123"
My clientId: "def-456"
→ No match. Apply entity to local state. UI updates in real time.No REST call is needed. The WebSocket event contains the complete entity, so the receiving client has everything it needs to render the change.
Client Identity
What Is a Client ID?
A clientId is a UUID generated per browser tab when the application initializes. It is not tied to the user's authentication identity — a single user can have multiple client IDs across different tabs and devices.
| Property | Value |
|---|---|
| Scope | One per browser tab / app instance |
| Lifetime | Created on tab open, discarded on tab close |
| Persistence | Stored in sessionStorage (survives page refresh within the same tab, not across tabs) |
| Format | UUIDv4 (e.g., abc-123-def-456) |
Why Per-Tab, Not Per-Session?
If the client ID were per-session (shared across tabs), a mutation from Tab A would suppress the WebSocket event in Tab B — meaning Tab B would never render the change. Per-tab IDs ensure that only the exact tab that initiated the mutation suppresses the echo.
Why Client ID, Not Mutation ID?
Some architectures use a unique mutationId per operation instead of a persistent clientId. The trade-off:
| Approach | Pros | Cons |
|---|---|---|
| Client ID (Wordloop) | Simpler — one header, no per-mutation tracking state. Echo suppression is a single string comparison. | Cannot distinguish between two of your own rapid mutations on the same entity (both are suppressed). |
| Mutation ID | Each operation is individually tracked. Can precisely reconcile specific operations. | Requires a pending-mutation queue on the client and mutation-ID propagation through the server. |
Wordloop uses clientId because entity operations are independent and non-overlapping — a user does not typically create the same note twice in rapid succession. If Wordloop ever introduces collaborative editing where individual keystrokes must be tracked, mutation IDs would be required.
Event Scoping
Not every connected client receives every event. Events are scoped to the relevant audience:
| Scope | Events Delivered To | Example |
|---|---|---|
| User | All clients authenticated as that user | note.created, task.updated, meeting.deleted |
| Meeting | All clients viewing that specific meeting | transcript.segment.produced, insight.generated |
The WebSocket hub maintains a mapping of userId → [clientId, clientId, ...] and a subscription registry of meetingId → [clientId, clientId, ...]. When Core emits an event, the hub resolves the target audience and delivers to only those connections.
Initial State Hydration
When a client first loads, the WebSocket is not yet connected. The client must establish initial state before subscribing to real-time updates. The sequence is:
Hydration Race Condition
Between the REST response and the WebSocket connection being established, events can be lost. To handle this, the client should include a since timestamp (from the REST response's latest updatedAt) in the WebSocket connection handshake. Core replays any events that occurred after that timestamp during the connection setup.
Edge Cases
Mutation Failure and Rollback
If the REST call fails, the client must undo the optimistic update and surface the error. The rollback strategy depends on the error category:
| Error Category | HTTP Status | Retry? | Client Behavior |
|---|---|---|---|
| Validation / Client Error | 400, 409, 422 | ❌ No | Roll back immediately. Surface error to user. The request is structurally wrong and retrying won't help. |
| Authentication Error | 401, 403 | ❌ No | Roll back. Redirect to login or refresh token. |
| Not Found | 404 | ❌ No | Roll back. Entity was deleted by another client. Surface "item no longer exists" notification. |
| Server Error | 500, 502, 503 | ✅ Yes | Retry with exponential backoff + jitter (1s, 2s, 4s, max 3 retries). Roll back only after all retries are exhausted. |
| Network Timeout | — | ✅ Yes | Retry once. If it still fails, roll back and surface ambiguous error: "Changes may not have been saved." |
For network timeouts, the client cannot know whether the server received and processed the request. If the mutation did succeed server-side, the WebSocket event will eventually deliver the confirmed state — at which point the client should silently accept it rather than showing a duplicate.
Optimistic ID Reconciliation
When creating a new entity, the client uses a temporary ID (temp_xxx) for the optimistic update. When the REST response returns with the server-assigned ID, the client must replace the temporary ID everywhere it appears in local state:
Optimistic state: { id: "temp_abc", content: "..." }
REST response: { id: "note_01J...", content: "...", createdAt: "..." }
→ Replace temp_abc → note_01J in all local state referencesThe subsequent WebSocket echo is suppressed by clientId matching, so no further reconciliation is needed for the originating client.
WebSocket Event Arrives Before REST Response
The WebSocket event can arrive at the originating client before the REST response under high load. This is safe because:
- Echo suppression discards the event regardless of timing (the
clientIdmatches). - The REST response is the authoritative confirmation — it arrives independently and the client reconciles from it.
No special handling is required.
Concurrent Mutations (Last-Write-Wins)
If two devices edit the same entity simultaneously, the last write to reach the database wins. Both REST calls succeed independently, and both produce WebSocket events. Each client receives the other client's update event and replaces its local state.
No Conflict Resolution
Wordloop uses last-write-wins, not conflict resolution. This is appropriate for the current entity types (notes, tasks, meeting metadata) where conflicts are rare and the cost of a lost edit is low. If collaborative editing (e.g., simultaneous text editing within a note) is introduced, this section must be revisited with CRDTs or Operational Transform.
Delete Race Condition
If Client A deletes an entity while Client B is editing it:
- Client A's
DELETEsucceeds. Core publishesnote.deletedover WebSocket. - Client B receives
note.deletedand removes the entity from its UI — even if Client B has unsaved optimistic changes. - If Client B's
PATCHarrives at Core after the delete, Core returns404 Not Found. Client B rolls back its optimistic update and surfaces the error.
The delete always wins. The client must handle the case where a deleted event arrives for an entity the user is currently editing by closing the editor and surfacing a notification.
Stale Event Ordering
Under network jitter or high load, WebSocket events for the same entity can arrive out of order. Each entity carries a version field (monotonically incrementing integer) and an updatedAt timestamp:
Current local state: { id: "note_01J...", version: 3 }
Incoming WS event: { id: "note_01J...", version: 2 }
→ Event version < local version. Discard as stale.The client must never apply an event whose version is less than or equal to the local version for the same entity.
Reconnection and Missed Events
When the WebSocket connection drops (network change, server restart, mobile backgrounding), events published during the disconnection window are lost:
The client tracks the id of the last received event. On reconnection, it sends this as lastEventId in the handshake. Core replays all events after that ID from a short-lived event buffer before resuming the live stream.
Reconnection strategy: The client uses exponential backoff with jitter to avoid thundering-herd reconnection storms when the server restarts:
| Attempt | Base Delay | With Jitter (±30%) |
|---|---|---|
| 1 | 1s | 0.7s – 1.3s |
| 2 | 2s | 1.4s – 2.6s |
| 3 | 4s | 2.8s – 5.2s |
| 4 | 8s | 5.6s – 10.4s |
| 5+ | 16s (cap) | 11.2s – 20.8s |
Replay Window
The event buffer has a finite retention window. If the client has been disconnected longer than the buffer window, a replay is not possible. In this case, the client must perform a full state re-fetch via REST (the same hydration flow as initial page load) and then resume WebSocket subscription.
Idempotency on REST Retry
If a REST mutation times out and the client retries, the server may process the same mutation twice — producing two WebSocket events for a single user action.
For create operations, the client should generate and send an Idempotency-Key header. Core checks this key against a short-lived cache and returns the cached response if the key has been seen, preventing duplicate creation and duplicate WebSocket events.
For update and delete operations, natural idempotency applies — updating to the same values or deleting an already-deleted entity produces the same result.
POST /api/v1/notes HTTP/1.1
Idempotency-Key: idem_7f3a9c...
X-Client-Id: abc-123Partial Server Failure
If the database write succeeds but the WebSocket broadcast fails (hub crash, network partition between Core and hub):
- Originating client: Receives the REST
201 Createdresponse and knows the mutation succeeded. Its optimistic update is confirmed. - Other clients: Miss the WebSocket event and do not update their UI.
This is an eventually-consistent failure. Other clients will receive corrected state on their next REST fetch (page navigation, tab focus) or when the WebSocket reconnects and replays missed events. This is acceptable because the originating client — the device where the user performed the action — always sees the confirmed state.
Rapid Mutations on the Same Entity
If a user edits the same entity in rapid succession (typing a title, adjusting a slider), firing a REST call for every keystroke wastes bandwidth and creates ordering hazards where a slow early response overwrites a fast later one.
Strategy: Debounce + Coalesce
- Debounce the REST call. Wait until the user pauses interaction (300–500ms of inactivity) before sending the mutation. The optimistic update still applies immediately on every keystroke — only the network request is debounced.
- Coalesce intermediate states. Only the final state is sent to the server, not every intermediate value. If the user types "Hel", "Hell", "Hello" — the server receives one
PATCHwith"Hello". - Cancel stale in-flight requests. If a new mutation fires while a previous one is still in-flight for the same entity, abort the previous request using
AbortControllerto prevent a stale response from overwriting the newer state.
User types: H → He → Hel → Hello → [pauses 300ms]
Optimistic UI: H → He → Hel → Hello (each applied immediately)
REST calls: [none] → [none] → [none] → PATCH { content: "Hello" }Tab Focus Revalidation
When a browser tab regains focus after being backgrounded, the WebSocket may have silently disconnected without triggering an error event (common on mobile browsers and laptop lid-close). The client should treat tab-focus as a trigger to:
- Check WebSocket health. If the connection is dead, initiate reconnection with
lastEventIdreplay. - Revalidate stale queries. SWR's
revalidateOnFocus(or equivalent) re-fetches the current view's data via REST to catch any mutations that occurred while the tab was inactive.
This ensures the client is never silently stale after returning from background.
WebSocket Authentication Lifecycle
The WebSocket connection authenticates with a JWT during the initial handshake. Since JWTs have a finite lifetime, the connection must handle token expiry and session revocation:
Token Refresh (Proactive):
- The client monitors its JWT expiration. A few minutes before expiry, it refreshes the token via the standard Clerk token refresh.
- The client sends an
auth.refreshmessage over the existing WebSocket with the new token. - Core validates the new token and associates it with the connection. No reconnection is needed.
Session Revocation (Server-Initiated):
- When a user logs out from any device, or an admin revokes access, Core sends a
session.revokedevent to all WebSocket connections for that user. - Each client receives the event, closes the WebSocket, clears local state, and redirects to the login screen.
- Core terminates the server-side connection after sending the event.
Token Expired (Reactive):
- If the token expires without a proactive refresh (client was backgrounded), Core sends a WebSocket close frame with code
4401(custom "Unauthorized" code). - The client refreshes its token and reconnects with the new JWT.
Cache Reconciliation on Settled
After every mutation — whether it succeeds or fails — the client should revalidate the affected SWR cache key to ensure the local cache matches the server's authoritative state. This is the onSettled pattern:
- On success: The REST response already contains the server-authoritative entity. The client updates the SWR cache with this response. A background revalidation is triggered to catch any concurrent mutations from other devices that may have occurred during the request.
- On error: The rollback restores the snapshot, and a revalidation fetches the current server state to ensure the cache is clean.
This guarantees that even if echo suppression, version comparison, or reconnection logic has a subtle bug, the cache self-heals within one mutation cycle.
What This Pattern Does NOT Cover
| Concern | Handled By |
|---|---|
| Audio streaming | Dedicated binary WebSocket pipeline — see Real-Time WebSocket Streaming |
| ML-generated events | Transcript segments and insights originate from Pub/Sub consumers, not REST mutations. These always flow through the WebSocket without echo suppression because no client initiated them. |
| Authentication | JWT validation on REST and WebSocket handshake — see Authentication |
| Pub/Sub worker pipelines | Asynchronous inter-service communication — see Unified Asynchronous Meeting Finalization |