Guide · Frontend
Design a Chat App Frontend: Sockets, Offline Queue, Receipts
26 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- Chat app frontend system design is the plan for the client, the part of a chat app in the phone's browser. It covers the live connection, offline queue, message order and ticks (the sent, delivered and read marks).
- Keep one WebSocket, a connection where both sides can send at any time. Send a tiny heartbeat message to spot a dead link, and reconnect after growing, random waits.
- Save every message to an outbox, a list of unsent messages kept on the phone, before sending it. Each carries an id made on the phone, so the server can spot and drop a resend.
- The server gives each message a sequence number, a counter that rises by one per message in that chat. The client sorts by it, spots gaps and fetches what it missed.
- Beginners can stop after the receipts section. Engineers then get tested details from Chromium, the open-source browser that Chrome and Edge are built on. A silently dead socket stayed open for the full minute we watched, and closing it took 60 more seconds.
What goes wrong when a chat app runs on a weak network?
Messages go missing when the app sends them into a connection that has quietly died. They arrive twice when the app retries without giving the server a way to spot the repeat. Fixing both is the heart of chat app frontend system design.
Picture a common setup. A messaging app serves families and small shop owners, about 3 lakh (300,000) users, and most open it in a phone browser on patchy 4G. The setup is made up, but the problems are real.
The team's first version called send on the connection and hoped for the best. A month of support tickets showed four problems again and again.
- A message typed inside a lift showed one tick the moment the app called send, then vanished when the phone found signal again.
- A hardware shop owner got the same order for 30 bags of cement twice, and delivered both.
- After a reconnect, replies sometimes appeared above the question they were answering.
- Blue ticks showed for messages the other person never saw, because the chat was open in a background tab.
Each bug started in the client, meaning the app running on the phone. This guide designs that client, and names the few things it needs from the server.
What is chat app frontend system design?
Chat app frontend system design is planning the client side of a messaging app: its parts, the data it keeps and how it talks to the server. Above all, it decides what happens when the network fails.
A chat client has four jobs, and each one gets its own section below.
- Connection: keep a live link to the server, notice when it dies, and bring it back.
- Sending: never lose a typed message, even offline, and never deliver it twice.
- Receiving: show messages in the right order, with nothing missing.
- Receipts and presence: show sent, delivered and read ticks, plus who is typing.
Three words come up again and again. A WebSocket is one long-lived connection where the server and the phone can both send at any time. An outbox is a list of messages waiting to be sent. An acknowledgement, or ack, is a short reply from the server that says a message arrived safely.
Here are the parts of the client. Start at the chat screen. Notice that only the socket manager talks to the chat server, and every new message goes into the outbox first.
Parts of the chat client
Drag to exploreThe screen reads from one store. Only the socket manager talks to the chat server, and every new message is saved in the outbox first.
Diagram connections:
- Chat screen to Chat store (Reads, sends taps)
- Chat store to Outbox in IndexedDB (Saves new message)
- Outbox in IndexedDB to Socket manager (Next unsent message)
- Socket manager to Chat server (Messages, acks, receipts)
- Socket manager to Chat store (Incoming messages)
- Chat server to Browser push service (Alert for offline user)
- Browser push service to Service worker (Wakes it up)
- Chat screen: Message list, composer and ticks
- Chat store: Messages per chat, sorted by sequence
- Outbox in IndexedDB: Unsent messages, kept across restarts
- Socket manager: Opens, watches and reopens the WebSocket
- Chat server: Stores messages, numbers them, sends acks
- Browser push service: Delivers alerts while the tab is closed
- Service worker: Shows the new message notification
The chat store is one shared place in the app that holds every chat's messages and tells the screen when they change. IndexedDB is a database built into the browser that keeps data after the tab closes. MDN describes it as transactional: changes happen inside transactions, and aborting a transaction rolls all its changes back.
IndexedDB is also asynchronous, so its work does not block the page. A service worker is a script the browser runs apart from your page. The Push API can start it to show a notification even when no page of the app is open.
Live chat itself runs inside the open page. The service worker here only shows notifications for new messages. On iPhones, web push arrived in iOS 16.4 and works only for web apps saved to the Home Screen, per MDN's browser data.
How does a chat app keep a live connection to the server?
A chat app keeps one WebSocket open while it is in use, checks it with small heartbeat messages, and reopens it with growing, random waits when it drops.
Chat uses a WebSocket because messages flow both ways, often, and should appear within about a second. Server-sent events push only from server to client, and polling keeps asking even when nothing is new. Comparing the three is a topic of its own, and worth reading next.
How do you know the connection is dead?
Often you cannot tell at first. When you walk into a lift with your phone, no packet arrives to say goodbye. The browser can keep the socket looking open, and messages sent into it simply go nowhere.
The WebSocket standard, RFC 6455, has ping and pong control frames for this: tiny protocol-level messages, separate from your chat messages. An endpoint that receives a ping must answer with a pong. The RFC says a ping can serve as a keepalive or as a check that the other side still responds.
Page code cannot send those pings. MDN lists only two methods on a WebSocket: send() and close(). The browser answers the server's pings by itself, as the RFC requires and as Chromium, the open-source browser behind Chrome and Edge, did in our test. So the server can find dead clients, but the page needs its own heartbeat.
A heartbeat is a tiny message such as type heartbeat, sent every 25 seconds in this design. If nothing at all comes back within 10 seconds, the socket manager treats the link as dead. The engineer section shows the code and a tested timeline.
The browser also fires online and offline events. MDN calls this status inherently unreliable: a device on a local network, such as home Wi-Fi, generally counts as online even when that network has no internet. Use these events only as hints to try reconnecting sooner.
How should a chat app reconnect?
Reconnect after a short random wait, and wait longer after each failure. RFC 6455 says clients should back off after an abnormal closure, meaning the connection ended without the proper closing message. It calls a random first delay of 0 to 5 seconds reasonable.
The randomness matters most. Picture a power cut in one neighbourhood ending at 8 pm, and ten thousand phones (made up) losing the socket together. If all of them retry exactly 1 second later, they hit the server in one huge wave, again and again.
Spreading the retries out is called jitter. Here is the delay function. The attempt value counts failed tries since the last good connection.
// Wait before reconnect attempt n (0, 1, 2, ...), in milliseconds
export function reconnectDelay(attempt: number): number {
const base = 1000; // 1 second
const cap = 30_000; // never wait more than 30 s
const ceiling = Math.min(cap, base * 2 ** attempt);
return Math.random() * ceiling; // random point from 0 to ceiling
}The ceiling doubles each time, 1, 2, 4, 8 seconds and so on, but never passes 30 seconds. This doubling is called exponential backoff. Picking any point from zero up to the ceiling is the full jitter method from a well-known 2015 AWS Architecture Blog post.
That post found that adding jitter spreads out the spikes and cuts the total work clients do. A good connection resets attempt to zero. Save the function as backoff.ts, then check it with this small script on Node 22.
import { reconnectDelay } from "./backoff";
for (let attempt = 0; attempt <= 6; attempt++) {
const waits = Array.from({ length: 10_000 }, () => reconnectDelay(attempt));
const max = Math.max(...waits);
console.log(`attempt ${attempt}: waits up to ${(max / 1000).toFixed(1)} s`);
}attempt 0: waits up to 1.0 s
attempt 1: waits up to 2.0 s
attempt 2: waits up to 4.0 s
attempt 3: waits up to 8.0 s
attempt 4: waits up to 16.0 s
attempt 5: waits up to 30.0 s
attempt 6: waits up to 30.0 sThe waits are random, so your decimals may differ slightly, but no wait can pass its ceiling. Attempts 5 and 6 both stop at 30 seconds because of the cap.
How does a chat app send messages when the user is offline?
It saves each message to an outbox on the phone, shows it with a clock icon, and sends it once the socket is open. The message leaves the outbox only after the server acknowledges it.
Think of sending a parcel by courier. At the counter you get a receipt with a tracking number. Until you hold that receipt, you assume the parcel has not left. If the counter is shut, the packed parcel simply waits on your table.
The outbox is that table. Every message is written to IndexedDB with a client id before any network call. A client id is a unique id made on the phone, so closing the tab or losing signal cannot wipe the message.
The browser's crypto.randomUUID() makes a good client id: a random 36-character id in the version 4 UUID format. MDN notes it works only in secure contexts, such as HTTPS pages. The server's ack is the counter receipt: it carries the client id back, plus the server's sequence number.
Where the picture breaks: a courier writes the tracking number at the counter, but the client id is written at home, before the trip. That is the point. If the receipt is lost, the parcel can go again under the same number, and the courier spots the repeat.
Follow one message from tap to tick.
Sending one message
Drag to exploreNotice the question box: when the socket is down, the message waits instead of failing. Only the server's ack takes it out of the outbox.
Diagram connections:
- Tap send to Save with client id
- Save with client id to Socket open?
- Socket open? to Send the message (Yes)
- Socket open? to Wait for reconnect (No)
- Wait for reconnect to Send the message (Socket is back)
- Send the message to Ack with sequence number
- Ack with sequence number to Clear outbox, show one tick
- Tap send: User → Composer
- Save with client id: Composer → Outbox
- Socket open?: Outbox → Socket manager
- Wait for reconnect: Socket manager → Outbox
- Send the message: Socket manager → Chat server
- Ack with sequence number: Chat server → Socket manager
- Clear outbox, show one tick: Socket manager → Chat store
If no ack arrives within 15 seconds, the socket manager sends the same message again with the same client id. The server must remember recent client ids for each sender. When it sees one twice, it does not store it again and simply repeats the original ack.
That is idempotency: doing the same request twice has the same effect as doing it once. It is what stops the cement order arriving twice. Idempotency keys for ordinary APIs work the same way, and have a guide of their own.
Some browsers can also hand the outbox to a service worker, which sends it once the network is stable. This is the Background Synchronization API. MDN marks it limited availability because some of the most widely used browsers do not support it.
As of 19 October 2026, MDN's browser data (version 8.1.6) lists it in Chrome, Edge and Samsung Internet, but not in Firefox or Safari. So treat it as a bonus, and always flush the outbox when the app opens.
What should the user see while a message waits?
The user should always see where a message stands, without being nagged. The team settled on three waiting states.
- Clock icon: saved on the phone, not yet acknowledged. The user can still delete it before anyone sees it.
- Failed, with a Retry button: shown only after many tries, or when the server refuses the message, such as for a blocked contact.
- Photo with an upload ring: the picture shows at once, the file uploads first, and the message is sent once the file has a link.
How do you keep chat messages in order without duplicates?
Let the server give each message a sequence number within its chat, and sort by that number, never by the phone's clock. Then use the client id to merge your own pending copy with the server's version.
Phone clocks can be wrong, and users can change them. Two people can also send at the same moment from different cities. If the server numbers one chat's messages one at a time, its counter gives one order that every member agrees on.
Message 104 in a family group is simply the 104th message the server accepted for that group. Your own message sits at the bottom while it is pending. When its ack brings a sequence number, it moves to its true place.
Here is how the chat store merges a message that comes from the server. The rank table makes sure ticks only ever move forward.
type Status = "pending" | "sent" | "delivered" | "read";
export type Msg = { clientId: string; seq?: number; text: string; status: Status };
const rank: Record<Status, number> = { pending: 0, sent: 1, delivered: 2, read: 3 };
// Messages with a server number first, in number order; pending ones after
const bySeq = (a: Msg, b: Msg) =>
a.seq === undefined ? (b.seq === undefined ? 0 : 1)
: b.seq === undefined ? -1 : a.seq - b.seq;
export function applyFromServer(list: Msg[], incoming: Msg): Msg[] {
const old = list.find((m) => m.clientId === incoming.clientId);
if (!old) return [...list, incoming].sort(bySeq); // a new message
const status = rank[incoming.status] > rank[old.status]
? incoming.status : old.status; // ticks never go back
const merged = { ...old, ...incoming, status };
return list.map((m) => (m === old ? merged : m)).sort(bySeq);
}Two lines matter most. The find by clientId makes a resent message replace its pending copy instead of appearing twice. The status line ignores a late, older update, so a read message never drops back to delivered.
A tempting shortcut for the sort treats a missing seq as Infinity and subtracts. Infinity minus Infinity is NaN, which JavaScript's sort quietly treats as equal. It happens to work, but it is fragile, so bySeq spells out the cases.
Save the merge code as merge.ts and run this script next to it. It replays a pending reply, its ack, the same ack again, and a late delivered note after a read note.
import { applyFromServer, type Msg } from "./merge";
let chat: Msg[] = [
{ clientId: "a1", seq: 104, text: "Is the hall booked?", status: "read" },
{ clientId: "me7", text: "Yes, for Sunday", status: "pending" },
];
const show = (step: string) =>
console.log(step.padEnd(22), chat.map((m) => `${m.seq ?? "-"}:${m.status}`).join(" "));
show("start");
chat = applyFromServer(chat, { clientId: "b2", seq: 105, text: "Which hall?", status: "sent" });
show("new message 105");
chat = applyFromServer(chat, { clientId: "me7", seq: 106, text: "Yes, for Sunday", status: "sent" });
show("ack for me7");
chat = applyFromServer(chat, { clientId: "me7", seq: 106, text: "Yes, for Sunday", status: "sent" });
show("same ack again");
chat = applyFromServer(chat, { clientId: "me7", seq: 106, text: "Yes, for Sunday", status: "read" });
chat = applyFromServer(chat, { clientId: "me7", seq: 106, text: "Yes, for Sunday", status: "delivered" });
show("read, then late note");start 104:read -:pending
new message 105 104:read 105:sent -:pending
ack for me7 104:read 105:sent 106:sent
same ack again 104:read 105:sent 106:sent
read, then late note 104:read 105:sent 106:readWhat just happened? The pending reply moved from the end to its place as 106, the repeated ack changed nothing, and the late delivered note could not undo read.
Sorting the whole list on every update is fine for one open chat. In six runs on Node 22 in our test sandbox, each update took 41 to 97 microseconds for 300 messages. That is a server CPU, not a phone, so measure on real devices too.
How does the app catch up after being offline?
The app remembers the highest sequence number it holds for each chat. After reconnecting, it sends those numbers, and the server returns everything newer, page by page.
Gaps can also appear while connected. If message 107 arrives but the last one stored was 105, the store knows 106 is missing. It asks the server for 106 before treating 107 as the latest.
Here is the full catch-up after a reconnect. Look at the order of the last three boxes.
Catching up after a reconnect
Drag to exploreThe phone says what it already has, and the server sends only what is newer. Missed messages are merged before the outbox is flushed.
Diagram connections:
- Signal comes back to Open socket after jitter
- Open socket after jitter to Send last seq for each chat (Connected)
- Send last seq for each chat to Return newer messages
- Return newer messages to Merge, skip duplicates (Pages of 100)
- Merge, skip duplicates to Flush the outbox
- Flush the outbox to Send delivered up to seq
- Signal comes back: Phone → Socket manager
- Open socket after jitter: Socket manager → Chat server
- Send last seq for each chat: Socket manager → Chat server
- Return newer messages: Chat server → Socket manager
- Merge, skip duplicates: Socket manager → Chat store
- Flush the outbox: Outbox → Socket manager
- Send delivered up to seq: Socket manager → Chat server
How do delivered and read receipts work in a chat app?
Receipts are short notes from the receiver's phone back to the sender. Delivered means the message is stored on that phone, and read means it was actually on screen. Each tick is a state that only moves forward.
Back to the courier. The counter receipt was your one tick. The signature at the door is two ticks, because it is proof from the receiving side. Read receipts go one step further and report that the parcel was opened.
The diagram shows the life of one message's ticks.
Ticks on one message
Drag to exploreEach state is reported by a different side: the server for one tick, the receiver's phone for two ticks and blue ticks. Ticks never move backwards.
Diagram connections:
- Waiting (clock) to Sent (one tick) (Server ack)
- Sent (one tick) to Delivered (two ticks) (Delivered note)
- Delivered (two ticks) to Read (blue ticks) (Read note)
- Waiting (clock) to Failed (Retry button) (Gave up)
- Waiting (clock): Saved in the outbox on the sender's phone
- Sent (one tick): Server stored it and acked the sender
- Delivered (two ticks): Receiver's phone stored it and told the server
- Read (blue ticks): Receiver had it on screen in an open chat
- Failed (Retry button): Refused, or too many tries without an ack
When should the app send a read receipt?
Send it only when the message is really on screen, the chat is open, and the page is visible. The old bug came from marking messages read the moment they arrived in a background tab.
- Check that document.visibilityState is visible. MDN says hidden covers a background tab, a minimised window or a locked screen.
- Watch the newest bubbles with an Intersection Observer, a browser feature that reports when an element scrolls into view.
- Send one note per chat, such as read up to sequence 220, instead of one note per message.
- Wait about a second and combine notes, so fast scrolling sends one receipt, not twenty.
Visible is a weak promise. MDN says the page may be at least partially visible, so a window covered by another window still counts. That is why the Intersection Observer check is needed too.
The single read-up-to note keeps receipts cheap. The design treats read up to 220 as covering every earlier message. When 50 members open a busy group, the server gets 50 small notes, not one per message per member.
In groups, each member's note is stored separately. The sender sees a count, such as read by 12 of 18, and can open the full list. If a user turns read receipts off, the phone still sends delivered notes but never read ones.
How do you detect a dead WebSocket in the browser?
Send your own heartbeat and treat silence as death, because the browser will not tell you. We checked this in headless Chromium 141 and 153, meaning the browser ran without a window, driven by a test script. A small proxy stopped forwarding bytes without closing anything, standing in for the lift.
For the 60 seconds we watched, readyState stayed 1 (OPEN) and bufferedAmount stayed 0 bytes. navigator.onLine stayed true, and no error or close event fired. The server received none of the 12 messages sent in that time.
bufferedAmount cannot help either. MDN defines it as bytes queued by send() but not yet transmitted to the network, and small messages leave that queue even when nobody receives them.
Here is the heartbeat watcher. The socket manager starts it in the socket's open handler, because send() throws while a socket is still connecting. It also stops itself when the socket closes, so a server restart cannot fire onDead later and start a second reconnect.
// Calls onDead if no message at all arrives within waitMs of a heartbeat
export function watchHeartbeat(
ws: WebSocket, onDead: () => void, everyMs = 25_000, waitMs = 10_000,
): () => void {
let deadline: ReturnType<typeof setTimeout> | undefined;
const alive = () => { clearTimeout(deadline); deadline = undefined; };
const beat = setInterval(() => {
ws.send(JSON.stringify({ type: "heartbeat" }));
deadline ??= setTimeout(() => { stop(); onDead(); }, waitMs);
}, everyMs);
ws.addEventListener("message", alive); // any message proves the link works
const stop = () => { clearInterval(beat); alive(); ws.removeEventListener("message", alive); };
ws.addEventListener("close", stop); // a socket that closes by itself needs no watcher
return stop; // call this when you close on purpose
}Any message counts as proof of life, so a busy chat needs no extra replies. The server only has to answer a heartbeat when nothing else is flowing. In the same test, onDead fired 34.4 seconds after the cut in Chromium 141, and 34.5 in Chromium 153.
That fits the timings: the next heartbeat went out about 24 seconds after the cut, and its 10-second deadline then passed. Shorter timings find dead links faster but cost more battery and data.
What happens when you close a dead socket?
Pause and predict: the watcher has fired, and you call ws.close() on the dead socket. When does the close event arrive?
In both Chromium 141 and 153 it arrived 60.0 seconds later, with code 1006 and wasClean false. Right after close(), readyState read 2 (CLOSING). The browser sends a closing message and waits for the server's reply, which never comes.
We found no browser documentation for that wait as of 19 October 2026. Chromium's source code sets a 60-second timeout for this closing handshake, and an older version of the same file used 240 seconds.
Firefox and Safari were not tested, so pin browser versions in your tests. The lesson: never wait for the close event before reconnecting.
- Remove your handlers from the old socket, then call close() on it.
- Start the reconnect timer at once, using reconnectDelay.
- Ignore any late events from the old socket, so its 1006 close does not start a second reconnect.
Where should a chat app store messages on the phone?
Keep the open chat in memory for speed, recent chats in IndexedDB for restarts, and the outbox apart from both. Typing and online status are never saved at all.
| Data | Where it lives | How long | Why |
|---|---|---|---|
| Messages of the open chat | In-memory chat store | Until the tab closes | Fast drawing and merging |
| Last 200 messages per chat | IndexedDB | 30 days, or until space runs low | Chats open instantly and work offline |
| Unsent messages | IndexedDB outbox | Until the server acks | Nothing typed is ever lost |
| Last sequence number per chat | IndexedDB | Until logout | Tells the server where to resume |
| Photos and voice notes | Browser cache, via a CDN | Set by response headers | No repeat downloads |
| Typing and online status | Memory only | A few seconds | Old status is worse than none |
A CDN, or content delivery network, is a set of servers close to users that store and send files such as photos. MDN notes that how much space browsers allow, and what they delete when full, differs between browsers.
What happens when the browser deletes stored data?
By default, browser storage is best-effort: it stays while the site is under its quota and the device has space. When the browser evicts a site's data, MDN says it deletes all of it at once, IndexedDB included.
Safari goes further when cross-site tracking prevention is on. If a site gets no click or tap in seven days of browser use, Safari deletes the data its scripts created. That would include the outbox.
A site can ask for persistent storage with navigator.storage.persist(). Firefox shows the user a prompt. Safari and most Chromium browsers decide silently from how the user has used the site.
So treat saved messages as a cache that the server can always refill. The outbox is the one thing the server cannot refill, so keep it tiny and flush it first on every app open.
To check what a site is using, run navigator.storage.estimate() in the browser console. It returns usage and quota in bytes, and MDN says the values are not exact.
How do typing indicators and online status stay cheap?
They are treated as throwaway signals. They skip the outbox, are never retried, and are never saved, because a typing note is useless a few seconds later.
In this design the phone sends a typing note at most once every 3 seconds while keys are pressed. The other phone hides the dots if no new note arrives within 5 seconds. A lost note just means the dots vanish a little early.
How do you make a chat app fast on cheap phones?
Draw only what is on screen, load old messages in pages, and keep the main thread free. The main thread is the single line of work in the browser that also handles taps and drawing.
- Open each chat at the newest message, and load older ones in pages of 50 as the user scrolls up.
- Use a virtualized list for long chats. It draws only the bubbles near the screen and reuses them.
- Keep the socket's message handler tiny. MDN warns the WebSocket API has no backpressure, a way to tell the sender to slow down, so a flood can fill memory or max out the CPU.
- Send photos at phone size, and show a small blurred preview until the full image arrives.
MDN points to WebSocketStream as an alternative with backpressure. As of 19 October 2026, MDN's browser data marks it experimental and lists it only in Chromium browsers, from Chrome 124.
Scrolling up needs care. Adding older messages above the reader pushes the content down, so the list must keep the message being read in place. Otherwise it jumps out of view.
Browsers with CSS scroll anchoring do this for you. In our Chromium 141 and 153 tests, adding 2,000 px of messages above moved scrollTop from 400 to 2400 by itself. As of 19 October 2026, MDN's data lists scroll anchoring in Safari only from version 27.
For older Safari, or when anchoring is turned off, do it by hand. Read scrollHeight before adding, then add the difference to scrollTop afterwards. With anchoring off, that gave the same 2400 in our test.
How do you explain chat app frontend design in a system design interview?
Follow one message through its life: typed, saved, sent, acked, delivered and read. Then show what happens when the network breaks at each step.
Ask about users and networks
Ask about group sizes, phones, network quality and whether chats must work offline. The answers decide how much you store and how hard you retry.
Draw the client parts
Draw the chat screen, chat store, outbox in IndexedDB, socket manager and server. Say the socket manager is the only part that talks to the server.
Explain the connection
One WebSocket, your own heartbeat because an open socket can be dead, and reconnects with exponential backoff and jitter so phones do not retry in one wave.
Explain sending
Save to the outbox first, send with a client id, retry until the ack arrives, and let the server drop repeats.
Explain ordering and catch-up
Server sequence numbers per chat, gap checks, and a sync from the last known number after every reconnect.
Explain receipts
Tick states that move one way, read-up-to notes, visibility checks, batching, and a setting to turn read receipts off.
Close with what you would measure
Time from send to one tick, messages stuck in outboxes, duplicates seen by the server and reconnects per hour, all on real cheap phones.
Questions people ask
- Should a chat app use WebSockets or server-sent events?
- WebSockets are the usual choice for chat, because both sides send often. Server-sent events push only from server to client, so sending needs separate HTTP requests. That can still work for simple chats, because both parts are plain HTTP.
- What do one tick, two ticks and blue ticks mean?
- In the design in this guide, one tick means the server stored the message. Two ticks mean the receiver's phone stored it. Blue ticks mean the receiver had it on screen in an open chat. Real apps vary in the details, so check each app's own help pages.
- Why not order chat messages by the phone's clock?
- Phone clocks can be wrong, and users can change them. Two phones can disagree, so replies would land above questions. A sequence number from the server gives one order that every member of the chat sees.
- What happens if the chat is open in two browser tabs?
- Each tab can open its own socket, which works but doubles the connections. One fix is to let one tab hold the socket, chosen with the Web Locks API, and share messages with the other tabs through BroadcastChannel. Either way, client ids stop the server storing a message twice.
- Is IndexedDB safe for storing private chats?
- IndexedDB follows the same-origin policy, so its data belongs to one website and other websites cannot read it. Treat it as plain storage, not a locked vault, because anyone using the unlocked phone could reach it. Keep only recent messages, and clear the stored data when the user logs out.
- How do you test offline behaviour in a chat app?
- Recreate the lift: put a small local proxy between the browser and the server, and make it stop forwarding bytes without closing anything. Then send messages, close the tab and reopen it. Check that the heartbeat fires, nothing vanishes, nothing doubles and ticks never move backwards. Also test on real cheap phones.
- How many messages should a chat app keep on the phone?
- Keep enough to open recent chats instantly and read them offline, and fetch the rest from the server. This guide's design keeps the last 200 per chat for 30 days. Set limits by count and age, and drop the oldest first when space runs low.
Sources and further reading
- RFC 6455: The WebSocket Protocol (sections 5.5.2 Ping, 7.2.3 Recovering from abnormal closure, 7.4.1 code 1006)
- MDN Web Docs: WebSocket (methods and the backpressure note)
- MDN Web Docs: WebSocket bufferedAmount property
- MDN Web Docs: Navigator onLine property
- AWS Architecture Blog: Exponential Backoff and Jitter (Marc Brooker, 2015)
- MDN Web Docs: IndexedDB API
- MDN Web Docs: IDBTransaction abort() method
- MDN Web Docs: Storage quotas and eviction criteria
- MDN Web Docs: Crypto randomUUID() method
- MDN Web Docs: Background Synchronization API
- MDN Web Docs: Push API
- MDN Web Docs: Document visibilityState property
- MDN Web Docs: WebSocket send() method
- MDN Web Docs: StorageManager estimate() method
- Chromium source: net/websockets/websocket_channel.cc (kClosingHandshakeTimeoutSeconds)