Construimos ExtraETF, una app de inversión en ETF y bolsa en tiempo real, en Flutter con un backend en Go para la capa de datos de mercado en vivo. Se publicó en 2022 para Isarvest GmbH y sigue en la App Store y en Google Play. Este artículo es la inmersión técnica que la página de portfolio no puede contener: cómo funciona de verdad la capa de streaming, cómo construimos gráficos financieros en Flutter y —porque la honestidad es lo importante— dónde nos peleamos con el stack y qué cambiaríamos en 2026.
Una nota previa sobre los números. No publicamos las métricas de negocio de nuestros clientes —usuarios, ingresos, retención— y hemos escrito sobre por qué las promesas vagas de crecimiento no valen nada. Las cifras de aquí van sobre el sistema —reutilización de código, dimensionado de búferes, cadencia de actualización—, no sobre el mercado del cliente.
La versión corta
ExtraETF es una app de inversión cargada de gráficos que transmite precios en vivo a muchos clientes a la vez. Dos problemas dominaron el desarrollo. El primero fue el fan-out de datos de mercado en tiempo real: llevar un único feed de precios aguas arriba a todos los clientes suscritos a un instrumento dado, a una tasa de actualización acotada, sin desmoronarse cuando caen las conexiones. El segundo fue renderizar gráficos financieros de verdad en Flutter —ejes, cuadrículas, varias series, una cruz de seguimiento— con la eficiencia suficiente para caber en el presupuesto de fotograma de un móvil.
La arquitectura tiene tres capas: una fuente de datos de mercado aguas arriba, un servicio WebSocket en Go que gestiona las conexiones y difunde las actualizaciones con contrapresión, y clientes Flutter que consumen el flujo y renderizan la interfaz. Go se encarga de la concurrencia; Flutter se encarga de una base de código para iOS y Android con control total sobre el renderizado. Esa es la historia en tres frases; el resto es cómo se gana su sitio cada capa.
Qué exige realmente una app de inversión en tiempo real
Quítale la marca y una app de inversión en vivo es un problema difícil de sistemas disfrazado de finanzas. Cuatro requisitos marcan el listón:
- Precios en vivo, empujados y no consultados. Las cotizaciones cambian durante toda la sesión. Consultar un endpoint REST cada pocos segundos es a la vez demasiado lento para el usuario y demasiado caro a escala. Necesitas un canal de empuje.
- Muchos clientes, una fuente. El feed aguas arriba se lee una sola vez; el precio de un instrumento dado tiene que llegar a todos los clientes que lo estén mirando. Eso es un problema de fan-out, y el fan-out es donde se funden las implementaciones ingenuas.
- Gráficos densos e interactivos. Una línea de precio, un relleno de área, varias series y superposiciones de comparación, una cruz de seguimiento: todo en una pantalla de 6 pulgadas y todo dentro del presupuesto de fotograma.
- El listón de fiabilidad del fintech. Los usuarios perdonan que una app social pierda un fotograma. No perdonan que una app de dinero muestre un precio obsoleto, pierda la conexión en silencio o dé tirones mientras leen un gráfico. La reconexión, la resuscripción y la corrección no son pulido: son el producto.
Todo lo que sigue se deriva de esos cuatro puntos.
Por qué Flutter y Go
Flutter para el cliente. Necesitábamos un solo equipo entregando la misma funcionalidad a iOS y Android en un calendario agresivo, y necesitábamos la opción de ser dueños de los píxeles: un gráfico financiero de verdad es renderizado a medida, no un widget de interfaz estándar. Flutter te da ambas cosas: una única base de código en Dart y un lienzo sobre el que puedes dibujar directamente. La reutilización de código entre iOS y Android rondó el 99 %: la base de código Dart se comparte al completo, con solo unos cientos de líneas de pegamento nativo específico de plataforma para facturación, OAuth y push. Si quieres la versión larga de ese compromiso, escribimos Flutter frente a nativo en 2026.
Go para la capa WebSocket. El servicio de streaming es un problema de concurrencia antes que ninguna otra cosa. El modelo de Go —un par de goroutines ligeras por conexión más un único bucle de fan-out— encaja con esa forma casi a la perfección: una goroutine bloqueada cuesta kilobytes y no un hilo del sistema, y los canales hacen legible la lógica de fan-out en lugar de un laberinto de callbacks. El coste por conexión es una o dos goroutines aparcadas y un búfer, así que el techo honesto son los descriptores de archivo, no la CPU.
Los compromisos, con honestidad. El manejo de errores de Go es verboso, y un servicio WebSocket es todo casos límite —conexiones medio abiertas, consumidores lentos, escrituras parciales—, así que hay mucho if err != nil. Flutter en 2022 significaba pelearse con el framework en algunos detalles de renderizado y aceptar que algunos SDK nativos (los de pagos sobre todo) tenían un soporte de plugin más áspero del que tendría una app nativa. Ninguno de los dos fue un impedimento. Ambos fueron reales.
La arquitectura
El sistema es un pipeline recto con un servicio haciendo la parte difícil en el medio: una arquitectura WebSocket convencional con Flutter. Tiene tres capas, de izquierda a derecha:
- La fuente de datos de mercado aguas arriba: el feed de precios en bruto, leído una sola vez.
- El servicio WebSocket en Go: la capa de fan-out en el medio, y el único componente que habla con el feed aguas arriba. Todos los clientes se conectan aquí, nunca directamente a la fuente.
- Los clientes Flutter en iOS y Android: cada uno mantiene un único WebSocket, convierte el flujo entrante en estado de interfaz y lo renderiza en las celdas de precio en vivo y en la capa de gráficos.
Los precios fluyen de izquierda a derecha —de la fuente al servicio y del servicio a los clientes— mientras que las suscripciones fluyen en sentido contrario: cada cliente le dice al servicio qué símbolos tiene en pantalla, y solo bajan las actualizaciones de esos símbolos.
Tres comportamientos lo hacen robusto, y viven casi por completo en el servicio del medio:
- Enrutado de suscripciones. Los clientes no reciben la manguera entera. Cada cliente se suscribe a los símbolos que tiene en pantalla, el servicio mantiene por símbolo un conjunto de suscriptores, y una actualización solo se entrega a los clientes que pidieron ese símbolo. Al suscribirse, el servicio reproduce de inmediato la última cotización conocida para que una pantalla recién abierta nunca esté en blanco.
- Reconexión y resuscripción. Las conexiones móviles mueren constantemente: túneles, apps en segundo plano, cambios de red. El cliente trata un socket caído como algo normal, se reconecta tras un breve retraso y reproduce sus suscripciones actuales para que el servidor reconstruya el estado de enrutado de ese cliente.
- No reconstruir el mundo en cada tick. Una celda de precio se actualiza reconstruyendo solo ese widget; el gráfico no se alimenta del flujo de ticks en bruto en absoluto. Más sobre ambos abajo.
Problema difícil n.º 1: tiempo real a escala
En el núcleo está el hub clásico: una única goroutine es dueña del fan-out. Cada conexión tiene su propia goroutine lectora y escritora —la escritora vacía un canal con búfer y escribe al socket— y el hub mantiene, por instrumento, el conjunto de clientes suscritos a él. La alternativa ingenua, escribir de forma síncrona a cada suscriptor dentro de la difusión, se bloquea en el momento en que el socket de un cliente va lento, porque todos los demás esperan detrás de él. Eso es lo primero que se rompe.
La solución es la estándar pero fácil de equivocar: dar a cada cliente un canal de envío con búfer y hacer que la difusión sea un envío no bloqueante. Si el búfer de un cliente está lleno, no puede seguir el ritmo, y para precios en vivo lo correcto no es bloquear el 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)
}
}
}
Dos decisiones convirtieron esto de «funciona en una demo» en «funciona en la apertura del mercado»:
- La contrapresión es un estado de la conexión, no de cada mensaje. El búfer de envío es deliberadamente profundo —un cliente atascado puede quedarse decenas de miles de mensajes por detrás antes de que ceda nada—, así que un hipo breve se absorbe en lugar de castigarse. Pero un cliente que sigue desbordando se desconecta, no se le mantiene con vida a base de datos obsoletos. Se reconecta limpio. El búfer cambia memoria por tolerancia; eso es una perilla, y la subimos.
- La agregación ocurre aguas arriba, antes de la capa de sockets. Esta es la parte honesta que la mayoría de los artículos de «tiempo real» se salta. Las actualizaciones se conflan antes del fan-out —aproximadamente una actualización por instrumento y segundo, gana el último valor—, así que un símbolo movido nunca inunda a nadie. Eso significa que esto no es streaming tick a tick por debajo de 100 ms; es una cadencia estable de en torno a 1 segundo. Que es exactamente lo que necesita una persona mirando un precio, y lo que mantiene sanos la CPU y el ancho de banda del cliente.
La recompensa del modelo de goroutines aparece aquí: el coste de un suscriptor inactivo es una goroutine aparcada y un búfer, así que un solo nodo sostiene muchas más conexiones inactivas de las que sostendría jamás un servidor de un hilo por conexión. La restricción real siempre han sido los límites de sockets y de descriptores de archivo, no el runtime de Go, así que provisionas el techo de descriptores para eso. En producción un solo nodo lleva miles de conexiones concurrentes con un orden de magnitud de margen de sobra.
Problema difícil n.º 2: renderizar gráficos financieros en Flutter
ExtraETF está cargada de gráficos, y renderizar un gráfico financiero de verdad en Flutter es una disciplina en sí misma. Así los construimos.
Para cualquier cosa más allá de un sparkline ligero, construimos el gráfico como un RenderObject a medida, no como un CustomPainter. Un RenderBox con un Element propio te permite tratar las etiquetas de los ejes, la leyenda y los marcadores por serie como objetos de renderizado hijos con su propia maquetación, mucho más limpio que colocar todo a mano sobre un lienzo plano, y te da control preciso sobre qué ocurre en tiempo de maquetación frente a tiempo de pintado. Esa división es toda la historia del rendimiento.
Las técnicas que lo mantienen barato:
- Hacer la geometría una vez, en la maquetación. Los objetos
PathyPaintde cada serie se construyen enlayout()y se cachean;paint()simplemente dibuja los trazados cacheados. El trabajo caro —recorrer la serie, proyectar puntos— ocurre cuando cambian los datos o la caja, no en cada fotograma. - Memoizar la escala. El mapeo del espacio de datos a píxeles (y el rectángulo del gráfico) se cachea y solo se recalcula cuando cambian sus entradas, tras setters protegidos por igualdad.
- Cerrar los repintados en el origen. En vez de una heurística en
shouldRepaint, los setters de propiedades del objeto de renderizado comparan primero —unlistEqualssobre la lista de series— y solo llaman amarkNeedsLayout/markNeedsPaintcuando algo visible ha cambiado de verdad.
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
}
- Mantener la preparación pesada de datos fuera del hilo de interfaz. Filtrar una serie de referencia larga hasta el rango seleccionado se ejecuta en un isolate con
compute(), así que el hilo principal nunca se atasca preparando lo que el gráfico va a dibujar.
El gesto que importa en un gráfico financiero es la cruz de seguimiento. La conectamos con un HorizontalDragGestureRecognizer, elegido deliberadamente para que el gráfico gane la arena de gestos frente al deslizamiento de un PageView/TabBar ancestro sin dejar de permitir el scroll vertical. Un arrastre mueve el marcador y reconstruye solo la pequeña superposición de leyenda e indicadores, con un toque háptico; nunca llama a setState sobre el árbol.
Dónde nos peleamos: la ruta del arrastre es el coste interesante. Mover la cruz invalida tanto el pintado como la maquetación, así que un arrastre activo vuelve a ejecutar layout() y reconstruye esos trazados cacheados: la caché que protege los fotogramas normales no protege un arrastre. Es el tipo de cosa que solo encuentras con el presupuesto de fotograma abierto delante, y es a donde va la siguiente ronda de optimización.
Una no-funcionalidad deliberada: no hay zoom por pellizco ni desplazamiento de la ventana. Cambiar el rango temporal vuelve a pedir datos y produce una serie nueva; no es una transformación dentro del gráfico. Eso mantiene el gráfico sin estado respecto al viewport y deja la capa de datos como única fuente de verdad, un compromiso real, tomado a propósito.
Nada de esto va de perseguir un benchmark. El presupuesto de fotograma son 16,6 ms, y un gráfico financiero completo —ejes, cuadrículas, varias series, leyenda, marcadores— es mucho que meter ahí. Por eso justamente el trabajo de geometría vive en layout() y paint() se mantiene barato.
Aparte, los números de precio en vivo en pantalla sí se actualizan en cada tick, reconstruyendo solo ese widget, un value-listenable envolviendo la celda de precio, para que un precio en movimiento nunca reconstruya el gráfico ni la página.
Una regla específica del fintech para cerrar, porque acaba mordiendo a todo el mundo: nunca hagas la aritmética del dinero en coma flotante. Los precios, los cambios porcentuales y las sumas de cartera van en unidades menores enteras o en un tipo decimal, no en un double. Escribimos la explicación completa de por qué 0,1 + 0,2 ≠ 0,3 importa para el dinero: en una app financiera no es académico.
La capa de integración con la plataforma
El servicio de streaming y los gráficos son la ingeniería interesante. La capa de plataforma es a donde se va de verdad el calendario, porque cada punto de aquí es un SDK nativo con sus propios casos límite que un framework multiplataforma no puede abstraer:
- Iniciar sesión con Apple y OAuth de Google. El camino feliz es rápido; los casos límite no. Apple te entrega el nombre y el correo del usuario solo en la primerísima autorización: si te lo pierdes, se acabó, así que lo capturas y lo persistes entonces o nunca. Vincular ambos proveedores a la misma persona es un pequeño problema de diseño en sí mismo.
- Notificaciones push para alertas de precio. Entregar un «tu alerta se ha disparado» implica gestión del ciclo de vida de los tokens, avisos de permiso temporizados para que los usuarios digan que sí de verdad, y un diseño de carga útil que siga siendo útil cuando la app está en segundo plano o cerrada.
- Compras dentro de la app y derechos de suscripción. Esta es la que más tiempo cuesta en cualquier desarrollo fintech. Niveles, pruebas gratuitas, restauración de compras entre los dispositivos de un usuario, y la conciliación entre el recibo de la tienda y tu propio estado de derechos. Las reglas difieren entre Apple y Google, y ninguna perdona. Flutter no lo hace más difícil que lo nativo, pero tampoco lo hace apreciablemente más fácil.
- Deep linking a valores concretos. Un enlace debería abrir la app directamente en el instrumento correcto, incluso cuando la app no estaba instalada en el momento del toque. El deep linking diferido es un rincón genuinamente complicado que se puso más difícil tras el cierre de Firebase Dynamic Links; escribimos cómo lo resolvemos ahora con App Clips y el Play Install Referrer.
La app llega a casi 70 pantallas —onboarding, cotizaciones en vivo, noticias, listas de seguimiento, gráficos multitemporales y gestión de suscripciones— todo desde una única base de código Flutter.
Qué haríamos distinto en 2026
Un desarrollo de 2022 revisado en 2026 es una buena prueba de honestidad. Lo que ha envejecido bien: la forma. Go para la capa de fan-out y Flutter para el cliente sigue siendo exactamente lo que elegiríamos hoy; el modelo de goroutines y un único cliente con renderizado a medida solo han ganado argumentos a su favor.
Lo que cambiaríamos:
- Llevar los gráficos totalmente a nativo. Construir el gráfico como un
RenderObjecta medida —en vez de apoyarnos en opciones más pesadas ya hechas— es donde hemos acabado, y es la dirección que estamos tomando con nuestros gráficos. Impeller refuerza esa apuesta: los gráficos con renderizado a medida son ahora mucho más predecibles que en el antiguo camino de Skia, con menos tirones de compilación de shaders en el primer pintado. Una app cargada de gráficos empezada hoy se apoya en eso desde el primer día. - Recurrir a una capa de tiempo real probada antes de hacerla a mano. Nuestro servicio en Go no es complejo, pero la gestión de conexiones, la reconexión y el fan-out son problemas resueltos. Para un desarrollo nuevo sopesaríamos en serio una capa de tiempo real gestionada o probada en combate y gastaríamos el tiempo ahorrado en la lógica de dominio, bajando a un servicio Go a medida solo si la economía o los requisitos de latencia lo exigieran.
- Tipar el contrato cliente-servidor de principio a fin. Los mensajes del flujo se serializaban a mano. Hoy definiríamos el esquema una vez y generaríamos desde él tanto los tipos de Go como los de Dart, para que renombrar un campo no pueda desincronizar en silencio los dos extremos.
Nada de eso es un reproche al desarrollo original. Salió adelante, sigue en producción y las decisiones centrales aguantaron. Es simplemente lo que te compran cuatro años de progreso del ecosistema.
Preguntas frecuentes
¿Estás construyendo algo en tiempo real o de fintech?
Ese es el trabajo que hacemos. Si estás transmitiendo datos en vivo a clientes móviles, renderizando algo más pesado que una lista o conectando suscripciones y OAuth sin las aristas, ya lo hemos entregado. Mira la página del proyecto ExtraETF para capturas y detalles, explora nuestros paquetes Flutter de código abierto o lee más sobre nuestro servicio de desarrollo de apps con Flutter y sobre cómo abordamos en concreto el desarrollo de apps fintech.
¿Tienes un proyecto en mente? Ponte en contacto: cuéntanos la parte difícil y te diremos con honestidad si Flutter y Go son la decisión correcta para ella.

