case study · ambitio

real-time messaging
that stays correct.

building chat in flutter that stays consistent across devices and survives flaky networks, for a production app with 2,000+ active users.

role

mobile engineer

where

ambitio

stack

flutter, socket.io, riverpod

live on

play ↗   app store ↗

overview

chat looks trivial until you actually build it. the demo always works: you type, it appears, the other person sees it. it stops working when the same message arrives three times, when the connection dies halfway through a thread, and when the conversation is long enough that you can't keep all of it in memory.

i built the real-time messaging layer for ambitio. underneath it is one socket.io connection, a single riverpod notifier that is the source of truth for every thread, and a pagination scheme keyed on message ids rather than page numbers. most of the work went into keeping that state correct over connections that drop constantly and an os that suspends them on purpose: right order, no duplicates, nothing silently dropped.

the problem

students and counselors message back and forth for months. a single thread is not a screenful, it is thousands of messages going back to the day they first talked. you cannot fetch all of that into memory, and you shouldn't: the only message anyone cares about the instant they open a chat is the most recent one.

so the problem split in two. load a long history without ever loading the whole thing, and keep a live socket feeding new messages in at the top, without those two halves ever fighting over what the conversation actually contains.

constraints

thread lengtha few thousand messages per thread and still growing, so loading the whole thing was never on the table.
live feednew messages keep arriving at the top while the user scrolls into the past.
three pathsone message can show up from the optimistic local copy, the socket echo, and a reconnect refetch, all at once.
ios realitythe os suspends the websocket the moment the app backgrounds or the network changes, all day long.

what i built

reverse pagination as the foundation. chat opens on the newest messages and paginates upward into older history as you scroll, a page at a time. you start where the conversation actually is, at the bottom, and dig backward only as far as someone bothers to scroll. most threads, nobody ever scrolls past the last day, so most threads you pay for almost nothing.

cursor pagination by message id, not page-and-offset. each request for older messages carries one cursor: the id of the oldest message i already hold, sent as messageId, meaning "give me what came before this." catching up after a gap uses afterMessageId, which means the opposite. the page size is the server's call; the client only ever names a real message, never a numeric position. that distinction is the whole game: while you read old history, new messages are still landing at the top, so an offset i computed a second ago points at the wrong row by the time the response comes back. ask for messages 100 to 150, have four arrive while that request is in flight, and everything shifts down by four: you get back four rows you already have and miss four you don't. a cursor pointing at a real message id doesn't move when the top of the list does.

the list itself is index-anchored, not pixel-anchored. it renders through a positioned list (reversed, newest at the bottom), so when a page of older messages prepends to the front, the viewport stays exactly where the reader left it. the next page starts loading once the topmost visible row is within five of the oldest message in memory.

the optimistic
bubble

the instant you hit send, the bubble is already on screen, with zero delay. before any network call goes out, i mint a client-side id, a uuid v4 i carry as clientMessageId, and render a local copy stamped MessageStatus.pending. that id rides along inside the POST body.

when the server confirms, i find the optimistic message by its client id (indexWhere((m) => m.clientMessageId == id)) and swap the real message into that exact slot, re-stamping the server copy with the same clientMessageId so the keys never come apart. if the send throws, the very same lookup flips that one bubble to MessageStatus.failed and offers a retry, while every other message in the thread sits untouched.

media is the same shape with one extra beat. an attachment moves pendinguploadingsent, and when the server copy lands i deliberately keep the on-disk file showing for photos, so the picture doesn't blink out in the half-second before the remote url warms up.

one message,
three arrivals

the moment you have optimistic sends and a live socket, the same message starts arriving from several directions. you render the optimistic local copy on send. the server confirms and the socket echoes the real message back. then, if the connection dropped anywhere in that window, the reconnect refetch pulls a slice of recent history that contains it a third time.

so dedupe runs on two keys, not one. your own messages are matched on clientMessageId; everything else on the server id. when a socket message arrives, i check the client id first (is this my own bubble coming home?), fall back to the server id, and only append if neither already exists. when a refetch overlaps history i already hold, i build a Set of the ids on screen and keep only what isn't in it. the network can deliver the same message as many times as it likes; it renders exactly once. having that in one place is what let the reconnect path stop being careful. it refetches messages it probably already has, which is only a reasonable thing to do because something downstream is guaranteed to catch them.

ios keeps
killing the
socket

here is the part that never shows up in a demo and only bites you in production. on ios, the websocket is not yours to keep. the operating system suspends connections the instant the app goes to the background, and again on every network change, and ios users change networks constantly: wifi to cellular walking out the door, cellular to wifi walking back in, lock the phone, glance at it, switch to another app and back. the socket dies and reconnects, over and over, all day, for every single user.

the socket is configured to never give up, effectively infinite reconnection attempts, backing off from one second to five, with a ten second handshake timeout, and on top of the library's own retries i run a manual one second timer that re-dials if a disconnect gets past them. but a socket that reconnects is only half the job. every gap while you were down is a hole: messages that arrived in those few seconds simply never reached the device. and when the socket comes back and replays its buffer, you get the opposite problem, the same messages arriving twice. i was, in effect, fighting my own socket, and the most reliable thing about it was that it would not stay up.

making reconnects
boring

the fix was to stop treating a reconnect as an event and start treating it as a non-event. the rule i held to was three words: invisible, lossless, idempotent. a reconnect should look like nothing happened, lose nothing that arrived while i was down, and be safe to run a hundred times a day. so every return runs the same four steps:

fresh socketeach connect builds a brand-new socket and disposes the old one, so listeners attach exactly once and can never stack into double delivery.
re-joinevery chat the user had open is tracked in a set and silently re-joined on the new connection.
fetch the gapthe thread re-syncs from the last stable message id, skipping any pending or failed optimistic ones, pulling only what landed while the socket was dead.
dedupe by idthat catch-up overlaps what i already had, so it's reconciled against a set of known ids and produces no doubles.

and this delta-sync isn't only a reconnect thing. it runs every time you open a thread or bring the app back from the background: never refetch a thousand messages when the right answer is "fetch the four that arrived while you were gone." after that, the socket could drop as often as ios wanted. each return quietly reconciled itself against the cursor and moved on. i wouldn't call it elegant. the socket drops exactly as often as it always did, there's just something cheap on the other side of every drop.

correctness is
a lot of small
rollbacks

the same discipline runs through every edit, delete, and reaction. each one snapshots the thread, applies the change to the screen immediately, and rolls that snapshot back if the server says no. an edit is allowed for fifteen minutes after sending and a delete for two hours, both checked before the request ever leaves the device; a reaction is keyed one-per-user, so re-tapping replaces rather than stacks.

the user sees an instant, optimistic result every time, and the only moment they ever learn the network disagreed is the rare, quiet rollback. that is the whole posture of this layer: be fast and confident on screen, be paranoid and reconciling underneath.

what it
taught me

the honest version of this story is that the impressive-sounding parts, the live socket and the optimistic sends, were the easy half. most of the time went on reconciliation instead: the cursor, the two dedupe keys, the resync after a drop. that work is tedious to write and invisible when it works.

there's no screenshot of any of it. the feature is that you got in a lift, came out, and the conversation was still correct. but that is exactly the kind of correctness people feel without ever naming it, and the absence of it is the kind they quietly leave over. the win here was never a feature. it was taking the genuinely unreliable parts and making them feel reliable.

outcome

2,000+

active users

3

delivery paths deduped to one

0

duplicate or lost messages on reconnect

it's live on both stores, mostly running over indian mobile networks. threads open on the last page instead of the whole history, the live feed and the refetch stopped fighting over the list, and the socket drops all day without producing bug reports.

one thing i'd do differently: the dedupe and the resync live in the same notifier as everything else, and by the end that notifier was doing enough that it should have been its own layer with its own tests. it works, but i'd rather it were boring to read.

what's next

the next thing i'd build here is offline-first.

the thread lives in memory today and everything comes from the network, so a cold start always refetches, and with no connection at all you can't compose at all. that's the one case none of the reconnect work above helps with. a local store plus a send queue that drains on reconnect would close it, and most of the groundwork is already here: messages carry stable client ids, and the resync path already knows how to fetch only what it missed.