Every real-time application eventually has to answer a simple but uncomfortable question: what happens when the connection drops?
The honest answer, for most applications, is: not much good. The client reconnects, but it has no idea what happened while it was offline. It either shows stale data, re-fetches everything from the REST API (expensive and slow), or just leaves the user staring at whatever was on screen when the disconnect happened. None of those are good user experiences.
This post explains why disconnects happen, what the naive recovery approaches get wrong, and then is precise about NoLag: what a reconnect does for you, what it deliberately leaves to you, and how to design for the gap.
Why Disconnects Happen
Disconnects are not edge cases. They are a normal part of operating a real-time application in the real world.
Network switching. A user walks from their desk to a conference room. Their laptop drops Wi-Fi and picks up a different access point. That transition takes a few seconds and the WebSocket connection dies mid-handoff. The OS may not even surface this to the application immediately.
Mobile sleep. On iOS and Android, backgrounding an app causes the OS to aggressively suspend network activity to preserve battery. The WebSocket connection is severed. When the user brings the app to the foreground, it needs to reconnect and catch up on what it missed.
Train tunnels and dead zones. Users move through the physical world. Cellular coverage has gaps. A user reading a chat app on a commute will pass through areas with no signal. The connection drops and comes back, potentially multiple times in a single session.
Server deploys. Rolling out a new version of your backend closes existing WebSocket connections. If you deploy daily, every user gets disconnected once per day. If your reconnection story is poor, that is a daily UX degradation for your entire user base.
Idle timeouts. Load balancers, reverse proxies, and NAT gateways close idle connections. A user who has a chat window open but has not sent a message in 10 minutes may find their connection silently dropped by an intermediate piece of network infrastructure.
The Reconnection Problem
Reconnecting the WebSocket is the easy part. Any decent client SDK handles exponential backoff and reconnection automatically. The hard part is what comes after: the gap.
The client was connected, then lost the connection for 90 seconds. During those 90 seconds, 39 messages were published to the topics it was subscribed to. The client reconnects and starts receiving from the next message onward. Those 39 messages never reached it. The client's state is now inconsistent with reality.
In a chat application, those 39 messages might be a whole conversation thread the user missed. In a collaborative document, they are edits from other users that the local copy does not reflect. In a live trading dashboard, they are price updates that left the displayed prices stale.
Naive Approaches and Why They Fall Short
Re-fetching Everything
The simplest recovery is to call your REST API on reconnect and reload the full state. This works, but it is slow and expensive. A chat room with 500 messages means fetching all 500 on every reconnect. A dashboard with 200 data points means 200 API calls or one large query. At scale, every server deploy triggers a stampede of re-fetch requests from every connected client simultaneously.
Ignoring the Gap
Some applications simply pick up from where they reconnect and show nothing for the gap period. This is the worst option. The user sees a discontinuity in their data without any indication of what they missed. In a chat app, conversation threads jump forward inexplicably. In a notification feed, important alerts are silently lost.
Client-Side Timestamps
A slightly better approach is for the client to send its last-received timestamp on reconnect and ask the server to resend anything newer. This is closer to correct but has problems. Client clocks drift. Timestamps can collide or reorder under high throughput. And it requires custom server-side logic per application to query and replay by timestamp.
What a NoLag Reconnect Does
The NoLag SDK reconnects for you. When the socket drops, it retries with exponential backoff (5 seconds, growing by 1.5x per attempt, capped at 30 seconds, up to 10 attempts by default), and collapses that backoff the moment the device reports that connectivity is back. Client tokens minted by a token provider are refreshed before they expire. You do not write any of that.
What you do write is the subscribing, and the place for it is the connect handler rather than the line after your first connect(). The handler runs on the first connection and again after every successful reconnect, and a self-hosted broker does not reliably restore a connection's subscriptions on its own. Subscribing to a topic the connection already has is idempotent on the broker, so there is no harm in doing it every time. Your room.on handlers are registered locally and survive the reconnect, so those are registered once.
import { NoLag } from '@nolag/js-sdk'
// KRAKEN_URL is your broker, for example ws://localhost:8080/ws
const client = NoLag('YOUR_TOKEN', { url: KRAKEN_URL })
// APP_SLUG and the room are whatever your auth config grants this token;
// subscribing never creates a room.
const room = client.setApp(APP_SLUG).setRoom('room_abc')
room.on('chat', (message) => appendMessage(message))
client.on('disconnect', (reason) => showOfflineBanner(reason))
client.on('reconnect', () => showReconnecting()) // fires when a retry is scheduled
// Fires on the first connection and again after every successful reconnect.
client.on('connect', () => {
room.subscribe('chat')
hideBanners()
})
await client.connect()Two other things are not restored, and both are easy to miss. Room presence is not: if this client had called room.setPresence(...), call it again in the same connect handler, or let a blueprint SDK do it (they re-apply presence after every reconnect). And the messages published during the gap are not: a reconnect replays nothing, and a fresh subscription receives no history. That is the deliberate part, and the next section is about living with it.
Designing for the Gap
NoLag is a messaging layer, not a database. The gap is yours to fill, and the good news is that you know exactly when to fill it: the connect handler runs after every successful reconnect, so the sequence "subscribe, then fetch what I missed, then apply live messages" falls out naturally.
let lastSeenAt = 0
let firstConnection = true
client.on('connect', async () => {
room.subscribe('chat') // first, so nothing published from here on is lost
if (firstConnection) {
firstConnection = false
return // initial load already rendered the room
}
// Ask your own API only for what was published while we were away.
const missed = await fetchMessagesSince(lastSeenAt)
for (const message of missed) appendMessageSilently(message)
})
room.on('chat', (message: ChatMessage) => {
// The broker does not stamp a time on messages: carry one in the payload,
// assigned by whichever service writes the message to your store.
lastSeenAt = message.sentAt
appendMessage(message)
})This is the re-fetch approach from earlier, minus its two problems. It fetches only the gap, not the whole room, and it subscribes before it fetches, so a message published during the fetch itself arrives through the subscription rather than falling into a second gap. Deduplicate on your own message ID when merging, since the same message can plausibly arrive both ways.
Two things make this cheaper still. Design your topics so a missed message is superseded rather than required: a dashboard that publishes the full current value of a metric needs no catch-up at all, because the next update makes it whole. And for a subscription that represents state rather than a stream, a hydration webhook lets the broker hand a client the current state when it subscribes (delivered on client.on('hydration', ({ topic, data }) => ...)). Because the subscribe in the connect handler runs again after every reconnect, the webhook is called again too, so a state topic catches up without a separate fetch.
Caught-Up Messages Should Arrive Quietly
Messages fetched to fill a gap should be added to the UI to restore consistency, but they should not trigger notifications. If a chat app shows a desktop notification for every message fetched after an hour offline, a user comes back to 200 popups for messages they already saw on another device, or from a conversation they have muted. Live messages deserve the full treatment: badge counts increment, notifications fire, scroll behaviour updates. Caught-up messages should just appear in their correct position in the timeline, quietly. appendMessageSilently in the earlier sample is that rule.
How This Works in NoLag SDKs
In the NoLag client SDKs, reconnection is automatic: backoff, connectivity detection and token refresh are handled for you, and your room.on(topic, (data, meta) => ...) handlers keep running across reconnects. Subscriptions and room presence belong in the connect handler, which runs after every successful reconnect; the blueprint SDKs re-apply presence for you.
You do not write reconnection logic, and you do not implement retry backoff. What you do own is the gap: subscribe in the connect handler, keep the messages you care about in your own store, fetch just the missed window on connect, prefer topics whose latest message supersedes the ones before it, and treat caught-up messages quietly. The SDK handles the transport layer and gives you a clean, ordered stream of messages to build the rest on.