Isolates en Flutter: cuándo mover trabajo pesado fuera del hilo de UI sin romper el estado compartido
Flutter renderiza todo —el árbol de widgets, el layout, el paint— en un único hilo: el isolate principal. Cualquier función que corra en ese isolate y tarde más de lo que dura un frame compite directamente con el trabajo de dibujar la siguiente pantalla. No importa si la función en cuestión es "solo" parsear un JSON grande, decodificar una imagen, comprimir un archivo o recorrer una lista extensa para filtrarla: si corre de forma síncrona en el isolate principal, el usuario la paga como un frame perdido, un gesto que no responde, un scroll que se traba.
La solución conocida es mover ese trabajo a un isolate distinto. Lo que no siempre se explica con el mismo cuidado es que un isolate en Dart no es un hilo con memoria compartida: es un proceso aislado, con su propio heap, que no puede tocar directamente ninguna variable del isolate que lo creó. Toda comunicación pasa por mensajes a través de puertos (SendPort / ReceivePort), y cada mensaje que no sea un tipo primitivo se copia —no se referencia— al cruzar esa frontera. Ese detalle, que parece una curiosidad de bajo nivel, es exactamente lo que determina si mover trabajo a un isolate mejora el rendimiento o lo empeora.
Cuándo vale la pena el isolate
Para cálculos puntuales y acotados, compute() es suficiente: recibe una función top-level o estática y un argumento, genera un isolate efímero, ejecuta la función y devuelve el resultado.
Future<List<Producto>> parsearCatalogo(String json) {
return compute(_parsearCatalogoSync, json);
}
List<Producto> _parsearCatalogoSync(String json) {
final data = jsonDecode(json) as List;
return data.map((e) => Producto.fromJson(e)).toList();
}
Esto funciona bien cuando el trabajo es esporádico: el usuario abre una pantalla, se parsea un catálogo grande una vez, y el isolate termina su ciclo de vida ahí. El costo de levantar un isolate nuevo —no es gratis, implica inicializar un runtime de Dart completo— se amortiza porque solo ocurre una vez por pantalla.
El problema aparece cuando el trabajo pesado no es puntual sino recurrente: un stream de datos en vivo que hay que procesar varias veces por segundo, un filtro que se recalcula en cada tecla que el usuario escribe en un buscador, una transformación que se repite cientos de veces mientras la app está abierta. Ahí, crear un isolate nuevo por cada cálculo es más caro que el propio cálculo, y la respuesta correcta es un isolate de larga vida, creado una sola vez con Isolate.spawn, que se queda escuchando mensajes en un ReceivePort mientras dure la sesión.
Lo que se puede compartir entre isolates y lo que no
Entre isolates solo viajan valores que Dart sabe serializar para el paso de mensajes: tipos primitivos, String, listas y mapas de esos tipos, y algunos tipos especiales como TransferableTypedData, que mueve la propiedad de un buffer de bytes sin copiarlo. No viaja una instancia de BuildContext, ningún objeto que contenga una referencia a un widget, ningún Stream o Future ya en curso, y ninguna closure que capture variables del isolate de origen salvo que esas variables sean a su vez serializables. Esto no es una limitación arbitraria: es consecuencia directa de que no hay memoria compartida, así que cualquier objeto con referencias internas tiene que reconstruirse del otro lado, campo por campo, en vez de simplemente "pasarse".
En la práctica, esto significa que el modelo de dominio de la app —las clases que representan pedidos, usuarios, configuración— tiene que poder reconstruirse a partir de datos planos en el isolate de destino. Si esas clases tienen un constructor fromMap o fromJson ya pensado para persistencia o para una API, ese mismo constructor sirve para pasar instancias entre isolates: lo que cruza la frontera es el mapa, no el objeto.
El cuello de botella que no esperábamos
En un caso donde necesitábamos recalcular un conjunto de métricas derivadas cada vez que llegaba un evento de un sensor —varias veces por segundo, durante sesiones que podían durar minutos— la primera versión del isolate de larga vida recibía, en cada mensaje, una copia completa del estado acumulado de la sesión: todos los eventos recibidos hasta ese momento, no solo el nuevo. La idea detrás de esa decisión era simple de justificar: si el isolate tenía todo el estado, el cálculo de cualquier métrica derivada —promedios, máximos, tendencias— podía hacerse de forma autocontenida del lado del isolate, sin que el isolate principal tuviera que mandar nada adicional.
Esa decisión funcionó mientras la sesión era corta. A medida que la sesión se alargaba, el estado acumulado crecía, y cada mensaje nuevo tenía que copiar ese estado completo —serializarlo del lado del isolate principal, copiarlo a través del puerto, deserializarlo del lado del isolate de cálculo— antes de poder procesar un solo evento nuevo. El trabajo de copiar el historial completo en cada mensaje terminó costando más que el cálculo real que el isolate estaba ahí para hacer, y la latencia entre que llegaba un evento y que la UI mostraba la métrica actualizada crecía de forma visible a medida que avanzaba la sesión, exactamente lo contrario de lo que se buscaba al mover el trabajo fuera del hilo principal.
El rediseño: estado que vive en un solo lugar
La corrección no fue optimizar la serialización, sino dejar de mandar el estado completo. Rediseñamos el contrato entre isolates para que el estado acumulado —la responsabilidad de mantener el historial de eventos y las métricas ya calculadas— viviera enteramente dentro del isolate de cálculo, no en el principal. El isolate principal deja de mandar "todo lo que pasó hasta ahora" y empieza a mandar únicamente el evento nuevo; el isolate de cálculo mantiene su propio estado interno, lo actualiza de forma incremental con cada mensaje, y devuelve solo el delta de las métricas que cambiaron.
class _IsolateEntrada {
final SendPort respuesta;
final EventoSensor evento;
_IsolateEntrada(this.respuesta, this.evento);
}
void _loopCalculo(SendPort puertoInicial) {
final receptor = ReceivePort();
puertoInicial.send(receptor.sendPort);
final historial = <EventoSensor>[];
receptor.listen((mensaje) {
final entrada = mensaje as _IsolateEntrada;
historial.add(entrada.evento);
final metricas = _recalcularIncremental(historial, entrada.evento);
entrada.respuesta.send(metricas);
});
}
Con el estado viviendo de un solo lado de la frontera, cada mensaje pasa a ser proporcional al evento nuevo, no al tamaño acumulado de la sesión. El isolate principal nunca vuelve a tener una copia del historial completo —ni la necesita, porque su única responsabilidad es mostrar las métricas que el isolate de cálculo le devuelve—, y eso elimina por diseño la posibilidad de que las dos copias del estado se desincronicen, un riesgo que existía de forma latente en la primera versión aunque nunca llegó a manifestarse como bug.
Qué revisamos antes de dar por buena una implementación con isolates
Antes de considerar cerrado un diseño que mueve trabajo a isolates, verificamos que el isolate se cree una sola vez para trabajo recurrente, no por cada cálculo; que cada mensaje entre isolates transporte solo el dato nuevo necesario para ese cálculo, nunca una copia del estado acumulado completo; que el estado mutable tenga un único propietario claro —un isolate, no los dos— para que no pueda desincronizarse; y que los tipos que cruzan la frontera sean planos y reconstruibles, con un fromMap o equivalente, en vez de depender de que Dart adivine cómo copiar un objeto con referencias internas. Cuando esas condiciones se cumplen, el isolate deja de ser un lugar donde el estado se duplica en cada mensaje, y vuelve a ser lo que debería ser desde el principio: una forma de sacar trabajo pesado del camino de la UI sin pagar ese ahorro con una copia completa de la aplicación viajando de un lado a otro.