La arquitectura de JavaScript en los navegadores web es estrictamente monohilo (Single-Threaded Event Loop). El hilo principal (Main Thread) es responsable simultáneamente de ejecutar la lógica de negocio, procesar las fases de Reconciliation y Commit del Virtual DOM de React 19, gestionar los eventos de interacción del usuario (clicks, teclado, gestos táctiles, scroll), calcular la geometría CSS (Layout) y rasterizar los píxeles en pantalla (Paint).
Si se ejecuta una operación computacional síncrona en el Main Thread que exceda los 50 milisegundos (catalogada como Long Task), la interfaz del navegador se congela por completo:
La única solución arquitectónica definitiva para cálculos matemáticos intensivos, análisis sintáctico de datos y manipulación gráfica consiste en delegar el trabajo a los Web Workers (Dedicated Workers).
Para erradicar el código repetitivo y arcaico de postMessage / onmessage, la industria recurre a Comlink.
El Main Thread debe reservarse en exclusiva para la interfaz y la respuesta inmediata a las acciones del usuario. Cualquier procesamiento masivo de datos (CSV/Excel/GeoJSON), criptografía del lado del cliente, compresión multimedia o búsquedas vectoriales debe transferirse a un Web Worker en segundo plano. Comlink transforma el intercambio de mensajes en una llamada asíncrona convencional: await workerApi.processData(...).
Un Web Worker se ejecuta en un contexto totalmente aislado (DedicatedWorkerGlobalScope). Carece de acceso directo a los objetos window, document, nodos del DOM, localStorage o al árbol de contexto de React.
┌─────────────────────────────────────────────────────────────┐
│ MAIN THREAD (¡Protegiendo FPS y respuesta viva!): │
│ React 19 Reconciler │ User Events │ CSS Layout │ Paint UI │
└──────────────────────────────┬──────────────────────────────┘
│ Llamada RPC (Comlink.wrap)
▼ PostMessage / Transferable ArrayBuffer
┌─────────────────────────────────────────────────────────────┐
│ BACKGROUND WEB WORKER THREAD (Hilo en segundo plano): │
│ • Procesamiento de CSV/Excel de más de 500.000 filas │
│ • Criptografía cliente (Argon2 / SHA-256 / RSA-4096) │
│ • Procesamiento y renderizado vía OffscreenCanvas │
│ • Búsqueda vectorial y full-text sobre 1.000.000 registros │
└─────────────────────────────────────────────────────────────┘La comunicación entre hilos se realiza mediante la serialización de mensajes. Por defecto, el navegador aplica el algoritmo Structured Clone, el cual efectúa una copia profunda del objeto transmitido. Al transferir colecciones de datos de cientos de megabytes, el consumo de CPU derivado de la copia en memoria puede bloquear el Main Thread tanto como el propio cómputo.
Para alcanzar una transferencia de coste cero (Zero-Copy), se emplean:
Perfilado mediante Chrome DevTools Performance Profiler para aislar tareas síncronas que superen los 50 ms.
Implementación de worker.ts fuertemente tipado exponiendo los métodos con Comlink.expose.
Lectura del fichero en ArrayBuffer y cesión de propiedad mediante Comlink.transfer.
Consumo de métodos remotos a través de Promises con terminación garantizada (worker.terminate) al desmontar.
¿Qué le ocurre al objeto ArrayBuffer en el hilo principal tras enviarlo a un Web Worker mediante el mecanismo de Transferable Objects?
OffscreenCanvas las redimensiona y comprime a WebP 1080p en paralelo, ahorrando gigabytes de ancho de banda.Definimos el contrato tipado y lo publicamos con Comlink.expose:
import * as Comlink from 'comlink';
export interface ProcessedRow {
id: string;
timestamp: number;
value: number;
category: string;
}
export interface ProcessingReport {
totalRows: number;
validRows: number;
totalSum: number;
averageValue: number;
executionTimeMs: number;
}
export interface DataProcessorApi {
processHeavyDataset(
buffer: ArrayBuffer,
delimiter: string,
onProgress?: (percent: number) => void
): Promise<ProcessingReport>;
}
const dataProcessor: DataProcessorApi = {
async processHeavyDataset(buffer, delimiter, onProgress) {
const startTime = performance.now();
const decoder = new TextDecoder('utf-8');
const csvText = decoder.decode(buffer);
const lines = csvText.split('\n');
const totalLines = lines.length;
let validCount = 0;
let sum = 0;
const step = Math.max(1, Math.floor(totalLines / 100));
for (let i = 1; i < totalLines; i++) {
const line = lines[i]?.trim();
if (!line) continue;
const columns = line.split(delimiter);
const value = parseFloat(columns[2] ?? '0');
if (!Number.isNaN(value)) {
validCount++;
sum += value;
}
// Notificar progreso cada 1% mediante Comlink.proxy
if (i % step === 0 && onProgress) {
onProgress(Math.round((i / totalLines) * 100));
}
}
const executionTimeMs = performance.now() - startTime;
return {
totalRows: totalLines - 1,
validRows: validCount,
totalSum: sum,
averageValue: validCount > 0 ? sum / validCount : 0,
executionTimeMs,
};
},
};
Comlink.expose(dataProcessor);El hook aísla la creación y finalización del hilo del sistema, exponiendo una interfaz RPC fuertemente tipada:
'use client';
import * as React from 'react';
import * as Comlink from 'comlink';
export interface UseWorkerReturn<T> {
api: Comlink.Remote<T> | null;
isLoading: boolean;
error: Error | null;
terminate: () => void;
}
export function useWorker<T>(
workerFactory: () => Worker,
deps: React.DependencyList = []
): UseWorkerReturn<T> {
const [isLoading, setIsLoading] = React.useState(true);
const [error, setError] = React.useState<Error | null>(null);
const workerRef = React.useRef<Worker | null>(null);
const apiRef = React.useRef<Comlink.Remote<T> | null>(null);
const terminate = React.useCallback(() => {
if (workerRef.current) {
workerRef.current.terminate();
workerRef.current = null;
apiRef.current = null;
}
}, []);
React.useEffect(() => {
setIsLoading(true);
setError(null);
try {
const worker = workerFactory();
workerRef.current = worker;
const remoteApi = Comlink.wrap<T>(worker);
apiRef.current = remoteApi;
setIsLoading(false);
} catch (err) {
setError(err instanceof Error ? err : new Error(String(err)));
setIsLoading(false);
}
return () => {
terminate();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return {
api: apiRef.current,
isLoading,
error,
terminate,
};
}Creación del worker con la sintaxis del empaquetador (Vite/Webpack) y enlace con el proxy de Comlink.
Conversión del archivo a ArrayBuffer y cesión de propiedad mediante Comlink.transfer(buf, [buf]).
Ejecución del algoritmo en un núcleo de CPU independiente. El Main Thread permanece con 0% de carga a 120 FPS.
Resolución de la promesa en el componente React, actualización de estado y finalización con terminate() al desmontar.
¿Por qué es indispensable envolver con Comlink.proxy(fn) cualquier función callback (como onProgress) que se envíe al Web Worker?
A continuación se muestra un componente de análisis de archivos CSV masivos con reporte de progreso en tiempo real y sin impacto alguno en el hilo principal:
'use client';
import * as React from 'react';
import * as Comlink from 'comlink';
import { useWorker } from '@/shared/hooks/useWorker';
import type { DataProcessorApi, ProcessingReport } from '@/shared/workers/dataProcessor.worker';
export function HeavyCsvParser() {
const [progress, setProgress] = React.useState<number>(0);
const [isProcessing, setIsProcessing] = React.useState(false);
const [report, setReport] = React.useState<ProcessingReport | null>(null);
// Factoría del worker con la sintaxis estándar de empaquetadores modernos (Next.js / Vite)
const { api } = useWorker<DataProcessorApi>(
() =>
new Worker(
new URL('../../shared/workers/dataProcessor.worker.ts', import.meta.url),
{ type: 'module' }
)
);
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file || !api) return;
setIsProcessing(true);
setProgress(0);
setReport(null);
try {
// 1. Lectura del archivo a ArrayBuffer
const arrayBuffer = await file.arrayBuffer();
// 2. Creación del callback proxy para emitir progreso de forma segura
const progressCallback = Comlink.proxy((percent: number) => {
setProgress(percent);
});
// 3. Envío del ArrayBuffer con cesión de titularidad (Zero-Copy)
const result = await api.processHeavyDataset(
Comlink.transfer(arrayBuffer, [arrayBuffer]),
',',
progressCallback
);
setReport(result);
} catch (err) {
console.error('Fallo en el procesamiento en segundo plano:', err);
} finally {
setIsProcessing(false);
}
};
return (
<div className="p-6 rounded-2xl bg-bg-surface border border-border space-y-5">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-bold text-text-primary">Off-Thread CSV Analyzer</h3>
<p className="text-xs text-text-secondary mt-0.5">
Procesamiento en Web Worker con Comlink RPC (INP < 16 ms)
</p>
</div>
<span className="px-2.5 py-1 rounded-full text-xs font-mono bg-bg-elevated text-accent-violet border border-border">
Main Thread 0% CPU
</span>
</div>
<div className="flex items-center gap-4">
<label className="cursor-pointer px-4 py-2 bg-accent-violet text-white text-sm font-semibold rounded-xl hover:bg-accent-violet/90 transition-colors">
<span>{isProcessing ? 'Procesando...' : 'Cargar archivo CSV pesado'}</span>
<input
type="file"
accept=".csv"
onChange={handleFileUpload}
disabled={isProcessing}
className="hidden"
/>
</label>
{isProcessing && (
<span className="text-xs font-mono text-text-secondary animate-pulse">
Progreso: {progress}%
</span>
)}
</div>
{isProcessing && (
<div className="w-full bg-bg-elevated rounded-full h-2 overflow-hidden">
<div
className="bg-accent-violet h-full transition-all duration-150 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
)}
{report && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 pt-3 border-t border-border">
<div className="p-3 rounded-lg bg-bg-elevated/40 border border-border/40">
<div className="text-xs text-text-muted">Total de filas</div>
<div className="text-lg font-bold font-mono text-text-primary">
{report.totalRows.toLocaleString()}
</div>
</div>
<div className="p-3 rounded-lg bg-bg-elevated/40 border border-border/40">
<div className="text-xs text-text-muted">Registros válidos</div>
<div className="text-lg font-bold font-mono text-emerald-400">
{report.validRows.toLocaleString()}
</div>
</div>
<div className="p-3 rounded-lg bg-bg-elevated/40 border border-border/40">
<div className="text-xs text-text-muted">Valor promedio</div>
<div className="text-lg font-bold font-mono text-text-primary">
{report.averageValue.toFixed(2)}
</div>
</div>
<div className="p-3 rounded-lg bg-bg-elevated/40 border border-border/40">
<div className="text-xs text-text-muted">Tiempo de cálculo</div>
<div className="text-lg font-bold font-mono text-accent-violet">
{report.executionTimeMs.toFixed(1)} ms
</div>
</div>
</div>
)}
</div>
);
}Los Web Workers están totalmente aislados del entorno de la ventana. Transmitir window, nodos del DOM (document.getElementById(...)) o funciones normales de JavaScript mediante postMessage sin Comlink.proxy() provocará una excepción irrecuperable: DOMException: The object could not be cloned. Solo es posible transferir valores serializables o instancias de Transferables.
Enviar un objeto ArrayBuffer grande con worker.postMessage(buffer) sin declarar la cesión de posesión (worker.postMessage(buffer, [buffer])) duplicará los datos en la memoria RAM y bloqueará el Main Thread durante el proceso de clonación profunda.
Instanciar new Worker(...) en cada click de botón o re-renderizado sin invocar worker.terminate() genera hilos zombi persistentes. Una docena de workers huérfanos puede saturar la cuota de memoria del proceso de la pestaña.
Crear un hilo de worker y el paso de mensajes entre procesos acarrea un retardo constante de 1 a 3 ms. Trasladar la ordenación de un array de 50 elementos a un worker tardará diez veces más que resolverla de forma síncrona en el Main Thread. Los workers solo se justifican para tareas que demanden más de 30–50 ms.
ArrayBuffer en menos de 0.5 ms sin duplicar memoria.postMessage en invocaciones asíncronas RPC fuertemente tipadas.worker.terminate().