Inicio / Artículos

Coordine las subidas de múltiples pestañas con la API Web Locks

Evita las subidas duplicadas de archivos que se pueden reanudar entre pestañas del navegador al reemplazar los trucos con localStorage y BroadcastChannel por navigator.locks y un coordinador de subidas en React.

Editor: REACTAPP.TOP6 min de lectura1360 palabras

Fuentes: Handling concurrency on the Web with Web Locks API

La concurrencia significa avanzar en varios trabajos al mismo tiempo, o dar esa impresión, para que el software siga siendo reactivo. Un cocinero que corta ingredientes mientras una sartén se calienta y otra se usa para hornear está manejando tareas de forma concurrente en lugar de terminar los platos estrictamente uno tras otro.

Antes, el trabajo en el frontend parecía muy distinto a los sistemas multihilo clásicos con bloqueos, semáforos y puntos muertos. La plataforma web moderna cambió eso. Gracias a Workers, WebGL, Canvas y Service Workers, las aplicaciones de navegador ahora enfrentan problemas de coordinación que se asemejan mucho a competencias por memoria compartida.

El problema

Un equipo de ingeniería de un mercado en línea necesitaba cargas fiables para archivos muy grandes, del orden de decenas de gigabytes, hasta unos 20 GB. Pedir a las personas que mantengan una pestaña abierta durante toda la transferencia resulta inestable. El objetivo del producto era lograr cargas reanudables: cerrar y volver a abrir una pestaña debería permitir continuar la transferencia sin necesidad de reiniciarla manualmente.

La posibilidad de reanudar introduce un nuevo modo de fallo. Si varias pestañas restauran la misma carga en curso, cada una podría reiniciarla de forma independiente. El almacén de objetos entonces recibe flujos duplicados, se desperdicia ancho de banda, aumentan los costos de procesamiento y la interfaz de progreso puede volverse inestable al intentar gestionar simultáneamente las mismas tareas.

Lo problemático es que cada pestaña, por separado, realiza una acción razonable. La restauración de sesión encuentra un registro de carga incompleto, el cliente reanuda la transferencia multiparte y la interfaz muestra el progreso. Si se multiplican estas situaciones por dos pestañas abiertas, ese mismo proceso “razonable” se convierte en dos procesos que intentan escribir sobre la misma clave de objeto remoto.

Sin un coordinador, las dos pestañas restauradas creen ambas que controlan la carga y ambas envían datos. La solución requiere exclusión mutua entre las pestañas; los navegadores ofrecen esta funcionalidad básica a través de los Web Locks.

Antes de entrar en los detalles de implementación, es útil recordar para qué sirve un bloqueo.

Primero, ¿qué es un bloqueo?

Un bloqueo serializa el acceso a un recurso compartido para que quienes lo utilizan vean siempre un estado consistente. En el ejemplo clásico de un banco, varios cajeros automáticos leen y escriben al mismo tiempo el saldo de la cuenta. Sin sincronización, los retiros y depósitos se superponen de forma insegura.

Con un bloqueo, el primer usuario que lo obtiene realiza su lectura o escritura; los demás esperan a que se libere. Esa misma idea se aplica a la restricción de “solo una pestaña puede reanudar esta carga”.

El panorama de la coordinación entre pestañas

Varios patrones antiguos de navegadores intentan coordinar las pestañas. Funcionan hasta que aparecen condiciones competitivas, fallos o errores en el protocolo.

Opción 1: localStorage + Sondeo

Un mutex casero común almacena una bandera en localStorage:

// Simplified example - DON'T use this
if (!localStorage.getItem('upload-lock')) {
  localStorage.setItem('upload-lock', Date.now());
  // Start upload
}

Rápidamente surgen problemas:

  • Condiciones competitivas: las lecturas y escrituras no son atómicas
  • Bloqueos obsoletos: una pestaña que falla deja la bandera activa para siempre
  • Carga adicional por sondeo: las demás pestañas deben seguir verificando constantemente
  • Límites de los eventos: los eventos de almacenamiento se disparan en otras pestañas, no en la que realiza la escritura

Opción 2: API de canales de difusión

Las pestañas pueden comunicarse entre documentos mediante mensajes a través de BroadcastChannel:

const channel = new BroadcastChannel('upload-coordination');
channel.postMessage({ type: 'REQUEST_UPLOAD_PERMISSION' });

Aún existen desafíos:

  • Sin primitiva de mutex: debes crear tú mismo una máquina de estados
  • Condiciones de carrera: los mensajes que se superponen siguen generando conflictos
  • Protocolo complejo: los mensajes de verificación, la detección de fallos y las reglas de conflicto deben implementarse manualmente
  • Sujeto a errores: los problemas de ordenamiento y temporización son comunes

Llega la API Web Locks: Mutex nativo del navegador

Los Web Locks nativos ofrecen un mutex atómico del navegador. Las propiedades útiles incluyen:

  • Adquisición atómica del bloqueo — evita las condiciones de carrera en localStorage
  • Limpieza automática — al cerrar o hacer que falle una pestaña, el bloqueo se libera
  • Opciones no bloqueantes — mantienen la interfaz de usuario receptiva
  • API sencilla — sin protocolo de mensajería personalizado
  • Compatibilidad con navegadores — se detalla en la tabla de compatibilidad de MDN para los motores actuales
  • Nuestra implementación: useUploadCoordinator

    Un hook personalizado de React encapsula la estrategia para el control del envío.

    La estrategia principal

    Tratar los inicios interactivos de manera diferente a las restauraciones silenciosas. Cuando alguien elige explícitamente un archivo, se prefiere una adquisición no bloqueante para que la interfaz de usuario nunca espere en otra pestaña. Si el bloqueo está libre, se obtiene y se realiza el envío; si ya está en uso, la decisión sigue basándose en las reglas del producto en lugar de congelar la página.

    Los envíos restaurados deben seguir reglas más estrictas: solo la primera pestaña que recupere el control debe continuar con la transferencia. Las pestañas posteriores que vean que el bloqueo está ocupado deben dejar en paz al propietario actual en lugar de abrir un segundo flujo.

    // 1. Fresh uploads (user-initiated): Non-blocking
    // Try to get the lock, but proceed regardless
    navigator.locks.request(LOCK_NAME,
      { ifAvailable: true },
      async (lock) => {
        if (lock) {
          // Hold the lock until upload completes
          await holdLockUntilComplete(abortController);
        }
        // Upload proceeds either way - never block the user
      }
    );
    
    // 2. Restored uploads: Conditional
    // Only proceed if we get the lock
    navigator.locks.request(LOCK_NAME,
      { ifAvailable: true },
      async (lock) => {
        if (lock) {
          uploader.emit("restore-confirmed"); // Proceed with restore
          await holdLockUntilComplete(abortController);
        }
        // If no lock, skip silently - another tab is handling it
      }
    );
    

    Detalles clave de implementación

    Mantenga el bloqueo hasta que la transferencia finalice o se interrumpa:

    const holdLockUntilComplete = (abortController: AbortController) => {
      return new Promise<void>((resolve) => {
        const cleanup = () => {
          // Remove all listeners and resolve (releasing the lock)
          uploader.off("complete", cleanup);
          uploader.off("error", cleanup);
          uploader.off("cancel-all", cleanup);
          resolve();
        };
    
        // Release lock on any terminal event
        uploader.on("complete", cleanup);
        uploader.on("error", cleanup);
        uploader.on("cancel-all", cleanup);
    
        // Also release if component unmounts
        abortController.signal.addEventListener("abort", cleanup);
      });
    };
    

    Detecte la compatibilidad y funcione de forma segura cuando falte la API:

    const checkWebLocksAvailable = (): boolean => {
      return typeof navigator !== 'undefined' && 'locks' in navigator;
    };
    
    // Fallback behavior
    if (!checkWebLocksAvailable()) {
      // Allow restores to proceed (accept risk of duplicates)
      uploader.emit("restore-confirmed");
      return;
    }
    

    Los beneficios en la práctica

    Después de lanzar el coordinador, el equipo observó:

    1. Cero cargas duplicadas causadas por competencias al restaurar desde varias pestañas
    2. Mejor experiencia de usuario — la coordinación no congeló la interfaz
    3. Menores costos de infraestructura — menos transferencias duplicadas y objetos almacenados
    4. Código más simple — sin bucles de consulta ni máquinas de estado construidas a mano

    Solo la pestaña que obtiene el bloqueo continúa con la carga; las demás detectan el bloqueo en uso y dejan de actuar.

    Consideraciones del mundo real

    Cuándo usar este patrón:

    • Cargas de archivos que se restauran después de recargar
  • Sincronización de fondo que debe ejecutarse una sola vez
  • Cualquier tarea singleton que no debería ejecutarse en pestañas paralelas
  • Efecto en el rendimiento:

    • Mínimo: no hay bucle de consulta
    • La limpieza automática limita los waiters fugados
    • Las solicitudes no bloqueantes mantienen la interacción receptiva

    Estrategia de pruebas:

    • Probar cada navegador compatible que se incluya
    • Simular caídas abruptas de pestañas mientras se mantiene un bloqueo
    • Confirmar el comportamiento de fallback cuando los Web Locks no están disponibles

    Conclusión

    La coordinación entre pestañas no requiere un protocolo personalizado frágil. Confíe en el mutex de Web Locks de la plataforma: se limpia al salir de la pestaña y admite adquisiciones no bloqueantes. Consulte la documentación de Web Locks para obtener detalles sobre la API.

    Reglas prácticas que han funcionado en producción:

    1. Nunca bloquee las acciones del usuario — prefiera bloqueos no obstructivos para los gestos iniciados por el usuario
    2. Falle de forma segura — degrade el funcionamiento en lugar de interrumpir por completo las subidas
    3. Manténgalo simple — utilice las primitivas de la plataforma en lugar de reconstruirlas

    Esas tres reglas son más importantes que el nombre de cualquier herramienta específica. Un coordinador sofisticado que bloquea todo el proceso al hacer clic resultará peor que una solución más sencilla que ocasionalmente pida al usuario que intente nuevamente. De igual manera, un protocolo de mensajería ingenioso que olvide limpiar los fallos volverá a generar el problema de bloqueos obsoletos que causó tantas dificultades con localStorage.

    El mismo patrón se aplica siempre que las pestañas deben elegir un único propietario para el trabajo compartido, no solo en el caso de subidas grandes. Las colas de sincronización en segundo plano, los mecanismos de actualización sin conexión y los scripts de migración únicos se benefician todos del mismo modelo de “un ganador, muchos perdedores educados”.

    Lecturas relacionadas

  • Usa DHTMLX React Gantt con Remix para aplicaciones de planificación de proyectos — Conecta DHTMLX React Gantt a las rutas de Remix con una configuración estable, callbacks data.save y acceso opcional a la API imperativa, sin necesidad de soluciones alternativas de SSR.
  • Por qué los editores visuales tienen dificultades con componentes propios de shadcn UI — shadcn almacena los mapas CVA en tu repositorio, pero Tailwind requiere un proceso de compilación. Descubre cómo la colección de clases DOM permite utilizar el modo de diseño shadow-DOM sin escanear el código fuente.