How We Built a Real-Time Fintech App with Flutter and Go

We built ExtraETF, a real-time ETF and stock investing app, in Flutter with a Go backend for the live market-data layer. It shipped in 2022 for Isarvest GmbH and it is still in the App Store and Google Play. This post is the engineering deep-dive the portfolio page cannot hold: how the streaming layer actually works, how we build financial charts in Flutter, and — because honesty is the whole point — where the stack fought us and what we would change in 2026.
A note up front on numbers. We do not publish our clients' business metrics — user counts, revenue, retention — and we have written about why vague growth claims are worthless. The figures here are about the system — code reuse, buffer sizing, update cadence — not the client's market.
The short version
ExtraETF is a chart-heavy investing app that streams live prices to many clients at once. Two problems dominated the build. The first was real-time market data fan-out: getting one upstream price feed to every client subscribed to a given instrument, at a bounded update rate, without falling over when connections drop. The second was rendering real financial charts in Flutter — axes, gridlines, multiple series, a scrub crosshair — efficiently enough to stay inside the frame budget on a phone.
The architecture is three layers: an upstream market-data source, a Go WebSocket service that manages connections and fans updates out with backpressure, and Flutter clients that consume the stream and render the UI. Go handles the concurrency; Flutter handles one codebase across iOS and Android with full control over rendering. That is the story in three sentences; the rest is how each layer earns its place.
What a real-time investing app actually demands
Strip away the branding and a live investing app is a hard systems problem wearing a finance costume. Four requirements set the bar:
- Live prices, pushed not polled. Quotes change through the trading day. Polling a REST endpoint every few seconds is both too slow for the user and too expensive at scale. You need a push channel.
- Many clients, one source. The upstream feed is read once; the price for a given instrument has to reach every client currently watching it. That is a fan-out problem, and fan-out is where naive implementations melt.
- Dense, interactive charts. A price line, an area fill, multiple series and comparison overlays, a scrub crosshair — all on a 6-inch screen, all inside the frame budget.
- The fintech reliability bar. Users forgive a social app that drops a frame. They do not forgive a money app that shows a stale price, silently loses its connection, or janks while they read a chart. Reconnection, resubscription, and correctness are not polish; they are the product.
Everything below follows from those four.
Why Flutter and Go
Flutter for the client. We needed one team shipping the same feature to iOS and Android on an aggressive timeline, and we needed the option to own the pixels — a real financial chart is custom rendering, not a stock UI widget. Flutter gives you both: a single Dart codebase and a canvas you can draw on directly. Code reuse across iOS and Android came in around 99% — the Dart codebase is shared wholesale, with only a few hundred lines of platform-specific native glue for billing, OAuth, and push. If you want the long-form version of that tradeoff, we wrote Flutter vs native in 2026.
Go for the WebSocket layer. The streaming service is a concurrency problem before it is anything else. Go's model — a couple of lightweight goroutines per connection plus a single fan-out loop — fits that shape almost perfectly: a blocked goroutine costs kilobytes, not an OS thread, and channels make the fan-out logic readable instead of a callback maze. The per-connection cost is a parked goroutine or two and a buffer, so the honest ceiling is file descriptors, not CPU.
The tradeoffs, honestly. Go's error handling is verbose, and a WebSocket service is all edge cases — half-open connections, slow consumers, partial writes — so there is a lot of if err != nil. Flutter in 2022 meant fighting the framework on a few rendering details and accepting that some native SDKs (payments especially) had rougher plugin support than a native app would. Neither was a dealbreaker. Both were real.
The architecture
The system is a straight pipeline with one service doing the hard part in the middle — a conventional Flutter WebSocket architecture. It has three layers, left to right:
- The upstream market-data source — the raw price feed, read once.
- The Go WebSocket service — the fan-out layer in the middle, and the only component that talks to the upstream feed. Every client connects here, never to the source directly.
- The Flutter clients on iOS and Android — each holds a single WebSocket, turns the incoming stream into UI state, and renders it in live price cells and the chart layer.
Prices flow left to right — the source into the service, the service out to the clients — while subscriptions flow the other way: each client tells the service which symbols are on screen, and only updates for those symbols come back down.
Three behaviors make it robust, and they live almost entirely in the service in the middle:
- Subscription routing. Clients do not receive the whole firehose. Each client subscribes to the symbols on screen, the service keeps a per-symbol set of subscribers, and an update is delivered only to the clients that asked for that symbol. On subscribe, the service immediately replays the last known quote so a fresh screen is never blank.
- Reconnection and resubscription. Mobile connections die constantly — tunnels, backgrounding, network switches. The client treats a dropped socket as normal, reconnects after a short delay, and replays its current subscriptions so the server rebuilds that client's routing state.
- Not rebuilding the world on every tick. A price cell updates by rebuilding just that one widget; the chart is not driven off the raw tick stream at all. More on both below.
Hard problem #1 — real-time at scale
At the core is the classic hub: a single goroutine owns the fan-out. Each connection has its own reader and writer goroutine — the writer drains a buffered channel and writes to the socket — and the hub keeps, per instrument, the set of clients subscribed to it. The naive alternative, writing synchronously to every subscriber inside the broadcast, deadlocks the moment one client's socket is slow, because every other client waits behind it. That is the first thing that breaks.
The fix is the standard-but-easy-to-get-wrong one: give every client a buffered send channel and make the broadcast a non-blocking send. If a client's buffer is full, it cannot keep up — and for live prices the right move is not to block the fan-out.
// Broadcast an update to everyone subscribed to a symbol.
// A slow client never blocks the others: if its buffer is full,
// we drop the whole connection rather than stall the fan-out.
func (h *Hub) broadcast(symbol string, update Update) {
for _, c := range h.subscribers[symbol] {
select {
case c.send <- update: // buffered channel, non-blocking
default:
// Buffer full → this client cannot keep up. Drop it;
// it will reconnect and resubscribe with a clean buffer.
h.drop(c)
}
}
}
Two decisions turned this from "works in a demo" into "works at the market open":
- Backpressure is a connection state, not a per-message one. The send buffer is deliberately deep — a stalled client can fall tens of thousands of messages behind before anything gives — so a brief hiccup is absorbed, not punished. But a client that keeps overflowing is disconnected, not nursed along on stale data. It reconnects clean. The buffer trades memory for tolerance; that is a knob, and we turned it up.
- Coalescing happens upstream, before the socket layer. This is the honest part most "real-time" posts skip. Updates are conflated before the fan-out — roughly one update per instrument per second, latest-value-wins — so a busy symbol never floods anyone. That means this is not sub-100ms tick-by-tick streaming; it is a steady ~1-second cadence. Which is exactly what a human watching a price needs, and what keeps client CPU and bandwidth sane.
The payoff of the goroutine model shows up here: the cost of an idle subscriber is a parked goroutine and a buffer, so a single node holds far more idle connections than a thread-per-connection server ever could. The real constraint has always been socket and file-descriptor limits, not the Go runtime — so you provision the descriptor ceiling for that. In production a single node carries thousands of concurrent connections with an order of magnitude of headroom to spare.
Hard problem #2 — rendering financial charts in Flutter
ExtraETF is chart-heavy, and rendering a real financial chart in Flutter is its own discipline. Here is how we build them.
For anything past a lightweight sparkline, we build the chart as a custom RenderObject, not a CustomPainter. A RenderBox with a custom Element lets you treat the axis labels, the legend, and the per-series markers as real child render objects with their own layout — far cleaner than hand-placing everything onto one flat canvas, and it gives you precise control over what happens at layout time versus paint time. That split is the whole performance story.
The techniques that keep it cheap:
- Do the geometry once, at layout. The
PathandPaintobjects for each series are built inlayout()and cached;paint()just draws the cached paths. The expensive work — walking the series, projecting points — happens when the data or the box changes, not on every frame. - Memoize the scale. The mapping from data space to pixels (and the chart rectangle) is cached and recomputed only when its inputs change, behind equality-guarded setters.
- Gate repaints at the source. Instead of a
shouldRepaintheuristic, the render object's property setters compare first — alistEqualson the series list — and only callmarkNeedsLayout/markNeedsPaintwhen something visible actually changed.
set series(List<Series> value) {
if (listEquals(value, _series)) return; // nothing visible changed
_series = value;
markNeedsLayout(); // paths, scale and labels rebuilt in layout(), then one repaint
}
- Keep heavy data prep off the UI thread. Filtering a long benchmark series down to the selected range runs in a
compute()isolate, so the main thread never stalls preparing what the chart will draw.
The one gesture that matters on a financial chart is the scrub crosshair. We attach it with a HorizontalDragGestureRecognizer — chosen deliberately so the chart wins the gesture arena against an ancestor PageView/TabBar swipe while vertical scrolling still works. A scrub moves the marker and rebuilds only the small legend/indicator overlay, with a haptic tick; it never calls setState on the tree.
Where it fought us: the scrub path is the interesting cost. Moving the crosshair invalidates both paint and layout, so an active scrub re-runs layout() and rebuilds those cached paths — the cache that shields ordinary frames does not shield a scrub. That is the kind of thing you only find with the frame budget open in front of you, and it is where the next round of optimization goes.
One deliberate non-feature: there is no pinch-zoom or pan-to-scroll a window. Changing the time range refetches data and produces a new series; it is not an in-chart transform. That keeps the chart stateless with respect to the viewport and leaves the data layer as the single source of truth — a real tradeoff, made on purpose.
None of this is about chasing a benchmark. The frame budget is 16.6ms, and a full financial chart — axes, gridlines, several series, a legend, markers — is a lot to fit into it. That is exactly why the geometry work lives in layout() and paint() stays cheap.
Separately, the live price numbers on screen do update on every tick — by rebuilding just that one widget, a value-listenable wrapped around the price cell, so a moving price never rebuilds the chart or the page.
One fintech-specific rule to close, because it bites everyone eventually: never do the money math in floating point. Prices, percentage changes, and portfolio sums belong in integer minor units or a decimal type, not a double. We wrote the full explanation of why 0.1 + 0.2 ≠ 0.3 matters for money — in a finance app it is not academic.
The platform integration layer
The streaming service and the charts are the interesting engineering. The platform layer is where the schedule actually goes, because every item here is a native SDK with its own edge cases that a cross-platform framework cannot abstract away:
- Sign-in with Apple and Google OAuth. The happy path is quick; the edge cases are not. Apple hands you the user's name and email only on the very first authorization — miss it and it is gone — so you capture and persist it then, or never. Linking the two providers to the same human is its own small design problem.
- Push notifications for price alerts. Delivering a "your alert triggered" push means token lifecycle management, permission prompts timed so users actually say yes, and a payload design that stays useful when the app is backgrounded or killed.
- In-app purchases and subscription entitlements. This is the one that costs the most time in any fintech build. Tiers, free trials, purchase restoration across a user's devices, and the reconciliation between the store's receipt and your own entitlement state. Store rules differ between Apple and Google, and neither is forgiving. Flutter does not make this harder than native — but it does not make it meaningfully easier either.
- Deep linking into specific equities. A link should open the app directly on the right instrument, including when the app was not installed at tap time. Deferred deep linking is a genuinely tricky corner that got harder after Firebase Dynamic Links shut down; we wrote up how we handle it now with App Clips and the Play Install Referrer.
The app ships across nearly 70 screens — onboarding, live quotes, news, watchlists, multi-timeframe charting, and subscription management — all from the one Flutter codebase.
What we would do differently in 2026
A 2022 build reviewed in 2026 is a useful honesty test. What has aged well: the shape. Go for the fan-out layer and Flutter for the client is still exactly what we would choose today — the goroutine model and a single custom-rendering client have only gotten stronger arguments in their favor.
What we would change:
- Move the charts fully native. Building the chart as a custom
RenderObject— rather than leaning on heavier off-the-shelf options — is where we have landed, and it is the direction we are taking our charting. Impeller makes that bet stronger: custom-rendered charts are far more predictable now than on the old Skia path, with less shader-compilation stutter on first paint. A chart-heavy app started today leans on that from day one. - Reach for a proven realtime layer before hand-rolling. Our Go service is not complex, but connection management, reconnection, and fan-out are solved problems. For a new build we would seriously weigh a managed or battle-tested realtime layer and spend the saved time on the domain logic — dropping to a custom Go service only if the economics or latency requirements demanded it.
- Type the client-server contract end to end. The stream messages were hand-serialized. Today we would define the schema once and generate both the Go and Dart types from it, so a field rename cannot silently desync the two ends.
None of that is a knock on the original build. It shipped, it is still live, and the core decisions held up. It is just what four years of ecosystem progress buys you.
FAQ
Yes. Flutter handles a live market-data stream well because the network and decoding work runs off the UI thread and only the widgets bound to a changed value need to update. The constraint is rarely Flutter itself; it is how you deliver updates. Conflate ticks upstream to a bounded cadence so the client is not flooded, and update the smallest possible widget subtree per change so the UI stays within its frame budget.
Building something real-time or fintech?
That is the work we do. If you are streaming live data to mobile clients, rendering something heavier than a list, or wiring up subscriptions and OAuth without the sharp edges, we have shipped it. See the ExtraETF project page for screenshots and details, browse our open-source Flutter packages, or read more about our Flutter app development service.
Have a project in mind? Get in touch — tell us the hard part, and we will tell you honestly whether Flutter and Go are the right call for it.

Dima
Lead Flutter Developer
Dima has been building with Flutter since 2021, and specializes in state management and app architecture. He has hands-on experience with real-time communication protocols — WebRTC for audio and video, and XMPP for messaging — which makes him comfortable with the network-heavy, stateful features many teams struggle to get right.
More from Dima