Agree on scope and numbers first, draw the client's layers and the API they need, then spend most of the time on the one or two problems that make this product hard. Interviewers score how you reason about trade-offs and mobile limits, not how much of the board you fill.
How the time splits
Requirements, 5-8 minutes: the two or three flows in scope and the numbers: how many items, how fresh, offline or not, real time or not; name what you are deliberately not designing
Architecture and API, about 15 minutes: widgets, a state holder, repositories, a local database (drift) beside the API client (dio) and a socket; then the endpoints or events, ids and the pagination style
Deep dives, about 20 minutes: the part that makes this product hard: merging offline edits in a notes app, the send queue and read receipts in a chat, the player pool in a video feed
Wrap-up, about 5 minutes: failure modes, the metrics you would watch, a rollout behind a remote flag
What gets scored
Clarifying questions before the first diagram, and every choice paired with its cost and the option you rejected
Mobile reality unprompted: flaky network, process death, background limits, users on old versions
Driving the session yourself; ten minutes on Bloc versus Riverpod, or on the server's database, spends time on what is not being graded
FlutterSenior
Flutter System Design
The design round: feeds, chat, offline-first sync and conflicts, live updates, media, background uploads, server-driven UI
Questions closed
0 / 17
- 0Review
- 1Asked often
Questions
A phone app runs on a network that drops, in a process the OS may kill at any moment, on a battery, and in versions you can no longer update. Each of those becomes a design rule: cache and queue, persist early, batch, and keep the API backward compatible.
Flaky network: every screen has a cached state and an error state, and writes carry idempotency keys, so a retry after a timeout cannot apply twice
Process death: Android kills a backgrounded app to reclaim memory, iOS suspends it within seconds; drafts and queued work live in a database, not in a Bloc
Battery and radio: each request keeps the cellular radio powered for seconds after it ends, so batch analytics, avoid polling, and let a push wake the app instead
Background limits: Dart code stops when the app is suspended; long uploads and syncs go to WorkManager or a background URLSession through plugins, and the OS may still postpone them
Old versions in the wild: users skip updates for months, so API changes are additive, the client ignores unknown fields, and a minimum-version check ships in the first release
Slow fixes and slow devices: a store fix takes days to reach most users, so risky features sit behind remote flags; low-end phones have 2-3 GB of RAM, so decode images at display size
Stop waiting on all five together: give each section its own request and its own loading, error and data state, and paint from the local cache first. Then ask the backend for one screen-shaped endpoint, so the important part of the screen costs one round trip.
**Future.wait is the blank screen: it completes only after the slowest call, and fails as a whole when any one fails, so a flaky recommendations service hides the user's own data
One state per section: a provider or a cubit each; the header renders when the profile arrives, the rest shows skeletons of the right height, so nothing jumps
Paint from disk: the last response of each section is stored, so a returning user sees yesterday's numbers at once and fresh data replaces them as it arrives
Priorities and timeouts: above-the-fold calls go first, the rest after the first frame; a slow optional section gives up after a few seconds and offers Retry
Connections**: dart:io's client speaks only HTTP/1.1, so five parallel calls can mean five TLS handshakes; native_dio_adapter (Cronet, URLSession) brings HTTP/2 multiplexing
From the API: a backend-for-frontend endpoint that gathers the data server-side, where calls are fast and parallel, and ETags, so a refresh is often a 304
Pick by direction, frequency and app state: polling for data that changes every few minutes, SSE for a one-way stream, a WebSocket when both sides talk often, push to reach a closed app. Any of them can drop events, so catch up after every reconnect.
Short polling: a GET every 30-60 s while the screen is visible; trivial, cacheable, passes every proxy, wasteful when nothing changed; stop the timer when the app is paused
Long polling: the server holds the request until there is news or a timeout: near real time over plain HTTP (the Telegram Bot API's getUpdates), at one open request per client
Server-sent events: one long HTTP response the server keeps writing into; one-way, resumable with Last-Event-ID; good for order status or LLM tokens. Mobile Flutter has no built-in EventSource, so parse the stream or use a package
WebSocket: full duplex and cheap per message, for chat, collaborative editing and games; you own the heartbeat, reconnects with backoff, auth and catch-up
Push (FCM, APNs): the only channel that reaches a backgrounded or killed app, and not a data pipe: no delivery guarantee, about 4 KB of payload, silent pushes throttled. Send "something changed" and let the app fetch
The usual mix: a socket or SSE while the screen is open, push for the rest, and a "what did I miss since sequence N" request after every reconnect
Every screen renders from a local database it subscribes to, and network responses only ever land in that database. A write updates the row and appends an operation to an outbox in one transaction, the UI marks the row pending, and a sync worker sends the queue when it can.
Read path
One source of truth: drift's watch() (or a repository that emits after every write) streams query results into the state holder, so a sync that writes rows updates every screen without a refetch
Changes since a token: the server returns what changed after an opaque token, tombstones for deletes included; commit the new token together with the rows, or a crash between the two loses changes
No spinner over cached data: show what is stored with its age ("updated 10 min ago") and an offline banner
Write path
Row and op together: one transaction holds the new values plus an outbox row with an idempotency key and the server version the edit started from
Drain in order: oldest first, one op at a time; an op is deleted only after the server answered, and edits still queued for the same row are rebased onto the version it returned
Triggers: right after the write, on app resume, when connectivity_plus reports a network (a hint, not proof), and from a periodic workmanager job, which runs in its own isolate and opens its own database connection
What the UI shows
The edited value immediately, with a small "pending" mark; "synced" only when no op for that row is left
"Failed" with Retry for a permanent error (a 403, a validation error), never a silent rollback of what the user typed
A warning on sign-out while changes are waiting: clearing the outbox throws them away
Conflicts
The server compares the op's base version with its current one and answers 409 with its copy; the client merges or asks, as in the conflict question
Detect the conflict on the server by version, then use the cheapest rule the data allows: last write wins where losing one edit is acceptable, a three-way field merge for records, and a CRDT only for text or lists that people really edit together.
Last write wins
One edit silently disappears: fine for a toggle, a status or a setting, wrong for anything a person typed
"Last" means the server's order of arrival or a hybrid logical clock, never the device clock: a phone forty minutes fast wins every conflict
Three-way field merge
Needs the base version both edits started from: a field changed on one side only takes that side's value, so a title edit on one phone and a pin on the other both survive
Only a field both sides changed differently is a real conflict: apply that field's rule or show both values
Cost: the client keeps the base (the outbox op's base version and old values), and a note body is one field, so two edits of the text still collide
CRDT
Each device keeps operations or per-element metadata that merge in any order to the same result, with no server arbitration; it is the idea behind Automerge and Yjs, and in Dart the crdt package does it for records with hybrid logical clocks
Cost: metadata and tombstones grow with history and need compaction, the Dart ecosystem is thin, bugs are hard to reason about, and the server must understand the format to validate or search it
Worth it for a shared document or checklist, not for a profile form
Decide these too
Delete against edit: does an edit bring a deleted note back, or does the delete win?
When no rule can decide, keep both versions (a "conflicted copy") rather than lose text
Ship the first copy as one prebuilt SQLite file instead of 50,000 JSON rows, keep it current with a feed of changes and tombstones since a server cursor, and search it through an FTS5 index in a bundled SQLite, off the UI isolate.
First download
The server builds a compressed database file (products, prices, search index) per region every night; the app downloads it with background_downloader, so it resumes and continues in the background, checks its hash, and swaps it in atomically
Paging JSON into the database instead means 50,000 decodes and inserts on the phone: batch a few thousand rows per transaction in a background isolate, or the first sync takes minutes and freezes the UI
Ask before pulling a large file over mobile data, and show progress in megabytes
Delta updates
GET /catalog/changes?cursor=... returns upserts and tombstones; apply each page and its new cursor in one transaction
Keep fast-changing data (prices, stock) apart from slow data (names, photos), so the daily delta stays small
A cursor older than the server's change history gets 410 Gone, and the app downloads a fresh file instead of replaying months of changes
Search
An FTS5 table over name, brand and description with prefix queries (мол*) and bm25() ranking, plus an ordinary index on SKU and barcode for exact lookups
Android's system SQLite does not guarantee FTS5, so use drift's NativeDatabase on the SQLite that package sqlite3 3.x bundles, opened with createInBackground, so a query never blocks a frame
Normalise text the same way when indexing and searching: lowercase, ё to е; FTS5 has no Russian stemmer, so prefix matching does that job, and the trigram tokenizer finds substrings at the price of a much bigger index
Debounce the search field and drop results for an outdated query
Images
Not part of the sync: load and cache them on demand, and prefetch only the salesperson's top products
Page by a server cursor into a local table the list watches, build rows lazily with their height reserved from each image's aspect ratio, decode images at display size, and remember which post sat at the top of the screen, so a restart reopens at the same place.
API
GET /feed?cursor=...&limit=20 returns posts with author, counts, image URLs in a few widths, and each image's width and height; nextCursor: null ends the feed
New posts appear as a "12 new posts" pill at the top, never as rows inserted above what the user is reading
Cache and state
Pages go into a drift table keyed by post id, with their position; the list reads that table, so a cold start paints the previous session's feed instantly and refreshes behind it
Posts are normalised by id: a like on the detail screen changes the one row everywhere, optimistically and with a rollback
Keep the last few hundred posts and trim the rest at startup
Scrolling
ListView.builder or a SliverList builds only what is near the viewport; request the next page about five posts before the end, one request at a time
Each row reserves an AspectRatio from the API's dimensions, so a late image never shifts the list
Rows stay cheap: no Opacity or clip layer per row, const subtrees, and a like counter that rebuilds alone through select
Images
cached_network_image loads the URL whose width is closest to the row's, with memCacheWidth set to the row width times the device pixel ratio
Precache the next few posts with the same provider and the same resize the row uses; a different cache key wastes the work
Video posts play only when mostly visible (visibility_detector) and share a small pool of players
Position after a restart
A pixel offset means nothing once content changes: store the top visible post id and the offset inside that row, and once the cached page renders, jump to that item (super_sliver_list and scrollable_positioned_list can jump to an index among rows of different heights)
A vertical PageView.builder over a small pool of players: the current clip plays, the next one or two are initialised and buffered but paused, and everything further away is disposed. A poster image covers each page until its first frame, and HLS renditions absorb a slow network.
Players
Each VideoPlayerController is a native player with a decoder, buffers and a texture, and a phone runs only a handful of hardware decoders at once: three to five live controllers is the budget, not one per page
Keyed by page index: in onPageChanged, play the new current clip, pause the old one, initialise the next (and the one after on a fast network), dispose everything behind the previous clip
The pool owns the controllers and a page widget only renders the one it is handed, so a rebuild never recreates a player
Preloading and the network
Serve HLS with a few renditions, including a low-bitrate one a weak network can start on: AVPlayer and ExoPlayer choose the starting rendition themselves (AVPlayer stopped following playlist order in iOS 13) and switch up or down from there
video_player has no disk cache, so a replayed clip downloads again; cached_video_player_plus adds one, or a native player behind a platform channel gives full control over buffering
On a slow or metered connection preload only the next clip and show the poster with a spinner, never a black frame
Memory and lifecycle
Pause on AppLifecycleState.paused and when another route covers the feed (RouteAware); resume only the current page
Posters are small JPEGs decoded at screen width; the list of clips is plain data and may be long, the players may not
What to measure
Time to first frame, rebuffering per minute, and dropped frames during the swipe
The chat screen watches a local database that one socket service outside the widget tree keeps filling. A sent message is a local row first, under an id the app made up; the ack returns that id and flips the row to sent. Old history is fetched only when local rows run out.
Transport
A single WebSocket, opened by a service that starts at sign-in and stops at sign-out; it authenticates on connect, pings to notice a dead link and reconnects after growing, randomised delays; no widget opens one
iOS suspends a backgrounded app within seconds and the socket dies with it; on Android, holding a socket open from a foreground service costs battery and a notification nobody asked for. In the background, push carries new messages
Events sent while the socket was down never come through it, so each reconnect starts with a "everything after seq N" request; Telegram's getDifference works this way
Message states
A row holds client_id, server_id, seq and status: sending, then sent when the server acks with a seq, then delivered and read, or failed with Retry
A resend after a timeout carries the same client_id, and the server answers with the stored message instead of saving a second one
Read receipts are "read up to seq N" per chat, not one event per message
Offline queue
The message row and its outbox entry are written together; when the socket is back, each chat's queue goes out oldest first, so a reply never overtakes its question
A photo becomes a background_downloader upload task while the bubble shows the local file; the message itself goes out once the server has returned a media id
History
Sort by the seq the server assigned within the chat: phone clocks disagree, so a device timestamp never decides order; unacked messages stay at the bottom
Index (chat_id, seq) and page from the database; fetch an older page from the server only when local rows run out, and keep the scroll anchor still while prepending
drift's NativeDatabase.createInBackground keeps large queries and sync writes off the UI isolate
The server decides who gets a push and shapes the payload per platform; the app draws chat notifications itself where it can, skips the chat that is on screen, keeps one notification per chat, and cancels it when another device reports the chat as read.
What the server sends
The server knows mutes, quiet hours and unread counts across devices, so a muted chat gets no push at all and the badge number comes from the server
Android: a high-priority data-only FCM message; the background handler builds the notification with flutter_local_notifications in MessagingStyle (sender, avatar, several lines), which a system-drawn notification payload cannot do
iOS: an alert push with thread-id set to the chat id and mutable-content: 1, so a Notification Service Extension (native Swift) can decrypt the text or attach the avatar before it shows; silent pushes are throttled, so messages never depend on them
The chat already open
In the foreground the message arrives on onMessage instead of the tray (unless you ask iOS for foreground banners); the handler checks the current route and, for the open chat, plays a short sound or nothing
Grouping
One notification id per chat, derived from the chat id: a new message updates that notification instead of adding another
Android groups chats under a summary notification with groupKey; iOS stacks them by thread-id
Clearing when read elsewhere
The server sends a "read up to seq N" event to the user's other devices
On Android a data message wakes the handler, which cancels that chat's notification by its id
On iOS that needs a background push, which may arrive late or never, so clearing is best effort: also clear a chat's notifications whenever the app opens it
The courier's app sends a fix every few seconds to the server, which fans it out over a socket while the customer's tracking screen is open, and through push and a lock-screen live notification when it is not. The client animates the marker between fixes and shows how old the last one is.
Getting the updates
Courier side: background location through geolocator with a foreground service on Android and the location background mode on iOS (When In Use is enough for a trip started in the foreground); send a fix only when the courier moved, and buffer while offline
Customer side: subscribe to the order's location over the existing socket or SSE only while the screen is visible, and unsubscribe on pause
Status changes (picked up, arriving) go by push, so a closed app still learns about them; an iOS Live Activity (the live_activities plugin) or an ongoing Android notification shows the ETA on the lock screen, updated by push
Moving the marker
Fixes arrive every 3-5 seconds: animate from the drawn position to the new fix over about that interval, and rotate the marker to the bearing
With google_maps_flutter the marker is native: every animation step is a marker update sent over the platform channel, so step 10-15 times a second, not 60. With flutter_map the marker is an ordinary widget, and an AnimationController (or flutter_map_animations) moves it every frame cheaply
Snap the route to roads on the server, and drop a fix older than the one already drawn: packets arrive out of order
When updates stop
After about 30 seconds without a fix, stop the animation where it is, grey the marker and say "Last updated 1 min ago"; never extrapolate along the route as if the courier were still moving
Keep showing the server's ETA and order status, which come through a separate channel
On reconnect, fetch the latest position before animating again, so the marker moves once instead of replaying a backlog
Keep the backup queue in your own database, one row per photo, and hand only a small window of uploads to the OS background transfer service, which keeps going while the app is suspended. Uploads are resumable and deduplicated by content hash, and Wi-Fi only unless the user says otherwise.
Finding photos
photo_manager lists assets with ids and modification dates; diff them against the queue on launch, on resume and in a periodic background job
On iOS an original may exist only in iCloud, and fetching it is a download of its own: do it inside the job, never while a list scrolls
From iOS 26.1 PhotoKit has a Background Resource Upload extension for exactly this: native Swift code the system runs when network and power allow; it creates upload jobs, the system fetches iCloud originals and uploads them (resuming if the server speaks IETF resumable uploads), and the records reach the app through an app group
The queue
One row per asset: asset id, content hash, state, upload session URL, bytes confirmed, attempts; the database, not the plugin, is the source of truth
The server deduplicates by hash, so a reinstall or a second phone never uploads the same photo twice
Moving the bytes
Export each original to a file in the app's own directory: an iOS background URLSession uploads only from files. background_downloader hands that file to URLSession on iOS and to WorkManager on Android
Keep about ten uploads enqueued with the OS and top the window up as they finish, instead of enqueuing 5,000 at once; delete each exported file after its upload
Large videos use a resumable protocol (tus, S3 multipart), so a dropped connection costs one chunk, not the file
Battery, data and the user
requiresWiFi by default, with a "use mobile data" switch; the workmanager job that tops up the window can also wait for charging (requiresCharging)
A force-quit on iOS cancels background transfers, an Android force-stop holds scheduled work until the next launch, and that launch resumes from the queue
Show "1,240 of 3,000 backed up, waiting for Wi-Fi", and count failures by reason, so a stuck backup shows up in your metrics
Let the OS repeat them: zonedSchedule from flutter_local_notifications with DateTimeComponents.time fires daily with no app running. Store the zone each was scheduled in, reschedule all of them when it changes, and rely on the plugin's boot receiver after an Android reboot.
Time zones
A daily repeat is pinned to the zone it was scheduled in: the plugin stores it on Android and iOS alike, so a user who flies from Moscow to Almaty still gets the 08:00 Moscow reminder, at 10:00 local time
A habit wants wall-clock time wherever the user is, so compare the stored zone with FlutterTimezone.getLocalTimezone() on launch and on every resume, and reschedule when it differs
On Android a native ACTION_TIMEZONE_CHANGED receiver can trigger that without the app being opened; iOS has no such hook, so the fix waits for the next launch
Repeating by wall-clock components also survives daylight saving; adding 24 hours does not
Platform rules
Android clears alarms on reboot: register the plugin's ScheduledNotificationBootReceiver. Android 13+ needs the POST_NOTIFICATIONS permission, and exact alarms need SCHEDULE_EXACT_ALARM, not granted by default from Android 14, so a habit app uses an inexact mode
A user force-stop, and some vendors' battery savers, cancel all alarms until the app is opened again
iOS keeps only the 64 soonest pending notifications per app: a repeating trigger counts once, but seven weekday reminders for each of ten habits do not fit
Weeks without opening the app
Repeats keep firing, but their text is frozen: write copy that stays true, not "Day 12 of your streak"
To let reminders fade for an abandoned app, schedule single ones for the next two weeks instead of a repeat, and extend the window on every launch
When the text must change, server push is the alternative, at the price of a network and a backend
Cut the volume at every stage: subscribe only to the instruments on screen, decode in a long-lived background isolate, keep only the latest tick per instrument, and push into per-row notifiers at most ten times a second, so a tick repaints one row and never the list.
Before the phone
Subscribe to the visible rows plus a margin, and change the subscription as the user scrolls; the server conflates to a few updates a second per instrument
A compact binary format such as protobuf instead of JSON cuts both the bytes and the decode time
Socket to the UI isolate
The socket and the decoding run in an isolate started once with Isolate.spawn; Isolate.run per message would pay the startup cost thousands of times
That isolate sends batches, one message every 50-100 ms rather than one per tick: messages between isolates are copied
Coalescing
The UI side keeps the pending quotes in a map by symbol: a newer tick overwrites one nobody saw, and a timer flushes the map into per-symbol ValueNotifiers
Nobody reads ten price changes a second: 5-10 updates per row is plenty, and the colour flash stays readable
Widgets
ListView.builder with a fixed itemExtent, so layout is arithmetic; each row is a ValueListenableBuilder inside a RepaintBoundary, so a tick rebuilds and repaints that row only
Nothing above the rows listens to prices: no setState on the screen, no provider that the whole list watches
Re-sort by change every few seconds or on request, never per tick: rows would jump and every one would rebuild
A chart is a CustomPainter whose repaint is a listenable, so it skips the build phase entirely
Feature code talks only to your own typed Analytics class, which returns at once and can never crash the caller; it stamps context, checks consent and buffers to disk. An uploader ships the buffer in batches, and each vendor is an adapter you can swap without touching features.
The API features see
Event classes generated from the tracking plan (CheckoutStarted(cartValue: ...)), not track('chekout_start', {...}): a typo fails the build instead of shipping an empty chart
Screen views come from one NavigatorObserver on the router, not from every page
App version, platform, session id and the user's experiment variants are stamped by the class, not by each caller
Pipeline
track() adds the event to memory and returns; a flush writes the buffer to a drift table or a file every few seconds and on AppLifecycleState.paused, because Android may kill the process after that with no further callback
The buffer has a cap: when it fills, impressions go before purchases, and the number of discarded events is reported too
Send gzipped batches of 50-100 events or every 30 seconds in the foreground, with exponential backoff; a UUID per event means a batch resent after a timeout counts once
Send both the event time and the send time, so the server can correct a phone whose clock is wrong
Sampling, consent, privacy
Keep 1 in N noisy events (impressions, performance spans) and write N into each, so dashboards can scale back; funnel steps and purchases always go in full
The uploader stays off until consent, revoking consent deletes the buffer, and a filter strips emails, phone numbers and free text before writing
iOS App Tracking Transparency matters only if you track users across other companies' apps and websites
Vendors and crashes
Firebase Analytics, Amplitude and your own endpoint are adapters of one Dart interface; a routing table says which adapter gets which event
Crashes bypass this buffer: FlutterError.onError and PlatformDispatcher.instance.onError hand them to Crashlytics or Sentry, whose native side writes the report even as the process goes down, with a ring buffer of recent log lines attached
It pays off on screens whose layout changes every week while the building blocks stay the same: a storefront home, campaign pages, onboarding. The backend returns JSON naming components the app already ships, with their data and named actions, and a registry turns each node into a widget.
How to build it
A versioned schema: {"type": "product_carousel", "props": {...}, "action": {...}}; the app sends its version and schema version, and the server uses only components that build supports
A registry, Map<String, Widget Function(Node)>, filled in the composition root; a node type this build has never heard of is dropped and logged
The server says what should happen (open_route, add_to_cart, track), never how: each action name maps to Dart code that shipped in the binary
Persist the last layout that rendered and ship a default one in the assets, so a first launch on a plane is not a blank page
Flutter's own rfw package (Remote Flutter Widgets) renders widget trees described in its format, with data binding and events back to the host; its README recommends it for interfaces assembled from prebuilt widgets and warns against page transitions, drag and drop or custom painters
Where it backfires
The JSON grows conditions, loops and expressions until it is an untyped programming language with no analyzer, tests or debugger
Every screen turns generic: no const subtrees, deep trees of wrappers, slower scrolling, and accessibility labels nobody owns
Checkout, the camera and editors need custom gestures, validation and offline behaviour that a generic component kit renders poorly; keep them as ordinary Flutter screens
Reproducing a bug now needs the app version, the layout JSON that user received and the backend that built it, and QA tests layouts that did not exist at release
Stores accept rearranging what the binary already contains, not shipping new features; changing Dart code itself is what Shorebird does, with its own limits