We bouwden ExtraETF, een realtime app om in ETF's en aandelen te beleggen, in Flutter met een Go-backend voor de laag met live marktdata. Hij kwam in 2022 uit voor Isarvest GmbH en staat nog steeds in de App Store en Google Play. Dit stuk is de technische verdieping die de portfoliopagina niet kan dragen: hoe de streaminglaag werkelijk werkt, hoe we financiële grafieken in Flutter bouwen, en — want eerlijkheid is het hele punt — waar de stack ons tegenwerkte en wat we in 2026 zouden veranderen.
Vooraf een noot over cijfers. We publiceren de bedrijfscijfers van onze klanten niet — gebruikersaantallen, omzet, retentie — en we schreven al waarom vage groeiclaims waardeloos zijn. De getallen hier gaan over het systeem — hergebruik van code, buffergroottes, updatetempo — niet over de markt van de klant.
De korte versie
ExtraETF is een grafiekzware beleggingsapp die live koersen naar veel clients tegelijk streamt. Twee problemen domineerden de bouw. Het eerste was fan-out van realtime marktdata: één inkomende koersstroom bij elke client krijgen die op een bepaald instrument geabonneerd is, op een begrensd updatetempo, zonder onderuit te gaan wanneer verbindingen wegvallen. Het tweede was echte financiële grafieken renderen in Flutter — assen, rasterlijnen, meerdere reeksen, een sleepbaar draadkruis — efficiënt genoeg om op een telefoon binnen het framebudget te blijven.
De architectuur bestaat uit drie lagen: een bron van marktdata, een WebSocket-service in Go die verbindingen beheert en updates met tegendruk uitwaaiert, en Flutter-clients die de stroom verbruiken en de interface renderen. Go verzorgt de gelijktijdigheid; Flutter verzorgt één codebase over iOS en Android met volledige grip op het renderen. Dat is het verhaal in drie zinnen; de rest is hoe elke laag haar plek verdient.
Wat een realtime beleggingsapp werkelijk vraagt
Haal de branding weg en een live beleggingsapp is een lastig systeemprobleem in een financieel kostuum. Vier eisen leggen de lat:
- Live koersen, geduwd en niet opgevraagd. Koersen veranderen de hele handelsdag door. Elke paar seconden een REST-endpoint bevragen is zowel te traag voor de gebruiker als te duur op schaal. Je hebt een duwkanaal nodig.
- Veel clients, één bron. De inkomende stroom wordt één keer gelezen; de koers van een bepaald instrument moet elke client bereiken die er op dat moment naar kijkt. Dat is een fan-outprobleem, en fan-out is waar naïeve implementaties smelten.
- Dichte, interactieve grafieken. Een koerslijn, een vlakvulling, meerdere reeksen en vergelijkingsoverlays, een sleepbaar draadkruis — allemaal op een scherm van zes inch, allemaal binnen het framebudget.
- De betrouwbaarheidslat van fintech. Gebruikers vergeven een sociale app die een frame laat vallen. Ze vergeven een geldapp niet die een verouderde koers toont, stilletjes zijn verbinding verliest, of hapert terwijl ze een grafiek lezen. Opnieuw verbinden, opnieuw abonneren en correctheid zijn geen afwerking; ze zijn het product.
Alles hieronder volgt uit die vier.
Waarom Flutter en Go
Flutter voor de client. We hadden één team nodig dat dezelfde functie op een strakke planning naar iOS en Android bracht, en we hadden de optie nodig om de pixels in eigen hand te hebben — een echte financiële grafiek is eigen renderwerk, geen standaard UI-widget. Flutter geeft je allebei: één Dart-codebase en een canvas waarop je rechtstreeks kunt tekenen. Het hergebruik van code over iOS en Android kwam uit rond 99% — de Dart-codebase wordt volledig gedeeld, met slechts een paar honderd regels platformspecifieke native lijm voor facturatie, OAuth en pushmeldingen. Wil je de lange versie van die afweging, dan schreven we Flutter tegenover native in 2026.
Go voor de WebSocket-laag. De streamingservice is een gelijktijdigheidsprobleem voordat het iets anders is. Het model van Go — een paar lichte goroutines per verbinding plus één fan-outlus — past bijna perfect bij die vorm: een geblokkeerde goroutine kost kilobytes, geen OS-thread, en kanalen maken de fan-outlogica leesbaar in plaats van een callbackdoolhof. De kosten per verbinding zijn een of twee geparkeerde goroutines en een buffer, dus het eerlijke plafond zijn bestandsdescriptors, geen CPU.
De afwegingen, eerlijk. De foutafhandeling van Go is omslachtig, en een WebSocket-service is louter randgevallen — halfopen verbindingen, trage afnemers, gedeeltelijke schrijfacties — dus er staat veel if err != nil. Flutter betekende in 2022 vechten met het framework op een paar renderdetails en accepteren dat sommige native SDK's (vooral betalingen) ruwere plug-inondersteuning hadden dan een native app zou hebben. Geen van beide was een breekpunt. Beide waren echt.
De architectuur
Het systeem is een rechte pijplijn met één service die in het midden het lastige deel doet — een conventionele Flutter-WebSocket-architectuur. Ze heeft drie lagen, van links naar rechts:
- De bron van marktdata — de ruwe koersstroom, één keer gelezen.
- De Go-WebSocket-service — de fan-outlaag in het midden, en de enige component die met de bron praat. Elke client verbindt hier, nooit rechtstreeks met de bron.
- De Flutter-clients op iOS en Android — elk houdt één WebSocket vast, zet de binnenkomende stroom om in interfacestaat, en rendert die in live koerscellen en de grafieklaag.
Koersen stromen van links naar rechts — de bron in de service, de service uit naar de clients — terwijl abonnementen de andere kant op gaan: elke client vertelt de service welke symbolen op het scherm staan, en alleen updates voor die symbolen komen terug.
Drie gedragingen maken het robuust, en ze leven vrijwel volledig in de service in het midden:
- Abonnementen routeren. Clients ontvangen niet de hele brandslang. Elke client abonneert zich op de symbolen op het scherm, de service houdt per symbool een verzameling abonnees bij, en een update wordt alleen afgeleverd bij de clients die om dat symbool vroegen. Bij het abonneren stuurt de service meteen de laatst bekende koers na, zodat een vers scherm nooit leeg is.
- Opnieuw verbinden en opnieuw abonneren. Mobiele verbindingen sterven voortdurend — tunnels, achtergrond, netwerkwissels. De client behandelt een weggevallen socket als normaal, verbindt na een korte vertraging opnieuw, en speelt zijn huidige abonnementen opnieuw af zodat de server de routeringsstaat van die client herbouwt.
- Niet bij elke tik de wereld herbouwen. Een koerscel werkt bij door precies die ene widget opnieuw op te bouwen; de grafiek wordt helemaal niet door de ruwe tikstroom aangedreven. Meer over beide hieronder.
Lastig probleem 1 — realtime op schaal
In de kern zit de klassieke hub: één goroutine bezit de fan-out. Elke verbinding heeft een eigen lees- en schrijfgoroutine — de schrijver leegt een gebufferd kanaal en schrijft naar de socket — en de hub houdt per instrument de verzameling clients bij die erop geabonneerd zijn. Het naïeve alternatief, binnen de uitzending synchroon naar elke abonnee schrijven, loopt vast zodra de socket van één client traag is, want alle andere clients wachten erachter. Dat is het eerste wat breekt.
De oplossing is de bekende-maar-makkelijk-fout-te-doen: geef elke client een gebufferd verzendkanaal en maak de uitzending een niet-blokkerende verzending. Is de buffer van een client vol, dan kan die niet bijbenen — en bij live koersen is de juiste zet niet de fan-out blokkeren.
// Stuur een update naar iedereen die op een symbool geabonneerd is.
// Een trage client blokkeert de rest nooit: is zijn buffer vol,
// dan verbreken we de hele verbinding in plaats van de fan-out op te houden.
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 vol → deze client kan het niet bijhouden. Verbreken;
// hij verbindt opnieuw en abonneert zich weer met een schone buffer.
h.drop(c)
}
}
}
Twee besluiten maakten hiervan "werkt bij de opening van de beurs" in plaats van "werkt in een demo":
- Tegendruk is een toestand van de verbinding, niet van een bericht. De verzendbuffer is bewust diep — een vastgelopen client kan tienduizenden berichten achterlopen voordat er iets meegeeft — zodat een korte hapering wordt opgevangen en niet afgestraft. Maar een client die blijft overlopen wordt losgekoppeld en niet met verouderde data aan de praat gehouden. Hij verbindt schoon opnieuw. De buffer ruilt geheugen voor tolerantie; dat is een knop, en die draaiden we omhoog.
- Samenvoegen gebeurt vóór de socketlaag. Dit is het eerlijke deel dat de meeste "realtime"-stukken overslaan. Updates worden vóór de fan-out samengevoegd — ruwweg één update per instrument per seconde, laatste waarde wint — zodat een druk symbool nooit iemand overspoelt. Dat betekent dat dit geen tik-voor-tik streaming onder de 100 ms is; het is een gestage cadans van ongeveer één seconde. Precies wat een mens die naar een koers kijkt nodig heeft, en wat het CPU-gebruik en de bandbreedte van de client redelijk houdt.
Het rendement van het goroutinemodel blijkt hier: de kosten van een stille abonnee zijn een geparkeerde goroutine en een buffer, dus één node houdt veel meer stille verbindingen vast dan een server met een thread per verbinding ooit zou kunnen. De echte beperking was altijd de limiet op sockets en bestandsdescriptors, niet de Go-runtime — dus daar richt je het descriptorplafond op in. In productie draagt één node duizenden gelijktijdige verbindingen met een orde van grootte aan ruimte over.
Lastig probleem 2 — financiële grafieken renderen in Flutter
ExtraETF is grafiekzwaar, en een echte financiële grafiek renderen in Flutter is een vak apart. Zo bouwen we ze.
Voor alles voorbij een lichte sparkline bouwen we de grafiek als een eigen RenderObject, geen CustomPainter. Een RenderBox met een eigen Element laat je de aslabels, de legenda en de markeringen per reeks behandelen als echte kind-renderobjecten met een eigen lay-out — veel schoner dan alles met de hand op één plat canvas plaatsen, en het geeft je precieze grip op wat er tijdens de lay-out gebeurt tegenover tijdens het tekenen. Die scheiding is het hele prestatieverhaal.
De technieken die het goedkoop houden:
- Doe de meetkunde één keer, bij de lay-out. De
Path- enPaint-objecten voor elke reeks worden inlayout()gebouwd en gecachet;paint()tekent alleen de gecachte paden. Het dure werk — de reeks doorlopen, punten projecteren — gebeurt wanneer de data of het kader verandert, niet bij elk frame. - Onthoud de schaal. De afbeelding van dataruimte naar pixels (en het grafiekrechthoek) wordt gecachet en alleen opnieuw berekend wanneer de invoer verandert, achter setters die op gelijkheid controleren.
- Beperk hertekenen bij de bron. In plaats van een heuristiek in
shouldRepaintvergelijken de setters van het renderobject eerst — eenlistEqualsop de lijst met reeksen — en roepen zemarkNeedsLayoutofmarkNeedsPaintalleen aan wanneer er werkelijk iets zichtbaars veranderde.
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
}
- Houd zwaar datavoorbereidingswerk van de UI-thread. Een lange referentiereeks tot het gekozen bereik filteren draait in een
compute()-isolate, zodat de hoofdthread nooit stilstaat bij het voorbereiden van wat de grafiek gaat tekenen.
Het ene gebaar dat op een financiële grafiek telt is het sleepbare draadkruis. We koppelen het met een HorizontalDragGestureRecognizer — bewust gekozen zodat de grafiek de gebarenarena wint van een PageView- of TabBar-veeg erboven terwijl verticaal scrollen blijft werken. Slepen verplaatst de markering en bouwt alleen de kleine legenda- of indicatoroverlay opnieuw op, met een haptische tik; het roept nooit setState op de boom aan.
Waar het ons tegenwerkte: het sleeppad is de interessante kostenpost. Het draadkruis verplaatsen maakt zowel het tekenen als de lay-out ongeldig, dus tijdens actief slepen draait layout() opnieuw en worden die gecachte paden herbouwd — de cache die gewone frames beschermt, beschermt het slepen niet. Dat is het soort ding dat je alleen vindt met het framebudget voor je open, en daar gaat de volgende ronde optimalisatie heen.
Eén bewuste niet-functie: er is geen knijpzoom of slepen om een venster te verschuiven. Het tijdsbereik wijzigen haalt data opnieuw op en levert een nieuwe reeks op; het is geen transformatie binnen de grafiek. Zo blijft de grafiek staatloos ten opzichte van het venster en blijft de datalaag de enige bron van waarheid — een echte afweging, met opzet gemaakt.
Niets hiervan gaat over een benchmark najagen. Het framebudget is 16,6 ms, en een volledige financiële grafiek — assen, rasterlijnen, meerdere reeksen, een legenda, markeringen — is veel om daarin te passen. Precies daarom leeft het meetkundewerk in layout() en blijft paint() goedkoop.
Los daarvan werken de live koersgetallen op het scherm wel bij elke tik bij — door precies die ene widget opnieuw op te bouwen, een value-listenable om de koerscel heen, zodat een bewegende koers nooit de grafiek of de pagina opnieuw opbouwt.
Eén fintech-specifieke regel om af te sluiten, want die bijt iedereen uiteindelijk: doe de geldberekening nooit in drijvende komma. Koersen, procentuele veranderingen en portefeuillesommen horen in hele getallen in de kleinste eenheid of in een decimaal type, niet in een double. We schreven de volledige uitleg waarom 0,1 + 0,2 ≠ 0,3 voor geld telt — in een financiële app is dat niet academisch.
De laag met platformintegraties
De streamingservice en de grafieken zijn de interessante techniek. De platformlaag is waar de planning werkelijk heen gaat, want elk item hier is een native SDK met eigen randgevallen die een cross-platform framework niet kan wegabstraheren:
- Inloggen met Apple en Google-:termOAuth. Het gelukkige pad is snel; de randgevallen niet. Apple geeft je de naam en het e-mailadres van de gebruiker alleen bij de allereerste autorisatie — mis je dat, dan is het weg — dus je legt het dan vast en bewaart het, of nooit. De twee aanbieders aan dezelfde mens koppelen is een eigen klein ontwerpprobleem.
- Pushmeldingen voor koersalarmen. Een melding "je alarm ging af" afleveren betekent tokens beheren, toestemmingsverzoeken zo timen dat gebruikers werkelijk ja zeggen, en een ladingsontwerp dat bruikbaar blijft wanneer de app op de achtergrond staat of is afgesloten.
- In-app-aankopen en abonnementsrechten. Dit is degene die in elke fintech-bouw de meeste tijd kost. Niveaus, gratis proefperiodes, aankopen herstellen op de toestellen van een gebruiker, en het afstemmen tussen de bon van de winkel en je eigen rechtenstaat. De winkelregels verschillen tussen Apple en Google, en geen van beide is vergevingsgezind. Flutter maakt dit niet lastiger dan native — maar ook niet noemenswaardig makkelijker.
- Deep linking naar specifieke effecten. Een link hoort de app rechtstreeks op het juiste instrument te openen, ook wanneer de app op het moment van tikken niet geïnstalleerd was. Uitgestelde deep linking is een werkelijk lastige hoek die na het stoppen van Firebase Dynamic Links moeilijker werd; we schreven op hoe we het nu doen met App Clips en de Play Install Referrer.
De app beslaat bijna 70 schermen — onboarding, live koersen, nieuws, volglijsten, grafieken over meerdere tijdvensters, en abonnementsbeheer — allemaal uit die ene Flutter-codebase.
Wat we in 2026 anders zouden doen
Een bouw uit 2022 in 2026 beoordelen is een nuttige eerlijkheidstest. Wat goed is verouderd: de vorm. Go voor de fan-outlaag en Flutter voor de client is nog precies wat we vandaag zouden kiezen — het goroutinemodel en één client met eigen renderwerk hebben er alleen maar sterkere argumenten bij gekregen.
Wat we zouden veranderen:
- De grafieken volledig native maken. De grafiek als eigen
RenderObjectbouwen — in plaats van op zwaardere kant-en-klare opties leunen — is waar we zijn uitgekomen, en het is de richting die we met ons grafiekwerk inslaan. Impeller maakt die weddenschap sterker: grafieken met eigen renderwerk zijn nu veel voorspelbaarder dan op het oude Skia-pad, met minder shader-hapering bij het eerste tekenen. Een grafiekzware app die vandaag begint leunt daar vanaf dag één op. - Eerder naar een beproefde realtimelaag grijpen dan zelf bouwen. Onze Go-service is niet complex, maar verbindingsbeheer, opnieuw verbinden en fan-out zijn opgeloste problemen. Voor een nieuwe bouw zouden we een beheerde of beproefde realtimelaag serieus afwegen en de gewonnen tijd aan de domeinlogica besteden — en pas naar een eigen Go-service afdalen als de economie of de latency-eisen dat vroegen.
- Het contract tussen client en server van begin tot eind typeren. De stroomberichten werden met de hand geserialiseerd. Vandaag zouden we het schema één keer vastleggen en er zowel de Go- als de Dart-types uit genereren, zodat een hernoemd veld de twee kanten niet stilletjes uit de pas kan brengen.
Niets daarvan is een aanval op de oorspronkelijke bouw. Hij kwam uit, hij staat nog steeds live, en de kernbesluiten hielden stand. Het is simpelweg wat vier jaar vooruitgang in het ecosysteem je oplevert.
Veelgestelde vragen
Bouw je iets realtime of iets in fintech?
Dat is het werk dat we doen. Stream je live data naar mobiele clients, render je iets zwaarders dan een lijst, of sluit je abonnementen en OAuth aan zonder de scherpe randen, dan hebben wij het opgeleverd. Bekijk de projectpagina van ExtraETF voor schermafbeeldingen en details, blader door onze opensource Flutter-pakketten, of lees meer over onze dienst Flutter-appontwikkeling en hoe we fintech-appontwikkeling specifiek aanpakken.
Heb je een project in gedachten? Neem contact op — vertel ons het lastige deel, en we zeggen eerlijk of Flutter en Go daar de juiste keuze voor zijn.

