Build a team chat app
This guide builds a team chat: channels, a list of who's online, typing indicators and unread counts. Showing a message as it arrives is the easy part. The hard parts are what users never think about until it goes wrong:
- Every message once, in order. After a dropped connection, the client must get exactly what it missed: no gap, no duplicate, nothing out of order.
- A message typed is a message sent. A dropped connection, a reload or a closed laptop shouldn't lose one, or send it twice.
- Unread counts that agree. Read a channel on the laptop, and the phone's count goes to zero.
- Who's here, and nobody frozen online. Someone who closes the tab disappears at once, and someone whose laptop died disappears soon after.
- Twelve tabs, one connection. A user who keeps the chat open in several tabs costs the server one connection, not twelve.
The protocol
Messages in a channel are numbered, one more each time, so a client can say where it left off. Everything else is plain messages:
The client
createTabsWebSocketmakes every tab of the app share one connection: one tab holds it, and the others reach it over aBroadcastChannel. Withtopics, a channel's messages go only to the tabs showing it, and one tab leaving a channel doesn't unsubscribe another.hellois what tabs change on the server. A tab's socket opens through the tab that holds the connection, and the server never hears about it, so it can't greet each tab with the channel list. Each tab asks instead, from anopenlistener, before anything it queued.feedsholds the open channel as a numbered feed. After a reconnect it subscribes again with the number of the last message it got, drops anything it already has, and asks again when a number is skipped. Themessagesstore reduces the feed's messages, not the client's, so its reducer sees each message once, in order.channelskeeps the unread counts: the newest number in each channel, and the newest one this user has read. Both only go up, so messages that arrive late can't move a count back.markReadsends with akey: while disconnected, only the newest read per channel waits in the queue.outboxkeeps each message in IndexedDB until the server acknowledges it, and sends it again on every connection until then. See Offline outbox.hereshares who's online and typing with presence. The tabs share one connection, so the server can't tell them apart: each tab names itself.pagehidesendsleave, so a closed tab disappears at once instead of after 30 seconds. The listener is added when the module loads, before the first connection adds the tabs' ownpagehidelistener, so theleavegoes out before the tab lets go of the connection.
The page
- A channel on screen is a held topic:
useWebSocketTopicsubscribes whenRoommounts and unsubscribes when it unmounts. Switching channels remountsRoomwith a newkey. - Reading happens whenever the newest message changes while the tab is visible, and when the tab becomes visible again.
feeds.position(topic)is the number of the last message the feed delivered. - Unsent messages come from the outbox, below the channel's messages, until the server's copy arrives with the same id. "Not sent yet" means the connection is down, and "Sending…" that it went out and waits for its acknowledgement.
- Typing starts with the first keystroke, and stops three seconds after the last one, when the message is sent, or when the user leaves the channel. The composer calls
here.set()only when it starts or stops, and presence refreshes the state in between.
The server
The server numbers each channel's messages, keeps the last thousand to catch clients up, and answers every message with its id. It runs in Node 22.18 or later without a build step, which is why it imports .ts files by name. Everything lives in memory here: keep the channels, their numbers and the handled ids in your database.
Channels and catching up
- A client that's behind by a few messages gets just those, then everything new.
- A client starting fresh, or too far behind, gets the channel's history: the last 50 messages and the channel's number. The feed always delivers that snapshot, and numbering goes on from it.
- A client ahead of the server gets the history too. That only happens when the server lost its numbers, say a restart with nothing persisted. Without this, the client would drop the server's next messages as duplicates of ones it already had.
Messages
- Each message id is handled once. The outbox sends a message again on every connection until it's acknowledged, and two tabs can both send one they found in storage. A second copy gets an acknowledgement and nothing else.
activitygoes to everyone, in every channel, for the unread counts. Only the channel's subscribers get the message itself.- Your own messages are marked read for you.
The connection
Peers are named by user and tab, since a user's tabs share one socket. When a socket closes, every peer on it leaves at once.
When things go wrong
In Chrome, against the server on this page, with two users and three tabs:
- Tabs. Alice's two tabs opened one connection to the server, and each sent its own
helloandsubscribeover it. - Unread counts. Two messages in a channel she wasn't showing showed "2" in both her tabs. Opening the channel in one tab cleared the count in both.
- Resuming. A connection closed and opened again while three messages were posted subscribed again from message 2, and got exactly those three, in order.
- Reloading. Two messages sent while the server was down survived a reload of the tab, went out once the server was back, and reached the other user once each.
- Closing a tab. Its
leavereached the server before the tab let go of the connection, and the other user's list dropped that tab's peer within half a second.
Testing it
Run two browser profiles, one per user, and several tabs in each. Stop the server while sending to watch the outbox, and use react-websocket/chaos to drop the connection at random. Pass storage: memoryStorage() to the outbox in unit tests, so they don't depend on IndexedDB.
See also
- Topic subscriptions and numbered feeds,
createWebSocketFeeds - Offline outbox and
createWebSocketOutbox - Presence and live cursors and One connection across tabs
- Build a collaborative whiteboard: the same building blocks for drawing together