Inicio / Artículos

Dónde se oculta la latencia en React Native: cruces de límites, no código lento

Descubra por qué las APIs rápidas y los componentes memorizados pueden seguir pareciendo lentos en React Native, y cómo el agrupamiento de solicitudes, la limitación del ritmo de ejecución y el diseño de la era JSI reducen los costos entre diferentes entornos de ejecución.

Editor: REACTAPP.TOP19 min de lectura4454 palabras

Fuentes: The Hidden Latency Cost in React Native Bridge Architectures

Una pantalla de React Native puede parecer lenta incluso cuando todos los indicadores que se miden parecen estar bien: la API responde en unos 100 ms, las consultas están indexadas, los componentes están memorizados y las imágenes provienen del caché. Sin embargo, un toque tarda demasiado en registrarse, una hoja inferior funciona con retrasos, los marcadores del mapa no se actualizan a tiempo, una animación que funciona sin problemas en un teléfono de gama alta se interrumpe en un dispositivo Android económico, y una ráfaga de eventos del sensor bloquea la interfaz. En muchos de estos casos, el tiempo perdido se invierte en un lugar que los equipos rara vez monitorean: la transferencia de control entre el entorno de ejecución de JavaScript y el código nativo.

Esta guía explica cómo funcionaba esa transferencia bajo el sistema Bridge heredado, por qué su costo se acumula en lugar de aparecer en una sola llamada, qué cambios realmente introduce la Nueva Arquitectura (JSI, TurboModules, Fabric y el renderizador rediseñado), y qué hábitos de diseño mantienen barato el tráfico entre entornos de ejecución sin importar la arquitectura que se utilice. La Nueva Arquitectura ha sido el valor por defecto desde React Native 0.76, pero sigue siendo útil comprender el Bridge: muchas aplicaciones en producción aún utilizan módulos heredados, capas de interoperabilidad, bibliotecas antiguas y patrones que fueron moldeados por él. Lo más importante es que eliminar el Bridge no hace que la comunicación mal diseñada entre entornos de ejecución sea gratuita.

Dos entornos de ejecución en una sola aplicación

Un modelo mental útil es que una aplicación React Native funciona en al menos dos entornos. Sus componentes, estado, reglas de negocio y funcionalidades de red se encuentran en el lado JavaScript; todo lo que interactúa con el sistema operativo está en el lado nativo.

┌──────────────────────────────┐
│        JavaScript / TS       │
│                              │
│ React Components             │
│ State Management             │
│ Business Logic               │
│ API Handling                 │
└──────────────┬───────────────┘
               │
               │ JS ↔ Native communication
               │
┌──────────────▼───────────────┐
│          Native Side         │
│                              │
│ Android / iOS APIs           │
│ Camera                       │
│ Bluetooth                    │
│ GPS                          │
│ Storage                      │
│ Native Views                 │
│ Sensors                      │
└──────────────────────────────┘

El código de React puede ejecutarse en JavaScript, pero casi todas las funcionalidades importantes necesitan eventualmente la plataforma. Las razones típicas para hacerlo incluyen:

  • renderizar vistas de interfaz de usuario nativas,
  • abrir la cámara,
  • leer coordenadas GPS,
  • acceder al sistema de archivos,
  • activar efectos hápticos,
  • comunicarse con dispositivos Bluetooth,
  • utilizar almacenamiento seguro,
  • llamar a SDKs nativos de terceros,
  • recibir lecturas de sensores,
  • invocar otras API específicas de la plataforma.

Así, los datos se mueven constantemente entre los dos entornos. En la arquitectura clásica, todo ese tráfico pasaba por el React Native Bridge.

Cómo transfería una llamada el puente heredado

El diseño original separó deliberadamente la ejecución en JavaScript de la ejecución nativa. Un único viaje de ida y vuelta se veía más o menos así:

JavaScript
    ↓
Prepare parameters
    ↓
Serialize data
    ↓
Queue bridge message
    ↓
Cross execution boundary
    ↓
Native receives message
    ↓
Native performs operation
    ↓
Prepare result
    ↓
Serialize result
    ↓
Queue response
    ↓
JavaScript receives result

La característica definitoria es que todo el intercambio era asíncrono. Los argumentos se serializaban, se colocaban en una cola, se transportaban al otro lado, se deserializaban y se procesaban en el lado nativo, y cualquier resultado realizaba el mismo viaje de regreso. El propio equipo de React Native describió en su nueva arquitectura la serialización y el puente asíncrono como limitaciones fundamentales del modelo antiguo.

Para llamadas ocasionales, este sobrecargo no supone un problema. Escribir una cadena de texto en el portapapeles es un buen ejemplo:

await Clipboard.setString("Hello");

Sea cual sea el costo de la transición aquí, ningún usuario lo percibirá nunca. Los problemas comienzan cuando el límite se convierte en un canal de alta frecuencia del cual la aplicación depende continuamente.

Una llamada es barata; el patrón es lo que daña

Las discusiones sobre el rendimiento de Bridge suelen dar a entender que cada llamada es excesivamente costosa. Ese enfoque no capta la esencia del problema. Las verdaderas ralentizaciones suelen provenir de la combinación de varios factores que se multiplican entre sí:

frecuencia × tamaño del payload × presión de programación × requisitos de sincronización

Supongamos que una sola transición cuesta casi nada. Ahora aumentemos la cantidad:

1 call
10 calls
100 calls
1,000 calls
10,000 calls

Luego, haga que cada carga de datos sea más grande, envíe actualizaciones continuamente mientras el usuario hace scroll, o exija un viaje de ida y vuelta de Nativo → JavaScript → Nativo antes de que se pueda dibujar el siguiente estado visual. Un costo que era insignificante por separado ahora se encuentra en la ruta crítica de renderizado. Ninguna función individual parece lenta en un perfilador; la latencia está distribuida por todo el sistema, y esa es precisamente la razón por la que permanece oculta.

Trate la latencia de interacción como una cadena de procesos

Es útil anotar cada etapa por la que pasa una interacción:

Total Latency =
    JS Work
  + Serialization
  + Queue Waiting
  + Context Boundary Cost
  + Native Work
  + Return Communication
  + Rendering Delay

Los equipos tienden a ajustar un término por separado, generalmente el trabajo nativo:

Native Work

o el trabajo en JavaScript:

JS Work

No obstante, el usuario solo experimenta la suma total:

TOTAL LATENCY

Considere una operación nativa que se completa en:

2 ms

Por sí solo, ese es un número excelente. Pero el desglose completo podría ser:

JS preparation             2 ms
Serialization              2 ms
Queue waiting              4 ms
Native operation           2 ms
Response communication     3 ms
React update               4 ms
--------------------------------
Total                     17 ms

La “operación de 2 ms” se ha convertido en una interacción de aproximadamente 17 ms. La documentación de React Native describe un presupuesto de frame de unos 16,67 ms a 60 FPS; si se excede, es posible que se descarte un frame. Muchos teléfonos actuales actualizan la pantalla más rápido que a 60 Hz, lo que reduce aún más ese presupuesto.

Los presupuestos de frame dejan poco espacio para gastos adicionales

A 60 Hz, los cálculos son:

1000 ms / 60 ≈ 16.67 ms

Eso da aproximadamente

16.67 ms

para generar cada frame. A 120 Hz, esa cantidad se reduce a la mitad:

1000 ms / 120 ≈ 8.33 ms

Aproximadamente ocho milisegundos es muy poco. Si algunos de esos tiempos se destinan a transferir datos de un lado a otro entre los entornos de ejecución sin motivo alguno, la propia arquitectura está consumiendo el presupuesto destinado a la renderización. Los síntomas son conocidos:

  • animaciones entrecortadas,
  • gestos que responden con retraso,
  • transiciones de navegación lentas,
  • frames que desaparecen,
  • retraso en la retroalimentación táctil,
  • desplazamiento irregular,
  • actualizaciones de la interfaz que llegan un frame después de lo esperado.
  • La documentación sobre rendimiento también separa el FPS del hilo de JavaScript del FPS del hilo principal de la UI. Cuando el hilo de JS está saturado, no puede procesar eventos táctiles ni actualizaciones de la interfaz generadas por JavaScript de manera oportuna, mientras que las interacciones nativas, como desplazarse por una lista nativa, pueden seguir siendo fluidas. Esa separación explica un acertijo típico de depuración: una parte de la pantalla parece fluida mientras que otra parece congelada.

    Caso de uso: transmitir ubicación a un mapa

    Imagínese una aplicación de transporte que recibe un flujo continuo de datos GPS. La conexión básica se ve así:

    Native GPS
        ↓
    Bridge
        ↓
    JavaScript
        ↓
    State Update
        ↓
    React Render
        ↓
    Bridge
        ↓
    Native Map
        ↓
    Marker Position Updated
    

    Ahora ejecute ese bucle para cada corrección. Ni calcular la posición ni mover un marcador son tareas costosas por sí solas, pero el diseño ha creado un viaje de ida y vuelta repetitivo:

    Native → JS → Native
    

    Se ejecuta una y otra vez mientras la pantalla esté abierta. Si se muestran muchos controladores al mismo tiempo, el tráfico se multiplica:

    Driver 1 position
    Driver 2 position
    Driver 3 position
    Driver 4 position
    ...
    Driver 100 position
    

    Emitar cien actualizaciones pequeñas una por una puede ser mucho más costoso que emitir un único lote bien preparado que las describa a todas. La causa raíz es la estructura del flujo de datos, no el renderizado de React.

    Escenario: flujos de sensores

    Los datos del acelerómetro y el giroscopio siguen el mismo patrón. Supongamos que el código nativo emite:

    60 events / second
    

    Cada evento pasa al JavaScript, es procesado y puede desencadenar una nueva actualización que vuelve a la interfaz de usuario nativa. El resultado es un pipeline en tiempo real que atraviesa esa frontera:

    Sensor
      ↓
    Native
      ↓
     JS
      ↓
    Calculation
      ↓
    React
      ↓
    Native UI
    

    A una baja tasa de eventos esto puede funcionar perfectamente bien. A una alta tasa, y especialmente en hardware más débil, los eventos pueden llegar más rápido de lo que la aplicación puede procesarlos, lo cual introduce otro costo oculto.

    Colas que crecen más rápido de lo que se procesan

    Supongamos que los eventos llegan cada

    10 ms
    

    pero procesar cada uno de manera eficaz lleva

    15 ms
    

    Esto ya no es solo una operación lenta; se trata de un acumulo que crece sin límites. Llegan nuevas lecturas mientras las antiguas siguen esperando ser procesadas, por lo que la interfaz siempre muestra información del pasado. Para interfaces en tiempo real, este es el peor resultado posible. Un usuario rara vez necesita cada muestra intermedia; lo que necesitan es el valor más reciente que sea relevante. Los diseños que combinan o descartan eventos obsoletos mantienen la cola corta y la visualización actualizada.

    Las interfaces conversacionales multiplican los cruces

    Esta lección no es específica de React Native; los ingenieros que desarrollan sistemas distribuidos se enfrentan a ella diariamente. Compare una API de módulo que establece un campo por llamada:

    await nativeModule.setName(name);
    await nativeModule.setEmail(email);
    await nativeModule.setAge(age);
    await nativeModule.setCountry(country);
    await nativeModule.setLanguage(language);
    

    con una que acepta toda la actualización de una sola vez:

    await nativeModule.updateProfile({
      name,
      email,
      age,
      country,
      language,
    });
    

    La segunda versión necesita muchas menos transacciones a través de la frontera para obtener el mismo resultado. La regla general es sencilla:

    Haga más trabajo por cada cruce, y cruce con menos frecuencia.

    El mismo principio se aplica a:

    • APIs HTTP,
    • llamadas a microservicios,
    • acceso a bases de datos,
    • hilos de trabajo,
    • comunicación entre procesos,
    • puentes móviles,
    • sistemas distribuidos en general.

    En redes esto se conoce como comunicación parlanchina. El puente heredado simplemente trajo ese problema típico de los sistemas distribuidos dentro de una sola aplicación móvil.

    El tamaño del payload es la segunda dimensión

    La frecuencia con la que se transmite es solo la mitad de la historia; también importa cuánto se transporta. Considere enviar algo similar a través de la frontera del sistema heredado:

    {
      users: [...10000Users]
    }
    

    El remitente debe codificar la estructura, y el receptor debe reconstruirla y procesarla. Los arrays grandes, los objetos profundamente anidados, la metadatos de imágenes, los datos binarios y los árboles de configuración extensos añaden una carga adicional considerable. Las tareas más afectadas por esto son:

    • frames de cámara,
    • buffers de audio,
    • procesamiento de imágenes,
    • entradas para aprendizaje automático,
    • resultados de consultas extensos,
    • animaciones complejas,
    • mapas en tiempo real,
    • flujos de sensores.

    La documentación de la arquitectura React Native utiliza un caso extremo pero ilustrativo: los buffers de fotogramas de cámara en tiempo real, que pueden llegar a ocupar decenas de megabytes por fotograma y volúmenes enormes por segundo. Serializar repetidamente ese tipo de datos a través de un puente asíncrono es simplemente la herramienta inadecuada.

    Por qué las cadenas de procesamiento de cámara hacen evidente el costo

    Un solo fotograma puede contener megabytes de píxeles. Ahora, se deben entregar los fotogramas a

    30 FPS
    

    o incluso

    60 FPS
    

    Si cada uno de ellos debe convertirse a un formato serializable compatible con JavaScript y enviarse a través de una capa asíncrona, el transporte se convierte en el cuello de botella. Se debe evitar esta situación para cada fotograma por separado:

        Native Camera
             ↓
    Huge serialized payload
             ↓
        JavaScript
             ↓
          Process
             ↓
       Serialized result
             ↓
          Native
    

    Por eso, las bibliotecas de alto rendimiento mantienen cada vez más el procesamiento intensivo cerca de la memoria nativa y le ofrecen a JavaScript una interfaz estrecha y eficiente en su lugar. Esa dirección conduce directamente a uno de los cambios más significativos en React Native moderno.

    Qué cambia con JSI

    JSI (JavaScript Interface) permite a JavaScript interactuar con código nativo y en C++ de manera mucho más directa. En lugar de depender completamente del método de serialización y cola del antiguo Bridge:

    JavaScript
         ↓
     Serialize
         ↓
       Bridge
         ↓
    Deserialize
         ↓
      Native
    

    la nueva arquitectura puede exponer funcionalidades nativas a través de interfaces directas:

    JavaScript
         ↓
        JSI
         ↓
    C++ / Native
    

    Según la documentación de React Native, JSI permite que JavaScript mantenga referencias a objetos en C++ y que C++ mantenga referencias a objetos en JavaScript, de modo que los métodos pueden ser invocados sin necesidad del modelo de serialización del Puente. Conceptualmente, llamar a un método nativo comienza a parecerse más a llamar a una función en un objeto anfitrión que a enviar un mensaje a otro proceso. Eso representa un cambio fundamental en la arquitectura.

    Por qué JSI no resuelve los problemas de rendimiento

    Una interpretación errónea común es la siguiente:

    "JSI elimina el Puente, por lo que los problemas de rendimiento desaparecen."

    No es así. El transporte es mucho mejor, pero un diseño deficiente del sistema sigue generando latencia. Cien mil llamadas síncronas a través de JSI:

    100,000 synchronous native calls
    

    Cada uno de ellos puede ser más económico que una llamada a Bridge, pero la mayor parte de ese trabajo sigue siendo innecesario. Lo mismo ocurre al pasar objetos enormes una y otra vez, ejecutar lógica que consume mucho procesador en el hilo de JavaScript, bloquear el hilo principal de la interfaz de usuario o renderizar miles de componentes que no necesitaban ser renderizados. El acceso síncrono incluso introduce un nuevo riesgo: una llamada nativa síncrona lenta ahora bloquea directamente el hilo de JavaScript en lugar de resolverse más tarde.

    Por lo tanto, la conclusión no es “Bridge era lento, JSI es rápido”. Está más cerca de esto:

    Cada cruce entre entornos de ejecución tiene un costo. Estructura la aplicación de modo que los cruces innecesarios nunca ocurran.

    TurboModules: el sistema moderno de módulos nativos

    TurboModules reemplazan al sistema heredado de Módulos Nativos. Los módulos antiguos estaban accesibles a través del Bridge; los nuevos se integran de manera más estrecha mediante JSI y Codegen, que genera enlaces tipados a partir de una especificación. Entre las ventajas que enumera React Native se encuentran:

    • acceso nativo síncrono cuando un caso de uso lo requiere,
    • seguridad de tipos a través de las fronteras,
    • interoperabilidad con C++,
    • compartir código entre plataformas,
    • carga perezosa de módulos.

    La carga perezosa merece atención. En el modelo anterior, inicializar los módulos nativos durante el arranque podía generar trabajo adicional antes de que apareciera la primera pantalla. Con la carga perezosa, un módulo se inicializa solo cuando se utiliza por primera vez. En lugar de:

    Application starts
            ↓
    Initialize everything
            ↓
    Eventually show UI
    

    el arranque puede verse más así:

        Application starts
                  ↓
    Initialize required components
                  ↓
               Show UI
                  ↓
    Load additional modules when needed
    

    Los cambios como este afectan algo que los usuarios notan mucho más que las cifras de rendimiento: la rapidez con la que la aplicación parece lista para usarla.

    Fabric: el aspecto de renderizado del rediseño

    La capa de comunicación no fue la única parte que se reconstruyó. Fabric, el nuevo sistema de renderizado, traslada una mayor parte de la infraestructura de renderizado a C++ compartido y permite funcionalidades que resultaban complicadas con el antiguo modelo asíncrono, entre ellas:

    • acceso síncrono al diseño donde es necesario,
    • mejor interoperabilidad,
    • múltiples prioridades de actualización,
    • soporte para las características modernas de React,
    • programación más flexible.

    Las prioridades son importantes porque las interfaces no tratan cada actualización de la misma manera:

    User presses button        → HIGH priority
    Background data refresh    → LOWER priority
    Analytics event            → LOW priority
    Animation                  → HIGH priority
    Prefetching                → LOW priority
    

    Un renderizador competente no debería tratar la presión de un botón y una solicitud de análisis como algo igualmente urgente. La priorización es la base de una interfaz de usuario receptiva, y Fabric es lo que permite a React Native integrarse en el modelo de programación concurrente de React. Si deseas conocer cómo React mismo codifica la prioridad de las actualizaciones, el artículo sobre cómo las vías de React utilizan máscaras de bits para codificar la prioridad explica los mecanismos al respecto.

    El verdadero enemigo es el cambio constante de límites

    Si todo el problema tuviera que resumirse en dos palabras, serían cambio constante de límites: el paso repetido de pequeños fragmentos de información de un entorno de ejecución a otro.

      JS
      ↓
    Native
      ↓
      JS
      ↓
    Native
      ↓
      JS
      ↓
    Native
    

    Cuando se ve esta estructura, generalmente significa que la responsabilidad de una función ha sido dividida en el lugar incorrecto. La pregunta a hacerse es dónde debería residir realmente ese trabajo. Tomemos como ejemplo el procesamiento de audio. Un diseño con alto índice de inestabilidad se ve así:

    Native microphone
    → JS audio frame
    → calculate
    → native
    → next frame
    → JS
    → calculate
    → native
    

    Una división mejor mantiene el procesamiento de alta frecuencia en el nivel nativo y envía a JavaScript solo los resultados que merecen ser procesados:

    Native microphone
            ↓
    Native processing
            ↓
    Native processing
            ↓
    Native processing
            ↓
    Meaningful result
            ↓
        JavaScript
    

    La cantidad de cálculos puede ser similar, pero el patrón de comunicación es completamente diferente, al igual que la carga en ambos hilos.

    Técnicas prácticas para reducir costos

    Combinar operaciones en una sola llamada

    Un bucle que espera una llamada nativa por cada elemento es una fuente común de inestabilidad:

    for (const item of items) {
      await NativeModule.saveItem(item);
    }
    

    Con 500 elementos, eso implica potencialmente cientos de cruces separados, cada uno con su propio costo de programación. Una API nativa mejor acepta toda la colección y permite que el código nativo realice la iteración:

    await NativeModule.saveItems(items);
    

    La diferencia en estructura es fácil de explicar:

    ❌ 500 boundary crossings × small operation
    
    ✅ 1 boundary crossing × 500 native operations
    

    El agrupamiento es una de las optimizaciones arquitectónicas más simples y efectivas disponibles. Un detalle de diseño a considerar: una llamada por lotes necesita un contrato de errores claro, como indicar qué elementos fallaron, para que un elemento defectuoso no descarte silenciosamente al resto.

    Controlar o combinar eventos de alta frecuencia

    Supongamos que un módulo nativo emite 120 actualizaciones por segundo. JavaScript rara vez necesita todas ellas. Para muchas interfaces, unas 20 actualizaciones significativas por segundo se ven prácticamente idénticas en la pantalla. En lugar de manejarlas todas

    120 events / second
    

    podrías procesarlas de forma agrupada

    20 events / second
    

    o acumular eventos en el lado nativo y enviar solo el estado más reciente. Realizar la limitación de frecuencia de forma nativa suele ser mejor que hacerlo en JavaScript, ya que los eventos enviados desde JavaScript ya han pagado el costo de transmisión. Este enfoque es adecuado para:

    • rastreo de ubicación,
    • eventos de desplazamiento,
    • datos de sensores,
    • informes de progreso,
    • niveles de audio,
    • estadísticas de red,
    • movimiento en mapas,
    • paneles de control en tiempo real.

    El evento más económico es aquel que nunca se envía.

    Transfiera el significado, no los datos brutos

    En lugar de enviar algo como

    10 MB raw dataset
    

    desde el lado nativo a JavaScript y pedirle a JavaScript que obtenga una respuesta, calcule la respuesta donde ya se encuentran los datos. Reemplace este flujo:

    Native
      ↓
    10 MB data
      ↓
    JavaScript
      ↓
    find average
    

    por este otro:

            Native
              ↓
      calculate average
              ↓
    JavaScript receives: 42.7
    

    En resumen, transfiera

    meaning
    

    en lugar de

    raw information
    

    cada vez que la arquitectura lo permita. La contrapartida es que la lógica se traslada al código nativo, el cual debe escribirse y probarse para cada plataforma o en C++ compartido; reserve esto para datos que sean realmente grandes o se utilicen con frecuencia.

    La latencia de red no es la latencia de interacción

    Un backend rápido puede ocultar una experiencia lenta. Supongamos que la API responde en

    120 ms
    

    y el panel de monitoreo muestra estado verde. Sin embargo, después de que llegue la respuesta, todavía hay mucho que suceder:

    API Response
        ↓
    JSON Parsing
        ↓
    State Transformation
        ↓
    Redux / Zustand / Context
        ↓
    Component Re-rendering
        ↓
    JS ↔ Native interaction
        ↓
    Layout
        ↓
    UI Commit
    

    Para cuando el resultado aparezca en la pantalla, el usuario podría haber estado esperando

    200+ ms
    

    Los informes de monitoreo del backend indican que todo funciona rápidamente; el usuario, en cambio, afirma que la aplicación es lenta. Ambos tienen razón, ya que miden cosas diferentes. La latencia del backend y la latencia de interacción son métricas separadas, y en las aplicaciones móviles suele ser esta última la que determina si el producto se siente fluido.

    Pruebe en los dispositivos que realmente poseen sus usuarios

    Los problemas de rendimiento suelen ocultarse en el hardware de los desarrolladores. Un teléfono de gama alta cuenta con una CPU rápida, suficiente memoria, almacenamiento veloz, una GPU potente y buena capacidad de enfriamiento, por lo que todo parece funcionar bien. Sin embargo, los usuarios finales ejecutan la misma versión en teléfonos con muchos menos recursos, y

    small inefficiency × thousands of operations
    

    de repente los problemas se vuelven visibles. Probar únicamente en dispositivos de alta gama es arriesgado. Una matriz de dispositivos adecuada debe incluir al menos:

    High-end device
    Mid-range device
    Lower-end supported device
    

    Esto es especialmente importante cuando su audiencia incluye mercados donde los teléfonos Android asequibles representan una gran parte de los usuarios.

    Mida las versiones finales, no las de desarrollo

    Las versiones de desarrollo realizan tareas adicionales relacionadas con advertencias, depuración y herramientas, y la guía oficial de rendimiento recomienda explícitamente medir con versiones finales. Antes de concluir que React Native en sí es lento, realice un análisis de rendimiento de

    Release / Production build
    

    en lugar de confiar únicamente en un

    Development build
    

    De lo contrario, puede pasar horas arreglando una ralentización que ningún usuario experimentaría nunca.

    Lista de verificación diagnóstica para una pantalla lenta

    Utilizar useMemo() de forma dispersa en una pantalla lenta rara vez es el primer paso adecuado. En su lugar, responda a estas preguntas.

    1. ¿Está ocupado el hilo de JavaScript?

    Busque:

    • bucles costosos,
  • análisis síncrono de grandes volúmenes de datos,
  • transformaciones complejas del estado,
  • vuelvas a renderizar evitables,
  • registro excesivo de datos,
  • árboles de componentes muy grandes.
  • 2. ¿Está ocupado el hilo de la interfaz de usuario?

    Busque:

    • renderizado nativo costoso,
    • diseños complejos,
    • imágenes de gran tamaño,
    • jerarquías profundas de vistas,
    • animaciones que se ejecutan en el hilo incorrecto.

    3. ¿Se cruza con demasiada frecuencia el límite nativo?

    Búsque viajes de ida y vuelta de este tipo, especialmente aquellos de alta frecuencia:

    Native → JS → Native
    

    4. ¿Qué tan grandes son los datos transmitidos?

    No basta con contar las llamadas. Mida cuántos datos fluyen a través de cada una.

    5. ¿Se pueden agrupar las llamadas?

    Cuando sea posible, convierta

    many small operations
    

    en

    one meaningful operation
    

    6. ¿Necesita realmente JavaScript estos datos?

    Si el código nativo genera 1,000 puntos de datos y JavaScript solo utiliza su promedio, envíe el promedio.

    7. ¿Es necesario hacerlo ahora mismo?

    Mantener

    User-critical work
    

    aparte de

    background work
    

    Los análisis nunca deben competir con la retroalimentación táctil en el mismo frame.

    8. ¿Existe aún una dependencia de la arquitectura heredada?

    La documentación actual de React Native marca los Módulos Nativos Heredados y los Componentes Nativos Heredados como obsoletos y recomienda los Módulos Nativos Turbo y los Componentes Nativos Fabric cuando sea apropiado. Al elegir un paquete para una función sensible al rendimiento, verifique qué arquitectura admite; una biblioteca que funciona a través de la capa de interoperabilidad podría no ofrecer los beneficios esperados. El estado de soporte cambia rápidamente, así que verifíquelo en las notas de lanzamiento actuales de la biblioteca.

    Una regla para distribuir el trabajo

    Para cualquier función sensible al rendimiento, comience decidiendo qué parte de la aplicación es realmente responsable de realizarla.

    • Cuando la tarea corresponde a JavaScript (reglas de negocio, estado de la interfaz, orquestación), déjela allí y evite recurrir al código nativo en cada paso.
    • Cuando la tarea corresponde a código nativo (bucles de sensores, procesamiento de fotogramas, buffers de audio), permita que esas rutas críticas se ejecuten en modo nativo y permanezcan allí.
    • Cruce la frontera solo cuando haya algo significativo que comunicar, no para que cada paso intermedio sea visible en ambos lados.

    Una división adecuada suele verse así:

    ┌───────────────────────┐
    │      JavaScript       │
    │                       │
    │ Business decisions    │
    │ Application state     │
    │ UI orchestration      │
    └───────────┬───────────┘
                │
         Meaningful calls
                │
    ┌───────────▼───────────┐
    │        Native         │
    │                       │
    │ Platform APIs         │
    │ High-frequency work   │
    │ Hardware interaction  │
    └───────────────────────┘
    

    y no así, cientos de veces por segundo:

    JS ↔ Native ↔ JS ↔ Native ↔ JS ↔ Native
    

    Lo que realmente arregla la nueva arquitectura

    La nueva arquitectura elimina varios límites estructurales del modelo anterior. En lugar de construir todo alrededor de un puente obligatorio, serializado y asíncrono, React Native moderno ofrece una interfaz directa a través de JSI:

           JSI
            ↓
    Direct JS/native interfacing
    

    junto con las demás componentes que se integran sobre él:

           TurboModules
                ↓
    Modern native module system         Fabric
                ↓
    Modern rendering architecture         Codegen
                ↓
    Typed interfaces between environments   Modern scheduling
                ↓
    Better prioritization of work
    

    El equipo de React Native describe que la eliminación del puente reduce la carga por serialización, mejora el tiempo de inicio y permite una comunicación más directa entre JavaScript y el entorno nativo. El cambio más significativo es la flexibilidad. Ahora el framework tiene un mayor control sobre:

    • cuándo se ejecuta el trabajo,
    • dónde se ejecuta el trabajo,
    • cómo se accede a las funcionalidades nativas,
    • qué actualizaciones tienen prioridad.

    Esta lección se aplica a cada límite

    Al alejarnos de React Native, el patrón es general: cada límite arquitectónico tiene un costo. Piense en estas intersecciones:

    Browser → API
    Service → Service
    Application → Database
    Process → Worker
    JavaScript → Native
    Application → Redis
    Backend → Message Broker
    

    A menudo, los sistemas se diseñan como si las llamadas a través de estos límites se comportaran como llamadas normales a funciones dentro del mismo proceso. Pero no es así. Cada límite añade algún tipo de:

    • serialización,
    • copia,
    • programación de tareas,
    • colas,
    • redes,
    • sincronización,
    • cambio de contexto,
    • gestión de errores,
    • gestión de memoria.

    Bridge hace que ese principio sea excepcionalmente visible dentro de una aplicación para teléfonos.

    Arregle la arquitectura antes de realizar microoptimizaciones

    Es tentador recurrir primero a herramientas como estas:

    useMemo(...)
    useCallback(...)
    React.memo(...)
    

    Tienen usos reales. Pero si la aplicación transfiere 5 MB de datos no necesarios entre ejecuciones 30 veces por segundo, ahorrar 0.2 ms con useMemo() no tendrá importancia. El trabajo de mejora de rendimiento es más efectivo cuando se realiza en este orden:

    Architecture
        ↓
    Data flow
        ↓
    Scheduling
        ↓
    Rendering
        ↓
    Algorithms
        ↓
    Micro-optimizations
    

    y no al revés.

    Puntos clave

    El legado de Bridge no se debió simplemente a una mala ingeniería. Permitió que JavaScript y el código nativo permanecieran desacoplados mientras se comunicaban de forma asíncrona, y ayudó enormemente a React Native. A medida que las aplicaciones incorporaron mensajería en tiempo real, animaciones complejas, pipelines de cámara y aprendizaje automático, mapas, procesamiento de audio, gestos complejos y otras interacciones nativas de alta frecuencia, los límites de la mensajería asíncrona serializada se volvieron fácilmente alcanzables. JSI, TurboModules, Fabric y el modelo rediseñado de programación y renderizado eliminan muchos de esos límites, pero no las consideraciones económicas subyacentes.

    • Mida todo el pipeline de interacción, no solo la llamada nativa o la respuesta de la API.
    • Estime el tiempo según el frame: aproximadamente 16,67 ms a 60 Hz y unos 8,33 ms a 120 Hz.
    • Grupé las operaciones, controle o combine los flujos de eventos en la fuente, y envíe los resultados en lugar de datos brutos.
  • Determine qué entorno de ejecución se encarga de cada responsabilidad y mantenga los bucles de alta frecuencia en un mismo lado.
  • Evalúe las versiones finales en dispositivos de gama media y baja, y verifique si las dependencias siguen utilizando módulos obsoletos.
  • Considere a JSI como un medio de transporte más rápido, no como una licencia para realizar más llamadas.
  • Cuando una aplicación parece lenta aunque cada componente muestre un rendimiento rápido, analice las conexiones entre esos componentes. A menudo, la parte costosa no es el trabajo en sí, sino la distancia que recorren los datos para llegar hasta él.

    Lecturas relacionadas