---
title: "Handling Disconnects Gracefully: Reconnection and Message Replay"
description: "Mobile networks drop, Wi-Fi switches, laptops close. Learn what a reconnect restores, what it does not, how to design for the gap, and when the broker replays missed messages for worker groups."
excerpt: "Mobile networks drop. Wi-Fi switches. Laptops close. A good real-time system recovers silently. This post covers reconnection strategies and message replay."
date: '2026-03-03'
readTime: 7 min read
category: Engineering
---

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 restores for you, what it deliberately does not, how to design for the gap, and the one case where the broker does replay what a client missed.

## 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 Restores

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.

When the new connection authenticates, the broker restores every subscription the connection had, with its filters and load-balancing settings intact. You do not resubscribe. Your `room.on` handlers are still registered locally, so live messages start arriving again with no code on your side.

```typescript [TypeScript]
import { NoLag } from '@nolag/js-sdk'

const client = NoLag('YOUR_TOKEN')

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.
// Subscriptions are already restored by the time this runs.
client.on('connect', () => {
  hideBanners()
})

await client.connect()

// APP_SLUG is the slug returned when you created the app (it has a random
// suffix); the room must already exist in the control plane.
const room = client.setApp(APP_SLUG).setRoom('room_abc')
room.subscribe('chat')
room.on('chat', (message) => appendMessage(message))
```

Two 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 `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: an ordinary 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. For an ordinary client, the gap is yours to fill, and the good news is that you know exactly when to fill it: the `connect` event fires after every successful reconnect, once subscriptions are back, so the sequence "restore subscriptions, then fetch what I missed, then apply live messages" falls out naturally.

```typescript [TypeScript]
let lastSeenAt = 0
let firstConnection = true

client.on('connect', async () => {
  if (firstConnection) {
    firstConnection = false
    return                       // initial load already rendered the room
  }
  // Ask your own API only for what was published while we were away.
  // Subscriptions are already restored, so nothing published from here on is lost.
  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 runs after the subscriptions are live, 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](/docs/concepts/webhooks) lets the broker hand a client the current state when it subscribes (delivered on `client.on('hydration', ({ topic, data }) => ...)`), which takes care of the initial load. It does not run again when a reconnect restores the subscription, so the gap fetch above still belongs in the `connect` handler.

## The isReplay Flag

There is one situation where the broker does replay messages, and it is worth understanding both because you may build on it and because it is where the `isReplay` flag comes from.

Replay exists for **load-balanced worker groups with persistent sessions** on the hosted platform: a pool of workers connected as `agent` or `orchestrator` actors, subscribed with `loadBalance: true`, where each message is delivered to exactly one member of the group. When such a group has scaled to zero and a member comes back, the broker replays the messages the group missed, claiming each one so that only one worker receives it. Replayed messages arrive between `replay:start` and `replay:end` events, live messages that arrive during the replay are buffered and flushed afterwards, and each replayed message carries `isReplay: true` in its metadata. The full contract, including what it does not cover, is on the [Replay and Durable Delivery](/docs/concepts/replay) page. It is not something an ordinary client subscription can opt into.

If you are writing a worker, the flag is what lets you tell catch-up from live:

```typescript [TypeScript]
import { NoLag } from '@nolag/js-sdk'
import type { MessageMeta } from '@nolag/js-sdk'

// A worker in a load-balanced group; the token belongs to an `agent` actor,
// whose broker session persists while it is away.
const worker = NoLag(WORKER_TOKEN, { loadBalance: true, loadBalanceGroup: 'image-workers' })

worker.on('replay:start', ({ count }) => log('catching up on', count, 'jobs'))
worker.on('replay:end', ({ replayed }) => log('caught up:', replayed))

await worker.connect()
const room = worker.setApp(APP_SLUG).setRoom('image-processing')
room.subscribe('jobs')

room.on('jobs', (job: ImageJob, meta: MessageMeta) => {
  // Always do the work
  processJob(job)

  // Only do "something just happened" side effects for live jobs
  if (!meta.isReplay) {
    metrics.increment('jobs.live')
    notifyOperators(job)
  }
})
```

The same instinct applies to any catch-up you implement yourself, which is why the flag is worth borrowing as a convention. 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 and subscriptions are restored by the broker, with filters and load-balancing intact. Your `room.on(topic, (data, meta) => ...)` handlers keep running across reconnects, and `meta.isReplay` is set on replayed messages for worker groups. Room presence is re-applied by the blueprint SDKs and is one call in the `connect` handler if you use the core SDK directly.

You do not write reconnection logic. You do not implement retry backoff. You do not resubscribe. What you do own is the gap: 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 with enough context to handle each one correctly.
