For realtime customer support chat, security controls should make publish acknowledgements prove that one authorized event crossed one defined boundary, not that a person saw it.

Choice Acknowledges Reconnect behavior Best fit
Socket receipt only The realtime edge accepted a publish Client retries from local memory Ephemeral typing indicators
Durable publish receipt The event was stored with an immutable ID Client resumes from a server cursor Chat messages and read receipts
End-user receipt A specific authorized user advanced their read position Server returns the latest position Unread state across devices

Recommendation: use all three meanings, but give each a distinct event type and field. Typing indicators can expire and disappear. Customer messages and read positions need durable IDs, authorization checks, idempotent retries, and cursor-based backfill. In an edtech support chat, that split keeps a student's "agent is typing" signal cheap and temporary while preserving the actual help request and its read state after a laptop sleeps.

This is the revenue-per-hour choice for a small SaaS. Don't build durable machinery for presence theater, and don't treat a transient socket response as a durable record. Ship the narrow contract first.

How should realtime publish acknowledgements secure a customer support chat?

The acknowledgement must bind together the authenticated principal, conversation, event ID, event kind, and accepted sequence position. A bare { ok: true } is too weak because it doesn't say what was accepted, for whom, or where a reconnect should resume. The server should derive the actor from the authenticated session rather than trust an actorId supplied in the payload, authorize that actor against the conversation before accepting the event, and return the client-generated event ID so the caller can match an acknowledgement to an in-flight publish.

Keep the claims narrow. A publish receipt can mean "accepted by the durable event log." It must not mean "rendered in the agent's browser" or "read by the student." Those later facts belong to separate events. This matters when two support tabs are open, a mobile device reconnects late, or an agent transfers the conversation: one transport action can't honestly certify all of those outcomes.

For a concrete example, suppose a student sends evt_01JCHAT8M4 to conversation conv_algebra_42. The acceptance response can repeat that event ID and assign cursor 1842. A retry carrying the same ID should resolve to the same accepted event instead of creating a second message. An agent reading through cursor 1842 then publishes a read-position event scoped to that agent and conversation. The server advances a position monotonically; an older delayed update must not move it backward. The identifiers here are illustrative contract values, not measured production data.

Small distinction. Big consequences.

The security controls sit around that state transition: authenticate the connection, authorize every publish, constrain event types and payload sizes, reject conversation changes after authorization, rate-limit by principal and conversation, and record a security audit entry without copying sensitive message bodies into logs. Encryption in transit protects the channel. It doesn't replace object-level authorization.

Acknowledgement semantics are the first decision criterion

Before choosing a transport or service, write down the state machine. A useful message lifecycle is pending -> accepted -> delivered -> read, with each transition owned by a different observation. Pending is local. Accepted comes from the durable publish boundary. Delivered means an authorized recipient session received the event, if the product truly needs that distinction. Read means the recipient advanced an explicit per-conversation position. Avoid one ambiguous status callback that changes meaning depending on timing.

Typing indicators need a different contract. They are short-lived hints, can be overwritten by a newer hint, and should not be replayed after reconnect. Persisting them creates misleading history: nobody benefits from learning that an agent was typing four minutes ago. Read receipts are durable user state, but they should store a high-water mark rather than one permanent event for every message. That reduces write amplification and makes an out-of-order update harmless when the server applies max(currentCursor, submittedCursor) after authorization.

WebRTC data channels illustrate why application acknowledgements remain necessary even when a transport offers ordering. The W3C recommendation defines an ordered setting that is true by default, optional retransmission limits, a bufferedAmount signal, and an error event. Those are transport properties. They don't prove that a chat event passed an application's permission check or entered its durable log. Whatever transport is selected, the product-level receipt still needs its own precise claim.

There is a catch: delivery and read tracking collect user activity metadata. A support team may need read state for handoff and response quality, but a classroom deployment may have stricter retention expectations. Keep the smallest state that supports the workflow, set a retention policy, restrict who can query it, and make the UI wording match the recorded fact. "Seen" should not appear when the system has only recorded socket delivery.

Reconnect and backfill are the second decision criterion

Reconnect is where attractive demos become operational systems. The client should persist the last applied durable cursor, reconnect with that cursor, fetch events after it, apply them in order, and then join the live stream. The server must define what happens at the boundary between backfill and live subscription so an event is neither lost nor applied twice. The simplest client rule is idempotent application by immutable event ID; duplicates are acceptable at the transport boundary because they collapse before changing UI state.

Race windows deserve a test, not a comment. Open a subscription, pause delivery, publish events at cursors 1843 and 1844, reconnect from 1842, and release the pause while backfill is running. The final rendered timeline must contain each durable event once and end at 1844. Repeat with the live event arriving before its backfilled copy. Then expire the cursor deliberately and verify that the server returns an explicit resync outcome rather than silently starting at "now." The exact retention window is a product decision, so I'm not sure there is a universal duration; storage cost, support audit needs, and the longest expected offline period should settle it.

Backfill must repeat authorization. A cursor is a position, not a bearer credential. If the student was removed from a conversation while offline, reconnecting with an old cursor must not reopen access. Apply the current membership policy before returning history, and filter the stream through the same conversation scope used for direct reads.

Observe the gaps. Track acknowledgement latency, rejected publishes by stable reason code, retry counts, duplicate event IDs, cursor age at reconnect, backfill size, and resync frequency. Avoid message bodies and access tokens in metrics. A useful alert is a sustained rise in client retries without a matching rise in authorized accepts; it points to a boundary problem without requiring sensitive content in telemetry.

One TypeScript contract keeps retries honest

The application contract can stay transport-neutral. This example separates transient typing from durable events and makes the acknowledgement claim explicit:

type DurableKind = "chat.message" | "conversation.read"; type TransientKind = "conversation.typing"; type PublishRequest = { conversationId: string; clientEventId: string; kind: DurableKind | TransientKind; payload: unknown; }; type PublishReceipt = | { accepted: true; clientEventId: string; durability: "durable"; cursor: number; } | { accepted: true; clientEventId: string; durability: "transient"; expiresAt: string; } | { accepted: false; clientEventId: string; code: "UNAUTHORIZED" | "FORBIDDEN" | "INVALID_EVENT" | "RATE_LIMITED"; }; interface ChatTransport { publish(request: PublishRequest): Promise<PublishReceipt>; backfill(conversationId: string, afterCursor: number): Promise<ChatEvent[]>; } type ChatEvent = { eventId: string; conversationId: string; cursor: number; kind: DurableKind; payload: unknown; }; 

The handler behind this interface should validate a schema chosen by kind, obtain the principal from the session, authorize the conversation, and only then append a durable event or broadcast a transient one. Use stable rejection codes for client behavior and observability, but keep detailed policy reasons server-side; telling an unauthorized caller whether a private conversation exists leaks information.

On the client, pending durable publishes remain keyed by clientEventId until an accepted receipt arrives. A timeout means "outcome unknown," not "publish failed," so retry with the same ID. For FORBIDDEN or INVALID_EVENT, stop retrying. Apply exponential backoff and jitter to transport failures and rate limits, then reconcile against backfill after reconnect. Typing can be dropped immediately when disconnected.

Test the contract at its trust boundaries: a valid user in the wrong conversation, a replayed event ID with a changed payload, a read position beyond the latest authorized cursor, an older read update, a malformed event kind, and two devices publishing the same retry concurrently. The expected behavior should be deterministic. Security tests also need to prove that audit records contain IDs and outcomes without message text.

When should the runner-up architecture win?

A socket-only receipt is the runner-up for typing indicators and other disposable presence signals. Stick with it when the event has no value after a few seconds, duplicates don't change durable state, and reconnect replay would confuse the user. It is not suitable for messages, support handoff notes, or read positions because an accepted frame doesn't establish durable application state.

At the other extreme, a conventional request followed by stream fan-out can be easier to operate than publishing every durable command over a long-lived connection. Choose that shape when the existing request path already handles authentication, authorization, idempotency, validation, and audit logging well. The realtime channel can then carry notifications while backfill uses the same durable store. The cost is another round trip and two paths to trace, but a one-person team may reasonably prefer boring operational boundaries over a clever unified socket protocol.

Neither choice removes the reconnect work.

The final decision is therefore about ownership of truth. Put transient UX hints on the realtime edge. Put messages and read positions behind a durable, authorized, idempotent boundary. Make the receipt name that boundary, and make backfill the recovery path you test before launch. That division is small enough to ship weekly and clear enough to debug without turning chat infrastructure into the product.

References