Short answer: make the event identity durable, deduplicate at the consumer boundary, and resume from a server-issued cursor; a client-side set alone cannot make a marketplace chat room survive reconnects or an incident-response burst. The constraint is trust. A browser reconnects after a laptop sleeps, a mobile radio changes networks, or a tab is restored from the back-forward cache. It may replay its last request, lose an acknowledgement, or present an event twice. In an incident response dashboard, the same mechanics become dangerous at scale: an alert that appears twice can page two people, while a missing alert can hide the incident. I design the storage boundary first, because a pretty WebSocket demo does not answer either question. Start with an event identity that can outlive a connection Every published event needs an immutable identity scoped to the stream, not to a socket. For a marketplace chat room, I use (room_id, sequence) as the primary key and keep a globally unique event_id for tracing. The sequence is allocated by the room writer, so two reconnecting clients can compare progress without trusting wall-clock timestamps. The payload is deliberately boring. It includes the room, sequence, event ID, type, and data. A client can verify that an event belongs to the room it requested; it cannot mint a higher sequence or widen its token scope. That last rule matters more than transport choice. from dataclasses import dataclass from typing import Any @dataclass ( frozen = True ) class ChatEvent : room_id : str sequence : int event_id : str event_type : str data : dict [ str , Any ] def identity ( event : ChatEvent ) -> tuple [ str , int ]: """ The room sequence is the replay-safe identity. """ return event . room_id , event . sequence Do not use a payload hash as the only key. Two legitimate messages can have identical text, and a producer retry can produce different JSON ordering. Persist the identity and the payload together, with a uniqueness constraint, before acknowledging the producer. How should realtime duplicate event suppression scale event delivery? The answer is a three-stage path: durable append, bounded replay, and idempotent apply. Durable append gives the server something to replay. Bounded replay prevents a client that was offline for six months from forcing an unbounded scan. Idempotent apply makes a repeated delivery harmless. On reconnect, the client sends the last contiguous sequence it has applied. The server validates the room token, checks retention, and returns events after that cursor. If the cursor is older than the retention window, return a snapshot plus a new cursor; silently starting at “now” is data loss disguised as recovery. Here is the consumer-side part. The database transaction that records applied_events must commit with the projection update. A process crash between those two writes is exactly how duplicate suppression becomes a claim instead of a guarantee. def apply_once ( db , event : ChatEvent ) -> bool : """ Return False when this event was already committed. """ with db . transaction (): inserted = db . insert_ignore ( " applied_events " , { " room_id " : event . room_id , " sequence " : event . sequence , " event_id " : event . event_id }, unique_by = ( " room_id " , " sequence " ), ) if not inserted : return False db . update_projection ( event . room_id , event ) return True The table needs a retention policy. Keep enough history for the longest supported reconnect plus an operational margin, then compact old events into snapshots. For high-volume incident feeds, a partitioned append log and a materialized “current state” table are easier to inspect than a mutable row that hides history. One sentence version: delivery is at-least-once, application is exactly-once per (room_id, sequence) . Token scope is the trust boundary, not a UI detail A reconnect token should name the room (or an explicit set of rooms), the maximum readable sequence, and an expiry. The server derives authorization from that token on every replay request. The browser supplies a cursor; it does not supply permission. In an incident response dashboard, this prevents a responder who can view one incident from probing another incident by changing an ID in a replay URL. In marketplace chat, it prevents a buyer's token from reading a seller's unrelated rooms. Scope checks should happen before storage lookup so unauthorized room IDs do not become a timing oracle. I also separate publication authorization from subscription authorization. A service may be allowed to append an incident status event but not read the associated private chat. Combining those capabilities into one broad token makes auditing painful and rotation risky. Pick the log by failure mode, not by throughput slogans The common options have different operational shapes. Redis Streams are convenient when a team already operates Redis and needs consumer groups, but retention and memory pressure need explicit policies. NATS JetStream offers a stream model with configurable retention and acknowledgements; teams still need to design replay authorization and projection idempotency. Kafka provides durable partitions and mature replay tooling, while partition keys and consumer lag become part of the day-to-day operating model. Choice Useful property Cost or boundary Redis Streams Familiar data structure and consumer groups Memory sizing and trimming are your responsibility NATS JetStream Explicit stream retention and acknowledgements Replay permissions remain application logic Kafka Long retention, partitions, and ecosystem tooling Partition design and lag operations add complexity Relational append log Transactions with projections are straightforward Horizontal fan-out requires deliberate indexing and workers None of these removes duplicate delivery. They move where you observe it. Your mileage may vary with message size, retention, and the number of rooms per tenant; measure those variables with production-shaped load before committing to a platform. Test the ugly reconnect paths before rollout A useful test is not “does a message arrive.” It is: publish 10,000 ordered events, kill the consumer after its acknowledgement but before its projection commit, reconnect with cursors at the beginning, middle, and retention boundary, and verify that the projection contains each sequence once and in order. Make that test deliberately inconvenient. Run two consumers for the same room, pause one for 90 seconds, rotate the signing key while it is paused, and deliver events in batches of uneven size. Then force a deployment restart between the insert into applied_events and the projection update, which is the narrow window most happy-path suites never exercise. The expected result is specific: the restarted worker may see the same event_id again, but the unique (room_id, sequence) record makes the second application a no-op; the authorized token can still replay from its last committed contiguous sequence; and a token signed with the retired key is rejected before the log is queried. Repeat the run with a client that sends sequence 0, then sequence 417, then sequence 416, because real reconnect code does produce out-of-order cursors when tabs race. Capture the source count, applied count, duplicate count, and maximum gap as test artifacts. If any count differs after the final transaction settles, the test should fail even when the UI appears correct. That evidence is more valuable than a benchmark headline because it exercises the exact boundary where delivery, authorization, and storage meet. I once assumed a cursor stored in local storage was enough. It was not. A browser restored an old tab, sent sequence 417, and the server interpreted it as a fresh subscription; the resulting replay looked like a flood to the operator. The fix was to make the server return a canonical cursor and to record the last contiguous sequence only after the projection transaction committed. Error code CURSOR_TOO_OLD then triggers a snapshot path instead of a best-effort replay. Keep metrics that reveal the failure, not just the volume: duplicate suppression count by room, replay age, cursor-too-old rate, projection commit latency, and the gap between published and applied sequence. Log event_id , room, sequence, token subject, and reconnect reason with privacy-safe identifiers. Alert on a rising gap, then inspect retention and consumer lag before scaling workers. Roll out with a reversible decision rule Start with one room partition and a short retention window, mirror the event IDs into an audit sink, and compare applied sequences against the source log. Increase retention only after reconnect tests pass during deploys and key rotation. Keep a snapshot endpoint available for the cursor-too-old case, and rehearse restoring it from a known event boundary. The catch is that this design is not suitable when your product requires global total ordering across every room, or when clients must edit history in place; use a workflow engine or a domain-specific ledger for those semantics. Stick with a simpler request/response read model when chat is ephemeral and losing offline history is an accepted product decision. The extra log, index, and metrics are justified only when duplicate or missing events have a real operational cost. That is the decision I would put in the review record: durable identity, scoped replay, transactional apply, and a measured recovery path. Transport can change later. Trust and history cannot be improvised after the first incident. Sources https://www.w3.org/TR/webrtc/ https://redis.io/docs/latest/develop/data-types/streams/ https://docs.nats.io/nats-concepts/jetstream https://kafka.apache.org/documentation/#intro_concepts_and_terms https://www.rfc-editor.org/rfc/rfc6749