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. the part nobody shows you is the message that arrives three times, the connection that dies mid-thread, and the conversation so long you physically cannot hold 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. almost all of the work served one quiet goal: stay correct. correct ordering, no duplicates, nothing silently lost, even while the network and the operating system were actively working against me.

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 lengthconversations grow without bound; loading an entire thread is never an option.
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, one window at a time, never the whole thread. 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 is the mirror image, afterMessageId, "give me what came after." 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, and any offset i computed a second ago is already pointing at the wrong row. a cursor pinned to a real message id doesn't drift when the top of the list moves under it.

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 instead of lurching upward. load-more arms quietly when the topmost visible row comes within five of the oldest message i'm holding.

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, after a reconnect, a catch-up refetch hands you a recent slice of history that contains it a third time. three paths, one message.

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. that single rule is what lets every other part of the system be sloppy and redundant on purpose, without the user ever seeing a doubled bubble.

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 ever slips through. 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. the flapping was still happening underneath; it had simply stopped meaning anything.

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. the real work lived in the unglamorous reconciliation: the cursor that doesn't drift, the two-key dedupe that swallows redundancy, the reconnect path that is boring on purpose.

none of it is something you can point at in a screenshot. there is no button labelled "did not lose your message when you walked into the elevator." 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

messaging runs in production for 2,000+ users, on long threads, over flaky mobile networks, on an operating system that keeps cutting the connection. it stays correct anyway: history loads a window at a time keyed on real message ids, the live feed dedupes three arrivals down to one bubble, and a socket that dies all day comes back, fetches only the gap, and reconciles without anyone noticing. that last part, the invisible recovery, is the work i am proudest of precisely because no one will ever see it.