overview
i built the chat in the ambitio app. students and counselors use it to talk for months, so some threads have thousands of messages, and most people are on indian mobile networks that drop all the time.
getting messages to send and show up was quick. most of my time went into keeping the chat correct when the network wasn't: the same message showing up three times, messages missing after the phone was locked for a minute, or old history loading into the wrong spot.
constraints
loading old messages
the chat opens on the newest messages and loads older ones a page at a time as you scroll up. most people never scroll past yesterday, so most threads cost almost nothing to open.
each request for older messages asks for what came before a specific message (messageId), not for "page 3". page numbers break here. if i ask for messages 100 to 150 and four new ones arrive while that request is still out, everything shifts by four: i get back four i already have and miss four i don't. a message id doesn't move when new messages come in, so it always points at the right place.
the list keeps its place by message, not by pixel, so when older messages load in above you, what you're reading stays where it was. the next page starts loading when you're five messages away from the oldest one i have.
sending
when you hit send, the bubble shows up straight away. before anything goes to the server, i give it a local id (a uuid i call clientMessageId), mark it pending, and send that id along with the message.
when the server replies, i find the bubble by that id and swap the real message into the same spot. if the send fails, the same lookup marks just that bubble as failed and shows a retry, and nothing else in the thread moves.
photos go pending → uploading → sent. i keep showing the file from your phone until the uploaded copy has loaded, so the picture doesn't blink out for half a second.
the same message, three times
once you show messages before the server confirms them and also listen on a socket, the same message starts arriving more than once. there's the local copy from when you hit send, the copy the socket sends back, and, if the connection dropped in between, another copy in the refetch after reconnecting.
so there are two checks. my own messages are matched by clientMessageId and everything else by the server's id. a message only gets added if neither is already on screen. for a refetch, i make a Set of the ids i already have and keep only the new ones. since that check lives in one place, the reconnect code can fetch a bit more than it needs without anything showing up twice.
reconnecting on ios
on ios, the socket gets paused whenever the app goes to the background or the network changes. walk out of the house and switch from wifi to data, lock the phone, open another app and come back, and the socket has dropped and reconnected each time. that happens all day, for everyone.
the socket retries forever, backing off from one second to five, with a ten second timeout, and i added a one second timer on top in case a disconnect slips past the library. reconnecting was the easy part. anything sent while it was down never reached the phone, and when it came back it sometimes replayed messages i already had. so every reconnect does the same four things:
the same catch-up runs whenever you open a thread or come back to the app, so it fetches the four messages you missed instead of the last thousand. the socket still drops as often as it did before, but now a drop just means a small catch-up.
edits, deletes and reactions
these work the same way. the change shows on screen right away, and if the server says no, it goes back to how it was. edits are allowed for 15 minutes and deletes for 2 hours, checked on the phone before anything is sent. reactions are one per person, so tapping again swaps yours instead of adding another.
outcome
2,000+
active users
3
delivery paths deduped to one
0
duplicate or lost messages on reconnect
it's live on both stores. threads open on the latest page, the live feed and the refetch don't fight over the list anymore, and the socket drops all day without anyone filing a bug about it.
the socket and the instant sends were the easy part. most of the time went into the cursor, the two id checks and the catch-up after a drop, which is where most of the code is.
one thing i'd do differently: all of this lives in one riverpod notifier, and by the end it was doing too much. the dedupe and the catch-up should have been their own piece with their own tests. it works, but it's harder to read than it should be.
what's next
the next thing i'd build here is offline-first.
right now the thread lives in memory and everything comes from the network, so opening the app cold always refetches, and with no connection you can't write anything. a local database plus a queue of unsent messages that goes out on reconnect would fix that. most of the pieces are already there: messages have stable local ids, and the catch-up already knows how to fetch only what's missing.