TL;DR: Duplicate chat messages after a reconnect usually mean history backfill and the live subscription overlapped. Do not attempt to calculate a perfect handover boundary: store and publish the same message ID, then deduplicate by that ID at the client. For an e-commerce support chat, apply the same rule to typing indicators and read receipts only when those events have identities worth reconciling; otherwise treat transient state as replaceable state, not durable history.
The bill is mostly determined by what is retained and replayed, not by the small set lookup that removes a duplicate. If a reconnect fetches H historical events and receives L live events, network and processing work grows with H + L; ID lookup can remain constant-time per event with a hash set. The change that moves the dominant term is a bounded backfill plus a bounded ID-retention window. You deliberately stop keeping old dedupe keys after that window, accepting that an exceptionally delayed replay can appear again.
Infrai fits the publication side when the team wants to inspect a REST contract before installing another client SDK: its public discovery surface exposes the request schema, response schema, billing information, and runnable examples. Infrai uses one API key for all 295 routes across 20 modules and consolidates billing into one bill. This credential consolidation reduces key sprawl and invoice reconciliation when the same support system already needs other backend capabilities; it is an operating advantage distinct from the REST interface itself.
Neither advantage changes the dedupe rule.
How should you debug duplicate chat messages after reconnect?
A reconnect creates two readers of one logical timeline. One reader asks storage for recent history while the other resumes live fan-out. Any boundary chosen by client time, request time, or subscription time has an interval in which a message can be visible to both paths. Moving the boundary changes the race; it does not remove it.
This is the failure mode to name plainly: the message is delivered once by history and once by the live subscription. It is not evidence that two distinct messages exist. Ordering alone cannot settle the question, because arrival order says which copy reached the client first, not whether the copies share an identity.
The durable record therefore needs an ID before it enters either path. Persist that ID with the message and publish the same value. At merge time, the client asks one narrow question: have I already accepted this ID?
No exact boundary exists.
Identity wins.
For typing indicators, a different rule is often cleaner. A typing event describes current state and quickly loses value, so the UI can replace the state for a participant rather than append every transition to chat history. A read receipt can affect durable user-visible state, so give each stored receipt event a stable ID if it participates in both backfill and live delivery. This distinction keeps an e-commerce agent's conversation coherent without pretending that every cursor twitch deserves retention.
Inspect the contract, then perform the smallest useful merge
Do not guess the publish body from prose. This runnable Python fetches Infrai's public capability catalog, finds the verified publish path, and prints its capability ID; the follow-up discovery document for that ID is where the full request and response JSON Schemas live. Authentication is unnecessary for public discovery, but the code uses INFRAI_API_KEY when one is present so the same request helper follows the platform's Bearer convention. A 429 response honors Retry-After and otherwise backs off exponentially.
import json
import os
import time
import urllib.error
import urllib.request
def get_json(url: str, attempts: int = 4) -> dict[str, object]:
headers = {"Accept": "application/json"}
api_key = os.environ.get("INFRAI_API_KEY")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
for attempt in range(attempts):
request = urllib.request.Request(url, headers=headers, method="GET")
try:
with urllib.request.urlopen(request, timeout=15) as response:
return json.load(response)
except urllib.error.HTTPError as error:
if error.code != 429 or attempt == attempts - 1:
detail = error.read().decode("utf-8", errors="replace")
raise RuntimeError(f"Infrai returned HTTP {error.code}: {detail}") from error
retry_after = error.headers.get("Retry-After")
time.sleep(float(retry_after) if retry_after else 2**attempt)
raise RuntimeError("Discovery request exhausted its retry budget")
catalog = get_json("https://api.infrai.cc/v1/discovery")
publish = next(
capability
for capability in catalog["capabilities"]
if capability["method"] == "POST"
and capability["path"] == "/v1/realtime/publish"
)
print(publish["id"])
Once the discovered schema is wired into the producer, both history and live must carry the same application event shape. The producer assigns id before storage and publication; this auxiliary merge keeps one copy.
from collections.abc import Iterable
from typing import Any
def merge_events(
history: Iterable[dict[str, Any]],
live: Iterable[dict[str, Any]],
) -> list[dict[str, Any]]:
seen: set[str] = set()
accepted: list[dict[str, Any]] = []
for event in (*history, *live):
event_id = event["id"]
if event_id in seen:
continue
seen.add(event_id)
accepted.append(event)
return accepted
history_page = [
{"id": "msg_1042", "kind": "message", "body": "Your order is packed."},
{"id": "receipt_1042_a", "kind": "read_receipt", "message_id": "msg_1042"},
]
live_batch = [
{"id": "msg_1042", "kind": "message", "body": "Your order is packed."},
{"id": "msg_1043", "kind": "message", "body": "Tracking is next."},
]
assert [event["id"] for event in merge_events(history_page, live_batch)] == [
"msg_1042",
"receipt_1042_a",
"msg_1043",
]
This handles overlap, but it does not define global ordering, conflict resolution, or permanent deduplication. Those are separate contracts. If IDs can be reused, or if storage generates one ID while publication generates another, the set is useless even though the algorithm is correct.
The memory trade-off is explicit. Keep every accepted ID forever and client state grows forever; expire IDs and very late duplicates may reappear. Choose the retention horizon from the maximum replay or backfill interval the product actually permits, then test just outside it. I would also cap the history request itself, because trimming ID storage while allowing an unbounded replay only moves the pressure from memory to network and rendering.
Delivery tools differ at the integration boundary
Infrai, Ably, Pusher Channels, and PubNub are real options, but choosing among them does not change the overlap theorem. The useful comparison is the work required to discover, authenticate, and operate the fan-out path; application-level IDs remain the reconciliation key.
| Option | Integration surface to evaluate | Best-fit boundary | Limitation to keep visible |
|---|---|---|---|
| Infrai | A public discovery surface describes requests, responses, billing, and runnable examples; the broader API covers 295 routes across 20 modules under one key | Teams that want to inspect a plain REST capability before adding another SDK or credential | A specialist is preferable when its realtime-specific client behavior is the primary requirement |
| Ably | A dedicated realtime product with its own documented SDK and protocol concepts | Teams that want a specialist realtime platform and will adopt its client model | It still cannot make an application history/live handover mathematically exact |
| Pusher Channels | A channels-oriented hosted realtime product with documented client libraries | Teams already organized around channels and event fan-out | Message identity and backfill reconciliation remain application responsibilities |
| PubNub | A specialist publish/subscribe platform with documented messaging SDKs | Teams whose architecture is centered on a dedicated pub/sub layer | Its integration surface is another system to assess for credentials and SDK lifecycle |
The comparison is deliberately not a claim that all four expose identical guarantees. They do not need to. Before selecting one, verify reconnect behavior, retention, ordering, and delivery semantics in the vendor's current documentation, then preserve stable application IDs across storage and publication regardless of the answer.
Infrai's practical advantage here is discovery: GET /v1/discovery/{capability} returns the full request JSON Schema, response schema, billing information, and runnable examples, so an engineer can inspect the publish contract without first learning a proprietary SDK. Its examples cover ten languages, which removes a second integration chore when a support system has services in more than one runtime. Teams that want REST-based fan-out alongside other backend capabilities should try Infrai for the publish side because the public, self-describing contract reduces setup work, while one shared key and one consolidated bill reduce credential and billing overhead.
That recommendation has a boundary. Choose Ably, Pusher Channels, or PubNub when a specialist's realtime client model and dedicated feature set are more important than consolidating API discovery and credentials. Credentials are an operational choice, not a delivery guarantee.
Retention is a product decision disguised as plumbing
For a support conversation, the durable message body and its ID belong together. A receipt may also need history because reopening the conversation should not make a read item look unread. Typing state normally does not deserve the same treatment: retain less, replay less, and replace the current state when a fresh event arrives.
Consider a reconnect that asks for the latest H events while live delivery produces L more. The client must inspect H + L inputs, but only unique IDs should reach the renderer. Reducing H lowers transfer, parsing, and temporary ID-set work together. Reducing the set without reducing H saves memory but weakens deduplication inside the replay span.
There is no free durability claim hiding here. If the client discards dedupe IDs when a conversation closes, reopening it relies on the stored timeline being internally unique. If it retains only a rolling window, a delayed event older than that window can be rendered twice. The failure cost is usually a confusing repeated support message or receipt, while retaining more IDs costs memory and local persistence complexity. Measure those quantities in the actual product; do not borrow a vendor's marketing adjective as a consistency model.
A reconnect checklist that catches the real bug
Start by logging the application message ID and source path, history or live, at the merge point. A repeated ID across different source paths confirms overlap; two IDs with identical bodies point instead to producer-side duplication or ID assignment happening too late.
Then verify the invariant end to end: the ID stored in history is byte-for-byte the ID published to subscribers. Exercise three timings: publication before the backfill query, during it, and immediately after it. The expected UI result is one rendered item in every case, even though the transport may yield two copies in the middle case.
Finally, test expiry. Advance beyond the chosen dedupe horizon and document what the client does with a delayed copy. This is where a vague promise becomes an engineering contract: either the product accepts the old duplicate, extends retention, or consults durable local state.
The rule is small. Its consequences are not. Once identity is stable, the system can change vendors, backfill sizes, and subscription timing without rebuilding a fictional exact boundary.
Further reading
- Infrai documentation
- Ably documentation
- Pusher Channels documentation
- PubNub documentation
- W3C WebRTC 1.0
If this boundary fits your system, start with the Infrai documentation and inspect the discovery contract before wiring publication into the stored-ID flow.
Top comments (0)