BLOG · #Engineering

A reference design: quotes and watchlist MVP in Flutter

A concrete, buildable architecture for a small team — what to build, in what order, what to deliberately not build, and the measured trigger that tells you when each heavy element has finally earned its place.

Also in 中文. The closing part of Trading app architecture.

The preceding articles each argue a mechanism. This one commits to a design: a market-data app with search, watchlists, and charts, built by two or three people, with trading added later.

Committing matters. It’s easy to write “it depends”; the useful artifact is a specific plan, the reasoning that produced it, and an explicit account of what it refuses to build.

The constraints, stated honestly

Before choosing anything, the six magnitudes from the overview, answered for this product:

ConstraintOur magnitude
Freshness1-second quotes, a few hundred visible symbols
Irreversibilitynone in v1 — read-only
Catalog~150k instruments, searchable offline
PlatformsiOS + Android, one small team
Teamsone
Threatpublic market data; user accounts in v2

Five of six are small. That decides most of the architecture, and it decides it in the direction of less.

The design

WebSocket ──▶ decode (Dart) ──▶ FrameCoalescer ──▶ QuoteStore ──▶ leaf widgets
                                                        │
REST ──▶ catalog delta sync ──▶ SQLite (bundled snapshot) ──▶ search

Transport. One WebSocket for live quotes, REST for everything else. JSON at this rate is fine; a binary codec is a later optimization, not a starting decision.

Ingest. Decode on the UI isolate to begin with. At a few hundred messages a second this measures in microseconds. Move it to an isolate the day the profile says so — not before.

Conflation. The FrameCoalescer from the state article sits between the socket and the store. This one is worth building on day one even at low rates: it is thirty lines, and it makes the render cost independent of the feed rate permanently.

State. Per-symbol ValueNotifiers in a QuoteStore; leaves subscribe individually. Page-level state can be whatever you like — the granularity discipline only matters on the hot path.

Catalog. A prebuilt SQLite file shipped as an asset, copied on first run, delta-synced by version, searched via FTS with pinyin and initials columns, per the offline article.

Charts. CustomPainter with a static layer and a live layer in separate repaint boundaries. Use a charting package first if it fits; write your own when you need indicator panes or drawing tools.

Structure. One package with clear folders. Not a monorepo — see below.

What we deliberately do not build

This list is the actual content of the design. Anything can be added; the discipline is in the sequencing.

  • No native code, no FFI. A 1 Hz feed does not justify a boundary that costs you stack traces and two toolchains.
  • No custom binary protocol. JSON over WebSocket until payload size or parse time is measurably a problem.
  • No shared cross-platform core. One Flutter codebase already covers both platforms; a shared domain layer solves a problem we don’t have.
  • No package-per-feature monorepo. One team, one app: package boundaries would be overhead, not enforcement.
  • No add-to-app. Nothing to embed into.
  • No certificate pinning. Public data, and pinning is an operational commitment we cannot yet honor. Standard TLS, done properly.
  • No order state machine. No orders. When they arrive, that article becomes mandatory reading, not optional.

Every line is a decision we can reverse. That is the point of writing them down: someone will propose each one, and the answer should be “not yet, and here’s what would change my mind.”

Build order

Phase 1 — catalog and search. Ship the snapshot, build search, list instruments. No live data yet. This proves the largest, least reversible asset (the database and its sync) before anything depends on it.

Phase 2 — live quotes. WebSocket, coalescer, per-symbol notifiers, watchlist screen. Now the reactive path exists end to end. Reconnect with backoff and resubscribe from the start; retrofitting reconnection is painful because it touches every subscription site.

Phase 3 — charts. History over REST, custom painter, two layers. The first screen where raster-thread performance matters, and the first place you profile on a mid-range device.

Phase 4 — accounts and orders. Auth, then the full order reliability design: idempotency keys persisted before send, an unknown state, reconciliation on reconnect. This phase changes the threat model, so the security article applies from its first commit.

Phases 1-3 have no irreversible actions, which is why they can move fast. Phase 4 is where care stops being optional, and it deserves a slower cadence.

The upgrade path

The design’s real quality is not what it does now but where it can grow without a rewrite. Each heavy element has a defined seam and a measured trigger:

ElementWhere it plugs inAdopt when
Isolate for decodebehind the feed interfaceUI-thread profile shows decode in frame budget
Binary protocolcodec inside the feedpayload size or parse time measurably hurts
Native/FFI ingestreplaces the feed implementationconflation in Dart can’t keep up — likely never at this rate
Feature packagesextract from folderstwo people repeatedly collide, or a layer needs isolated tests
Shared domain coreextract domain/a second client appears that must not diverge
Order machinerynew modulethe first irreversible action ships

The seams are what you design deliberately today: a feed exposed as an interface rather than a concrete socket class, catalog access behind a repository, and business rules kept out of widgets. None of those cost anything now, and each one is what makes a later change an extraction instead of a rewrite.

The one thing to keep

If a reader takes a single idea from this series: write down your six magnitudes before you choose anything, then adopt each heavy element on the day a measurement demands it.

Large architectures are not more advanced. They are the same constraints at full size, plus years of accumulated migration state. Copying one early means paying its coordination costs with none of its scale — which is the most common and most expensive mistake in this whole subject.

References

API and renderer claims checked against Flutter 3.47.0 / Dart 3.13 (August 2026).


Previous: Flutter project structure · Back to the overview.