Volver al blog

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 principal

Tipos 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; // OK

Workarounds 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.

¡Vamos a por ello! 🦅

Comentarios (0)

Este artículo aún no tiene comentarios 😢. ¡Sé el primero! 🚀🦅

Añadir comentarios