Web Workers: Cómo Usar Threads en JavaScript Para Mejorar Performance
Hola HaWkers, JavaScript es single-threaded por diseño. Esto significa que operaciones pesadas bloquean el hilo principal, causando trabas en la interfaz. Web Workers resuelven ese problema permitiendo ejecutar código en threads separados.
Vamos a explorar cómo usar Web Workers para crear aplicaciones más responsivas y performáticas.
El Problema del Hilo Principal
El hilo principal de JavaScript es responsable por:
- Ejecutar tu código
- Procesar eventos del DOM
- Renderizar actualizaciones visuales
- Responder a interacciones del usuario
Cuando ejecutas código pesado, todas esas tareas quedan bloqueadas:
// ❌ MALO - Bloquea el hilo principal
function procesarDatosPesados(datos) {
// Simulación de procesamiento pesado
const resultado = [];
for (let i = 0; i < 10000000; i++) {
resultado.push(Math.sqrt(datos[i % datos.length]) * Math.random());
}
return resultado;
}
// Durante esa ejecución, la UI queda congelada
// Clics no responden, animaciones paran
const resultado = procesarDatosPesados(misDatos);Síntomas de hilo principal bloqueado:
- Interfaz trabada
- Animaciones pausadas
- Inputs no responden
- Scroll enganchado
- "Page Unresponsive" en el navegador
Cómo Web Workers Funcionan
Web Workers ejecutan JavaScript en un thread separado, completamente aislado del hilo principal.
Comunicación via Mensajes
Workers se comunican con el hilo principal a través de mensajes:
// main.js - Hilo principal
const worker = new Worker('worker.js');
// Enviar datos para el worker
worker.postMessage({ datos: misDatos, tipo: 'procesar' });
// Recibir resultado del worker
worker.onmessage = (event) => {
console.log('Resultado:', event.data);
// Actualizar UI con el resultado
actualizarInterfaz(event.data);
};
// Tratar errores
worker.onerror = (error) => {
console.error('Error en el worker:', error.message);
};// worker.js - Thread separado
self.onmessage = (event) => {
const { datos, tipo } = event.data;
if (tipo === 'procesar') {
// Procesamiento pesado - no bloquea la UI
const resultado = procesarDatos(datos);
// Enviar resultado de vuelta
self.postMessage(resultado);
}
};
function procesarDatos(datos) {
const resultado = [];
for (let i = 0; i < 10000000; i++) {
resultado.push(Math.sqrt(datos[i % datos.length]) * Math.random());
}
return resultado;
}
Tipos de Workers
Dedicated Workers
El tipo más común. Exclusivo para un script específico:
// Crear worker dedicado
const worker = new Worker('worker.js');
// Encerrar worker cuando no lo necesites más
worker.terminate();Shared Workers
Compartido entre múltiples tabs/ventanas del mismo origen:
// main.js
const sharedWorker = new SharedWorker('shared-worker.js');
sharedWorker.port.onmessage = (event) => {
console.log('Mensaje recibido:', event.data);
};
sharedWorker.port.postMessage('Hello from tab');// shared-worker.js
const connections = [];
self.onconnect = (event) => {
const port = event.ports[0];
connections.push(port);
port.onmessage = (event) => {
// Broadcast para todas las conexiones
connections.forEach(conn => {
conn.postMessage(`Broadcast: ${event.data}`);
});
};
port.start();
};Service Workers
Para cache, push notifications y funcionalidad offline (no cubierto en detalles aquí).
Casos de Uso Prácticos
1. Procesamiento de Imágenes
// main.js
const imageWorker = new Worker('image-worker.js');
async function procesarImagen(imageFile) {
const bitmap = await createImageBitmap(imageFile);
// Usar OffscreenCanvas para procesar en el worker
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const offscreen = canvas.transferControlToOffscreen();
// Transferir canvas para el worker (no copia, transfiere)
imageWorker.postMessage(
{ canvas: offscreen, bitmap },
[offscreen, bitmap]
);
}
imageWorker.onmessage = (event) => {
if (event.data.tipo === 'progreso') {
actualizarBarraProgreso(event.data.porcentaje);
} else if (event.data.tipo === 'completo') {
exhibirResultado(event.data.imageData);
}
};// image-worker.js
self.onmessage = async (event) => {
const { canvas, bitmap } = event.data;
const ctx = canvas.getContext('2d');
ctx.drawImage(bitmap, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// Aplicar filtro (ejemplo: grayscale)
const pixels = imageData.data;
for (let i = 0; i < pixels.length; i += 4) {
const avg = (pixels[i] + pixels[i + 1] + pixels[i + 2]) / 3;
pixels[i] = avg; // R
pixels[i + 1] = avg; // G
pixels[i + 2] = avg; // B
// Reportar progreso
if (i % 100000 === 0) {
self.postMessage({
tipo: 'progreso',
porcentaje: Math.round((i / pixels.length) * 100)
});
}
}
ctx.putImageData(imageData, 0, 0);
self.postMessage({
tipo: 'completo',
imageData: ctx.getImageData(0, 0, canvas.width, canvas.height)
});
};2. Parsing de Archivos Grandes
// main.js
const csvWorker = new Worker('csv-worker.js');
async function procesarCSV(file) {
const text = await file.text();
csvWorker.postMessage({ csv: text, opciones: { delimiter: ',' } });
}
csvWorker.onmessage = (event) => {
const { tipo, datos, error } = event.data;
if (tipo === 'suceso') {
console.log(`Procesadas ${datos.length} líneas`);
renderizarTabla(datos);
} else if (tipo === 'error') {
console.error('Error al procesar CSV:', error);
}
};// csv-worker.js
self.onmessage = (event) => {
try {
const { csv, opciones } = event.data;
const lineas = csv.split('\n');
const headers = lineas[0].split(opciones.delimiter);
const datos = [];
for (let i = 1; i < lineas.length; i++) {
if (!lineas[i].trim()) continue;
const valores = lineas[i].split(opciones.delimiter);
const objeto = {};
headers.forEach((header, index) => {
objeto[header.trim()] = valores[index]?.trim();
});
datos.push(objeto);
}
self.postMessage({ tipo: 'suceso', datos });
} catch (error) {
self.postMessage({ tipo: 'error', error: error.message });
}
};
3. Cálculos Matemáticos Complejos
// main.js
const mathWorker = new Worker('math-worker.js');
function calcularEstadisticas(numeros) {
return new Promise((resolve, reject) => {
mathWorker.postMessage({ numeros });
mathWorker.onmessage = (event) => {
resolve(event.data);
};
mathWorker.onerror = (error) => {
reject(error);
};
});
}
// Uso
const stats = await calcularEstadisticas(grandeArrayDeNumeros);
console.log('Media:', stats.media);
console.log('Desviación Estándar:', stats.desviacionEstandar);// math-worker.js
self.onmessage = (event) => {
const { numeros } = event.data;
// Media
const suma = numeros.reduce((acc, n) => acc + n, 0);
const media = suma / numeros.length;
// Variancia
const sumaCuadrados = numeros.reduce((acc, n) => {
return acc + Math.pow(n - media, 2);
}, 0);
const variancia = sumaCuadrados / numeros.length;
// Desviación estándar
const desviacionEstandar = Math.sqrt(variancia);
// Mediana
const sorted = [...numeros].sort((a, b) => a - b);
const medio = Math.floor(sorted.length / 2);
const mediana = sorted.length % 2
? sorted[medio]
: (sorted[medio - 1] + sorted[medio]) / 2;
self.postMessage({
media,
mediana,
variancia,
desviacionEstandar,
min: Math.min(...numeros),
max: Math.max(...numeros),
});
};
Worker con TypeScript y Bundlers
Configuración con Vite
// src/workers/data-processor.worker.ts
self.onmessage = (event: MessageEvent<{ data: number[] }>) => {
const { data } = event.data;
const result = data.map(n => n * 2);
self.postMessage(result);
};
export {}; // Necesario para TypeScript tratar como módulo// src/main.ts
// Vite soporta workers nativamente con ?worker
import DataWorker from './workers/data-processor.worker?worker';
const worker = new DataWorker();
worker.postMessage({ data: [1, 2, 3, 4, 5] });
worker.onmessage = (event) => {
console.log('Resultado:', event.data); // [2, 4, 6, 8, 10]
};Configuración con Webpack
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.worker\.ts$/,
use: {
loader: 'worker-loader',
options: {
filename: '[name].[contenthash].worker.js',
},
},
},
],
},
};
Transferable Objects
Para datos grandes, usa Transferable Objects para evitar copias:
// main.js
const buffer = new ArrayBuffer(1024 * 1024 * 100); // 100MB
// ❌ Copia los datos (lento para datos grandes)
worker.postMessage({ buffer });
// ✅ Transfiere ownership (instantáneo)
worker.postMessage({ buffer }, [buffer]);
// Después de eso, `buffer` no está más disponible en el hilo principalTipos Transferibles
// ArrayBuffer
const arrayBuffer = new ArrayBuffer(1024);
worker.postMessage({ data: arrayBuffer }, [arrayBuffer]);
// MessagePort
const channel = new MessageChannel();
worker.postMessage({ port: channel.port2 }, [channel.port2]);
// ImageBitmap
const bitmap = await createImageBitmap(imageBlob);
worker.postMessage({ image: bitmap }, [bitmap]);
// OffscreenCanvas
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen }, [offscreen]);
Pool de Workers
Para tareas paralelas, usa un pool de workers:
// worker-pool.js
class WorkerPool {
constructor(workerScript, poolSize = navigator.hardwareConcurrency || 4) {
this.workers = [];
this.queue = [];
this.activeWorkers = new Set();
for (let i = 0; i < poolSize; i++) {
const worker = new Worker(workerScript);
worker.id = i;
this.workers.push(worker);
}
}
execute(data) {
return new Promise((resolve, reject) => {
const task = { data, resolve, reject };
const availableWorker = this.workers.find(
w => !this.activeWorkers.has(w.id)
);
if (availableWorker) {
this.runTask(availableWorker, task);
} else {
this.queue.push(task);
}
});
}
runTask(worker, task) {
this.activeWorkers.add(worker.id);
worker.onmessage = (event) => {
task.resolve(event.data);
this.activeWorkers.delete(worker.id);
this.processQueue();
};
worker.onerror = (error) => {
task.reject(error);
this.activeWorkers.delete(worker.id);
this.processQueue();
};
worker.postMessage(task.data);
}
processQueue() {
if (this.queue.length === 0) return;
const availableWorker = this.workers.find(
w => !this.activeWorkers.has(w.id)
);
if (availableWorker) {
const task = this.queue.shift();
this.runTask(availableWorker, task);
}
}
terminate() {
this.workers.forEach(w => w.terminate());
}
}
// Uso
const pool = new WorkerPool('processor.worker.js', 4);
const results = await Promise.all([
pool.execute({ chunk: data.slice(0, 1000) }),
pool.execute({ chunk: data.slice(1000, 2000) }),
pool.execute({ chunk: data.slice(2000, 3000) }),
pool.execute({ chunk: data.slice(3000, 4000) }),
]);
Limitaciones de los Web Workers
Lo Que NO Está Disponible
// worker.js
// ❌ DOM no está disponible
document.getElementById('algo'); // Error
// ❌ window no existe
window.localStorage; // Error
// ❌ alert, confirm, prompt no existen
alert('test'); // Error
// ✅ Pero puedes usar:
fetch('/api/data'); // OK
setTimeout(() => {}, 1000); // OK
console.log('test'); // OK
crypto.randomUUID(); // OK
self.indexedDB; // OKWorkarounds Comunes
// Si necesitas datos del DOM, extrae antes de enviar al worker
// main.js
const elementosData = Array.from(document.querySelectorAll('.item'))
.map(el => ({
texto: el.textContent,
dataset: { ...el.dataset }
}));
worker.postMessage({ elementos: elementosData });Conclusión
Web Workers son una herramienta poderosa para mantener tus aplicaciones responsivas. Úsalos para:
- Procesamiento de datos pesados
- Parsing de archivos grandes
- Cálculos matemáticos complejos
- Procesamiento de imágenes
- Cualquier operación que tarde más de 50ms
Recuerda las mejores prácticas:
- Usa Transferable Objects para datos grandes
- Implementa pool de workers para tareas paralelas
- Trata errores adecuadamente
- Termina workers cuando no los necesites más
Si estás interesado en más técnicas de performance, recomiendo conferir el artículo Debugging JavaScript: Técnicas Avanzadas con DevTools donde exploramos cómo identificar cuellos de botella de performance en tu aplicación.

