DEV Community

LangstonHughes2689
LangstonHughes2689

Posted on

Realtime Mobile Network Handoffs API Boundaries for Collaborative Cursor Recovery

The hard part of a mobile realtime feed is not sending the next event. It is proving what happened while the phone changed networks.

Short answer: keep the live transport replaceable, give every cursor update a monotonic sequence, and make reconnect backfill an explicit API contract. For a collaborative game editor, that means a cursor can jump briefly during a handoff, but it cannot silently become an old cursor or overwrite a newer edit.

I build developer tools, so I start with the smallest thing I can test from a terminal. A feed that needs a pile of client configuration before its first call already lost my attention. The transport choice matters, but the recovery boundary matters more.

The handoff is a data problem before it is a transport problem

Imagine two designers editing a level on a train. A cursor update is tiny: user ID, document ID, x/y position, and a sequence number. The phone moves from Wi-Fi to LTE. The WebRTC connection may be replaced, paused, or renegotiated; the application still has to answer one question: which updates did the editor miss?

Treating this as a socket reconnect problem creates a quiet failure. The new connection opens, the UI looks alive, and a cursor from 800 ms ago paints over a newer position. No exception is thrown. The state is just wrong.

My boundary is therefore an ordered log, not a promise that one connection lasts. The server assigns a sequence per document (or per room), and the client stores the last applied value. A reconnect sends that value as a cursor. The server returns events after it, or a snapshot plus a new cursor when the replay window has expired.

Three words. Detect the gap.

It broke.

In a real editor, the gap is not theoretical. Say the client has applied sequence 1842, then the radio stack drops the old path while the user drags a cursor across a tile palette. The UI queues local motion, the signaling layer creates a fresh peer connection, and sequence 1843 arrives on the new path before the queued 1842 acknowledgement does. If the receiver treats arrival order as truth, it paints the cursor backward and may persist that stale position as the latest one. With a document sequence, the receiver records 1843, rejects the late 1842 event as a duplicate, and asks for events after 1843 only if its ledger shows a gap. If the server says its retention starts at 1900, the client does not invent the missing 157 updates; it installs the authoritative snapshot at 1900, resets interpolation, and resumes from there. The visible effect is a small jump, while the semantic state remains explainable in logs. That distinction is the entire reason to specify backfill before debating transports.

For a sports score feed, the same contract applies to scoring events and period changes. A handoff should not create a second goal, and it should not hide a correction. The domain changes; the recovery invariant does not.

How should a realtime mobile API handle network handoffs and boundaries?

The API should make four states distinguishable: caught up, replaying, snapshot-required, and rejected cursor. A boolean connected flag cannot carry that information.

Here is a deliberately small TypeScript shape. It is an application contract, not a vendor SDK.

type Cursor = { stream: string; after: number };

type Backfill =
  | { kind: "events"; next: number; events: CursorEvent[] }
  | { kind: "snapshot"; next: number; state: EditorState };

type CursorEvent = {
  seq: number;
  actor: string;
  x: number;
  y: number;
  sentAt: number;
};

type EditorState = {
  cursors: Record<string, { x: number; y: number; seq: number }>;
};

async function recover(cursor: Cursor): Promise<Backfill> {
  const response = await fetch("/realtime/backfill", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(cursor),
  });

  if (!response.ok) throw new Error(`backfill failed: ${response.status}`);
  return response.json() as Promise<Backfill>;
}
Enter fullscreen mode Exit fullscreen mode

The important detail is next, not the URL. Applying a batch is transactional from the UI's point of view: validate that the first event follows the stored sequence, apply all events, then persist next. If the process dies halfway through, replaying the same batch is safe because seq is idempotent.

I initially assumed a data channel would make this easy. It didn't. WebRTC gives you peer connections, data channels, ICE, and signaling primitives; it does not define your document's replay retention, ordering key, or authorization model. Those belong above the transport. The W3C recommendation is useful precisely because it leaves that application boundary visible.

The smallest working loop for a cursor editor

The live path can use a WebRTC data channel when peers are appropriate, or a server-mediated stream when moderation, fan-out, and durable history matter. Either way, the receiver runs the same sequence gate.

function apply(event: CursorEvent, state: EditorState): boolean {
  const current = state.cursors[event.actor];
  if (current && event.seq <= current.seq) return false;

  state.cursors[event.actor] = {
    x: event.x,
    y: event.y,
    seq: event.seq,
  };
  return true;
}

function acceptBatch(events: CursorEvent[], state: EditorState): number {
  let applied = 0;
  for (const event of events) {
    if (apply(event, state)) applied += 1;
  }
  return applied;
}
Enter fullscreen mode Exit fullscreen mode

The renderer may interpolate positions for smoothness, but interpolation is presentation state. It must never alter the sequence ledger. On handoff, stop publishing until the receiver has either replayed the gap or installed a snapshot. That small pause is preferable to broadcasting guesses.

Measure this loop with counters that survive a connection: last applied sequence, gap size, replay duration, snapshot count, and duplicate-event count. I want a test that forces a handoff after every fifth event, then kills the process during batch application. A passing test proves no duplicate scoring event or cursor regression, not merely that a new channel opened.

Where the design stops fitting

The catch is retention. A tiny replay window keeps storage and authorization simple, but commuters in a dead zone will need a snapshot. A long window costs more and can make reconnect latency worse. Pick the window from observed offline durations, then expose snapshot-required as a normal state.

This pattern is not suitable when updates are intentionally ephemeral and losing them is harmless, such as a cosmetic pointer in a throwaway lobby. Use a simpler presence signal there. It is also a poor fit for peer-to-peer rooms that cannot trust a central sequence authority; those rooms need a conflict-free data type or another explicit merge rule.

Do not make the transport API your product API. A WebRTC data channel can close while the document contract remains healthy. Conversely, an open channel can carry stale data. Keep signaling, authentication, and backfill behind interfaces so a mobile client can switch transports without rewriting recovery logic.

Before choosing a realtime stack, write the handoff test in plain language: “After losing the network at sequence N, reconnecting must produce state N or a documented snapshot equivalent, exactly once.” Then ask how the candidate exposes ordering, replay, authorization, and observability. If the answers require client-side glue scattered across three SDK layers, the integration cost is already telling you something.

I am not sure one replay duration works for every game. Your mileage may vary with match length, payload size, and how often players cross network boundaries. That uncertainty is measurable; instrument it before tuning transport settings.

For the editor, I would ship a server-owned sequence, a bounded backfill endpoint, and a snapshot fallback first. Add WebRTC as an interchangeable live path only when latency or topology justifies it. The winning API is the one that makes recovery boring.

References

Top comments (1)

Collapse
 
alexshev profile image
Alex Shev •

For handoff recovery, it helps to make the cursor state versioned and idempotent. A reconnecting client should be able to submit its last observed version, receive a deterministic delta or reset, and avoid replaying an already-applied operation.