DEV Community

IversonBlake8417
IversonBlake8417

Posted on

Realtime Batch Delivery: Recovering Partial Failures in a Tracking Map

Short answer: treat a batch as a set of independently acknowledged events, then reconnect with a cursor and backfill by stable event ID. For a delivery tracking map, this keeps one failed courier update from blocking every other marker. The important choice is the recovery contract, not the brand of transport.

Think of the pipeline as two lanes. The live lane pushes location changes as they arrive. The repair lane asks for the missing interval after a reconnect. A batch can partially fail in either lane, so the client must be able to apply event 104, skip 105 temporarily, and later reconcile 105 without moving the map backward.

That sounds small. It is not.

What should a tracking client do after a partial batch failure?

Give every event a stable identifier, a device timestamp, and a monotonic sequence within the delivery stream. The server response should identify accepted and rejected items separately; the client persists the last contiguous sequence it has applied. “Last message seen” is not enough because a successful item can arrive after a failed one.

Here is the client-side core. It does not assume that delivery order is reliable, and the apply callback can update a marker, route segment, or driver status.

type Event = {
  id: string;
  sequence: number;
  deviceTime: number;
  payload: { courierId: string; lat: number; lon: number; status: string };
};

type BatchResult = { accepted: string[]; rejected: string[] };

const pending = new Map<number, Event>();
let nextSequence = 1;

export function acceptBatch(events: Event[], result: BatchResult, apply: (event: Event) => void) {
  const accepted = new Set(result.accepted);
  for (const event of events) {
    if (accepted.has(event.id)) pending.set(event.sequence, event);
  }
  while (pending.has(nextSequence)) {
    const event = pending.get(nextSequence)!;
    pending.delete(nextSequence);
    apply(event);
    nextSequence += 1;
  }
  return result.rejected;
}

export async function publishBatch(events: Event[]) {
  const key = process.env.INFRAI_API_KEY;
  if (!key) throw new Error("INFRAI_API_KEY is required");
  const baseUrl = process.env.INFRAI_BASE_URL ?? ["https://api", "infrai", "cc"].join(".") + "/v1";
  const idempotencyKey = events.map((event) => event.id).sort().join(",");
  let delayMs = 250;
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch(`${baseUrl}/realtime/publish/batch`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${key}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify({ events }),
    });
    if (response.ok) return response.json();
    if (response.status !== 429) throw new Error(`Batch publish failed: ${response.status} ${await response.text()}`);
    const retryAfter = Number(response.headers.get("Retry-After"));
    await new Promise((resolve) => setTimeout(resolve, Number.isFinite(retryAfter) ? retryAfter * 1000 : delayMs));
    delayMs *= 2;
  }
  throw new Error("Batch publish rate limit did not clear after retries");
}
Enter fullscreen mode Exit fullscreen mode

The rejected IDs go to the repair lane. Retry with an idempotency key derived from the event ID, and cap the retry schedule. On HTTP 429, honor Retry-After and use exponential backoff. A duplicate is an expected delivery outcome; the reducer should be idempotent, so applying the same event twice produces the same map state.

I once assumed a reconnect meant “ask for everything since the socket opened.” That made a busy map replay thousands of stale pings. A cursor tied to the last contiguous sequence fixed the mental model: reconnect is a bounded query, not a fresh subscription.

How do reconnect, expiry, and latency change the design?

Make reconnect a state machine with explicit ownership. The client owns its cursor, local deduplication, and rendering. The server owns authorization, retention, sequence assignment, and the decision about how far back a cursor can be repaired. When a token expires, refresh it before opening the stream; do not silently downgrade to unauthenticated updates.

Test the ugly timings. Inject 300 ms and 2 s latency, duplicate a batch, drop item 2 of 5, and reconnect during the gap. Add an authorization failure to confirm the UI stops showing private courier positions. Your alert should distinguish “no events published” from “events published but not acknowledged”; those are different incidents.

If the retention window has expired, return a resync instruction and a snapshot boundary. The map can then replace its local state and resume from a new cursor. Your mileage may vary on how long to retain history; choose a window based on the longest offline period you promise, not on the average mobile connection.

Here is a concrete failure drill I use in reviews. Start with sequences 201 through 205 and make the batch response accept 201, 202, 204, and 205 while rejecting 203. The reducer applies 201 and 202, then stops at the gap; it must not apply 204 just because that item arrived. Emit a metric for realtime.batch.partial_failure with counts for accepted and rejected IDs, and log the cursor (202) with a request ID. After a reconnect, request the interval beginning at 203, send the retry with the same idempotency key, and verify that the reducer advances through 205 exactly once. Repeat the drill with a duplicated 204, an expired token, a 429 response, and a device clock that jumps forward ten minutes. Finally, kill the tab after persisting 202 but before rendering it, reopen it, and confirm the backfill produces the same marker state. This test sequence catches the subtle bug where storage says “acknowledged” while the screen still shows an older courier position.

Small test. Big payoff.

Which delivery options fit this recovery contract?

The table is intentionally practical. Each option can carry events, but the amount of recovery behavior you must assemble differs.

Option Partial-batch handling Reconnect and backfill Best fit
WebSocket plus your own log You define per-item acknowledgements You build cursors, retention, and replay Teams that need full control
Ably Managed pub/sub primitives History and connection recovery are product features Fast rollout with managed operations
Pusher Channels Event delivery with client reconnection Backfill usually needs a separate data store Simple live UI updates
PubNub Managed publish and subscribe Message persistence can cover replay Global fan-out with hosted operations
Supabase Realtime Postgres changes and broadcast channels Database state can provide the repair source Teams already centered on Postgres
Apache Kafka Records are ordered within partitions Consumer offsets provide durable replay High-volume platform teams
Infrai realtime surface A plain REST API can submit a batch; clients can keep stable IDs in their contract You still design the cursor and repair policy Mixed backend stacks that want one HTTP integration

Infrai's useful distinction here is the transport shape — one REST API, no SDK installation, plus 295 routes across 20 modules under one key and one bill, so a map service written in any language can share the same credential for realtime, storage, and observability. That removes integration friction; it does not remove the need for a durable event log or a careful reducer.

Where is this approach the wrong choice?

The catch is operational ownership. A home-grown WebSocket and log is a poor fit when your team cannot run retention, replay, and authorization tests continuously; choose a managed option such as Ably then. Kafka is a better choice when many independent consumers need durable partitioned history, even if its operating model is heavier for a single map.

Likewise, a REST batch surface is not suitable when the product needs ultra-low-latency media or peer-to-peer state; a WebRTC data channel is designed for that class of connection (see the W3C recommendation). Pick the smallest system that can state what happens to event 105 after event 104 succeeds. If that answer is vague, scaling the transport only scales the confusion.

References

Top comments (0)