La línea base de 09-01 reparte la culpa en cuatro frentes, y esta lección ataca el primero: el código tuyo que tarda demasiado en ejecutarse. Son las filas 2, 4, 6 y 7 de la tabla —480 ms de INP al escribir en el buscador, una tarea larga de 1.180 ms al arrancar, 96 ms tirados en recalcular lo mismo y un informe de planificación de 940 ms que deja la pantalla congelada—. Vas a ver cómo trabaja el motor por dentro (lo justo para no sabotearlo, sin caer en micro-optimizaciones inútiles), por qué el coste algorítmico manda sobre cualquier otra consideración, cómo calcular una sola vez lo que se usa muchas con una caché correctamente invalidada, cuándo toca debounce y cuándo throttle, cómo trocear un trabajo largo para que el bucle de eventos respire, y cómo sacar del hilo principal de una vez el trabajo verdaderamente pesado con un Web Worker. Terminaremos con una tabla de mitos desmentidos, porque casi todo lo que se repite sobre «JavaScript rápido» dejó de ser cierto hace quince años.

Contenido

  1. Dónde va el tiempo, exactamente
  2. Cómo trabaja el motor: análisis, interpretación y JIT
  3. Formas ocultas, monomorfismo y desoptimización
  4. Reglas prácticas para no sabotear al motor
  5. El coste que de verdad manda: la complejidad
  6. Del bucle cuadrático al índice con Map
  7. Calcular una sola vez: memoización
  8. Una caché bien invalidada en Tablero
  9. Manejadores frecuentes: debounce y throttle
  10. Trocear el trabajo largo para no bloquear el hilo
  11. Web Workers: otro hilo de verdad
  12. El coste de cruzar la frontera: serialización y transferibles
  13. El informe de planificación, en un worker
  14. Mitos desmentidos
  15. Errores Comunes y Consejos
  16. Ejercicios
  17. Conclusión

  1. Dónde va el tiempo, exactamente

Antes de tocar nada, desglosemos la tarea larga de 1.180 ms del arranque. Con la instrumentación de performance.mark/measure de 09-01 y el panel Performance con CPU 4×, el reparto es este (mediana de 15 arranques, 600 tareas, portátil de referencia):

Fase del arranque Duración ¿Es código tuyo?
Analizar y ejecutar los 28 módulos ES 214 ms Sí, pero es un problema de carga (09-05)
JSON.parse del tablero guardado (182 kB) 41 ms Sí, inevitable
600 × Tarea.desdeJSON 103 ms
agruparPorEtiqueta() 148 ms Sí, y es cuadrático
render() inicial 310 ms Sí, pero es DOM (09-04)
resumen() × 3 13 ms
Diseño y pintado del navegador 244 ms No directamente (09-04)
Resto (eventos, enrutador, repositorio) 107 ms
Total 1.180 ms

Este desglose ya toma dos decisiones por nosotros. La primera: agruparPorEtiqueta() consume 148 ms para hacer algo conceptualmente trivial, y eso huele a algoritmo equivocado. La segunda: micro-optimizar Tarea.desdeJSON para arañar un 10 % de sus 103 ms daría 10 ms, mientras que arreglar el algoritmo de agrupación dará 145. Ese es el orden en el que hay que trabajar.

Pero antes de tocar el algoritmo conviene saber qué hace el motor con tu código, porque hay un puñado de decisiones de escritura que sí importan —y muchísimas que no.

  1. Cómo trabaja el motor: análisis, interpretación y JIT

Un motor moderno (V8 en Chrome y Node, SpiderMonkey en Firefox, JavaScriptCore en Safari) no interpreta tu código línea a línea eternamente. Hace algo más listo: empieza rápido y mejora lo que se usa mucho.

flowchart TD
    A["Código fuente"] --> B["Analizador<br/>(parser)"]
    B -->|"análisis perezoso:<br/>solo la cabecera"| C["Bytecode<br/>(Ignition)"]
    C --> D["Intérprete<br/>arranca YA"]
    D -->|"la función se llama mucho:<br/>se vuelve caliente"| E["Compilador optimizador<br/>(Maglev / TurboFan)"]
    E --> F["Código máquina<br/>optimizado"]
    F -->|"una suposición falla"| G["Desoptimización"]
    G --> D

Cuatro ideas que hay que retener:

El análisis es perezoso. El motor no analiza a fondo el cuerpo de una función hasta que la vas a ejecutar. Solo mira lo justo para saber dónde termina. Por eso un fichero de 2 MB de JavaScript cuesta tiempo aunque no ejecutes casi nada de él: hay que recorrerlo entero. Es un argumento de 09-05, no de aquí, pero explica por qué «descargar menos código» es una optimización de ejecución además de una de red.

Se empieza interpretando. El bytecode se genera rápido y se ejecuta enseguida. Eso favorece el arranque frente a la velocidad máxima.

Lo caliente se compila. Cuando una función se ejecuta muchas veces, o un bucle itera mucho, el motor la promociona a un compilador optimizador que genera código máquina especializado para los tipos que ha visto hasta ahora.

Las suposiciones pueden fallar. Si el código optimizado supuso «este parámetro siempre es un número» y un día llega una cadena, el motor desoptimiza: tira el código máquina, vuelve al intérprete y quizá reoptimiza más tarde. Una desoptimización repetida en un bucle caliente (lo que se llama deopt loop) puede hacer que un fragmento sea diez veces más lento sin que haya cambiado ni una línea.

Y aquí está la consecuencia práctica más importante de todo el apartado: esto que acabas de leer es, para el 95 % del código, información cultural. Sirve para no escribir barbaridades y para entender mediciones raras. No sirve para decidir cómo escribir un filter.

  1. Formas ocultas, monomorfismo y desoptimización

Para que el acceso a propiedades sea rápido, V8 no guarda cada objeto como un diccionario. Asigna a cada objeto una forma oculta (hidden class o map) que describe qué propiedades tiene y en qué orden, y así puede acceder a tarea.horasEstimadas con un desplazamiento fijo en memoria, como en un lenguaje compilado.

Dos objetos comparten forma si se construyeron con las mismas propiedades en el mismo orden:

const a = { id: 1, titulo: 'Rediseñar la sala polivalente' };
const b = { id: 2, titulo: 'Cartelería del taller de serigrafía' };
// a y b comparten forma: acceso rápido a las dos

const c = { titulo: 'Actualizar la web de reservas', id: 3 };
// c tiene OTRA forma (orden distinto), aunque tenga las mismas claves

const d = { id: 4, titulo: 'Inventario de tintas' };
d.revisor = 'Marta';    // añadir una propiedad después CAMBIA la forma de d

Cuando un fragmento de código accede a t.horasEstimadas y todos los objetos que pasan por ahí tienen la misma forma, ese acceso es monomórfico: el motor guarda una caché en línea (inline cache) con el desplazamiento y va directo. Si pasan dos o tres formas distintas es polimórfico (todavía razonable), y si pasan muchas es megamórfico: el motor abandona y hace una búsqueda genérica, mucho más lenta.

Situación Nombre Coste relativo de un acceso
Siempre la misma forma Monomórfico
2–4 formas Polimórfico ~1,5–3×
Más de 4 formas Megamórfico ~10× o más

Lo bueno es que Nómada Tareas ya hace lo correcto sin proponérselo, y el motivo no era el rendimiento: era el diseño. La clase Tarea de 05-02 asigna todos sus campos en el constructor, siempre en el mismo orden, y #estado es privado y solo cambia por cambiarEstado. Resultado: las 600 tareas comparten una única forma y todos los accesos del render son monomórficos.

Compara con la alternativa que habríamos tenido si hubiéramos usado objetos literales sueltos:

// ✗ Genera formas distintas: cuatro variantes según los datos de entrada
function crearTareaSuelta(datos) {
  const t = { id: datos.id, titulo: datos.titulo };
  if (datos.responsable) t.responsable = datos.responsable;   // forma A o B
  if (datos.revisor) t.revisor = datos.revisor;               // forma C o D
  return t;
}

// ✓ Una sola forma para todas: los campos ausentes existen con valor nulo
function crearTareaEstable(datos) {
  return {
    id: datos.id,
    titulo: datos.titulo,
    responsable: datos.responsable ?? null,
    revisor: datos.revisor ?? null
  };
}

La segunda versión no es solo más rápida: es mejor código, porque el objeto tiene una forma predecible y no hay que comprobar la existencia de propiedades por ahí. Y esa es la regla general de este apartado: las prácticas que ayudan al motor coinciden casi siempre con las prácticas que ayudan al lector. Cuando no coincidan, gana el lector, salvo que tengas una medición que diga lo contrario.

Una advertencia clara: no escribas código raro para «ayudar al JIT». Los motores cambian cada seis semanas, y las tretas que circulaban en 2012 hoy son contraproducentes o irrelevantes. Lo que sigue siendo verdad es lo estructural: tipos estables, formas estables, y no cambiar la naturaleza de una variable a mitad de su vida.

  1. Reglas prácticas para no sabotear al motor

Estas son las únicas cinco reglas de este apartado que vale la pena recordar:

Regla Por qué En Nómada Tareas
Inicializa todos los campos en el constructor Una sola forma oculta class Tarea ya lo hace
No cambies el tipo de una variable Evita desoptimizaciones horasEstimadas siempre número, nunca '5'
No mezcles tipos en un array Un array de números es un array «empaquetado»; añadir un objeto lo degrada etiquetas siempre cadenas
No crees agujeros en los arrays arr[1000] = x sobre un array de 3 lo convierte en disperso y lento Usa push y métodos de array
Evita delete sobre objetos Cambia la forma y a menudo degrada a diccionario Asigna null, o usa una copia con rest (04-07)

Y el ejemplo del array degradado, que sí produce una diferencia medible:

// ✗ Array disperso: el motor lo trata como un diccionario
const horas = [];
horas[0] = 12;
horas[599] = 5;            // 598 agujeros: representación lenta
console.log(horas.length); // 600, pero solo 2 elementos reales

// ✓ Array empaquetado y de tipo homogéneo
const horasOk = new Array(600).fill(0);
horasOk[0] = 12;
horasOk[599] = 5;

Nada de esto va a arreglar los 480 ms de INP. Lo que sí lo va a arreglar es el apartado siguiente.

  1. El coste que de verdad manda: la complejidad

En 02-04 viste que dos bucles anidados sobre la misma lista producen un coste cuadrático. Toca formalizarlo, porque es la única categoría de optimización que mejora las cosas por factores de 50× en lugar de por un 15 %.

La complejidad describe cómo crece el trabajo cuando crecen los datos, ignorando constantes:

Notación Nombre 6 elementos 600 60.000 Ejemplo
O(1) Constante 1 1 1 mapa.get(id), arr[i]
O(log n) Logarítmica 3 10 16 Búsqueda binaria en un array ordenado
O(n) Lineal 6 600 60.000 filter, find, reduce, un for
O(n log n) Casi lineal 15 6.000 960.000 sort
O(n²) Cuadrática 36 360.000 3.600.000.000 Un find dentro de un bucle
O(2ⁿ) Exponencial 64 inviable inviable El simulador de repartos de 07-07

Fíjate en la fila cuadrática: al pasar de 6 a 600 tareas —cien veces más datos— el trabajo se multiplica por diez mil. Eso explica por qué Nómada Tareas iba perfecta con el backlog canónico y se arrastra con el de dos años. No es que se haya vuelto lenta: es que el defecto estaba ahí desde el principio y solo se manifiesta con volumen.

La señal de alarma es siempre la misma, y se reconoce a simple vista:

// ✗ Un bucle sobre tareas, y DENTRO una búsqueda sobre tareas → O(n²)
for (const tarea of tareas) {
  const relacionadas = tareas.filter((otra) => compartenEtiqueta(tarea, otra));
  // …
}

for es O(n). filter es O(n). Uno dentro del otro es O(n²). Si al leer un fragmento encuentras un find, filter, includes, indexOf o some dentro de un bucle que recorre la misma colección, has encontrado el problema.

  1. Del bucle cuadrático al índice con Map

Este es el agruparPorEtiqueta() real de Nómada Tareas, el que consume 148 ms:

// js/modelo/informes.js — versión O(n²)

/** Para cada etiqueta, las tareas que la llevan. */
export function agruparPorEtiqueta(tareas) {
  const etiquetas = [...new Set(tareas.flatMap((t) => t.etiquetas))];

  return etiquetas.map((etiqueta) => ({
    etiqueta,
    // ✗ Este filter recorre las 600 tareas... una vez por etiqueta
    tareas: tareas.filter((t) => t.etiquetas.includes(etiqueta))
  }));
}

Con 600 tareas y 6 etiquetas, el filter se ejecuta 6 veces sobre 600 elementos, y dentro de cada uno hay un includes sobre el array de etiquetas de la tarea. Son 3.600 iteraciones externas × el coste del includes. Y en el uso real de la vista se llama por responsable y por etiqueta, con lo que la anidación se multiplica.

La solución es la de 04-05, pero llevada al extremo: recorrer una sola vez y construir un índice.

// js/modelo/informes.js — versión O(n)

/**
 * Para cada etiqueta, las tareas que la llevan.
 * Un solo recorrido: cada tarea se visita una vez, y cada una de sus
 * etiquetas se añade a un Map en tiempo constante.
 */
export function agruparPorEtiqueta(tareas) {
  const indice = new Map();

  for (const tarea of tareas) {
    for (const etiqueta of tarea.etiquetas) {          // ojo: NO es cuadrático
      let lista = indice.get(etiqueta);                // O(1)
      if (lista === undefined) {
        lista = [];
        indice.set(etiqueta, lista);
      }
      lista.push(tarea);                               // O(1) amortizado
    }
  }

  return [...indice].map(([etiqueta, tareas]) => ({ etiqueta, tareas }));
}

El bucle interior no convierte esto en O(n²): recorre las etiquetas de una tarea, que son una o dos, no las 600 tareas. La complejidad total es O(n · e) con e ≈ 1,5, es decir, lineal en la práctica.

Medición comparada, con banco() de 09-01 (5 de calentamiento, 15 repeticiones, CPU 4×):

Variante Mediana Mín p95 Frente a la base
filter anidado — O(n²) 148 ms 141 ms 173 ms
Índice con Map — O(n) 3,1 ms 2,8 ms 4,4 ms 48× más rápido

Cuarenta y ocho veces. Ninguna micro-optimización del mundo produce eso. Y lo más importante: con 6.000 tareas, la versión cuadrática costaría unos 15 segundos y la lineal unos 31 ms. La mejora crece con los datos.

El mismo patrón se aplica a Tablero.buscarPorId, que el controlador delegado de 06-04 llama en cada clic:

// js/modelo/tablero.js
export class Tablero {
  #tareas = [];
  #porId = new Map();          // índice, mantenido junto al array

  agregar(tarea) {
    this.#tareas.push(tarea);
    this.#porId.set(tarea.id, tarea);      // el índice se actualiza AQUÍ
    this.#invalidar();
    return this;
  }

  /** O(1) en lugar de O(n). */
  buscarPorId(id) {
    return this.#porId.get(id) ?? null;
  }
}

Para un clic suelto la diferencia es inapreciable (0,004 ms frente a 0,00008 ms). Pero cuando el CanalTablero de 07-04 recibe un lote de 600 actualizaciones tras una reconexión, esas 600 búsquedas lineales son 360.000 comparaciones: 34,2 ms frente a 0,8 ms. La regla es sencilla y no admite matices: si buscas por clave más de una vez, indexa por clave.

Un aviso de honestidad, sin embargo. Un Map ocupa memoria y hay que mantenerlo sincronizado con el array: si alguien elimina una tarea y olvida el delete del índice, tienes un error de datos y una fuga de memoria (09-03). El índice se justifica cuando hay muchas búsquedas; con seis tareas y un clic cada minuto, find está perfecto.

  1. Calcular una sola vez: memoización

La segunda familia de optimizaciones es no repetir trabajo idéntico. En 03-04 construiste crearContadorMemoizado() con un closure; el patrón general es este:

// js/util/memoizar.js

/**
 * Devuelve una versión de `fn` que guarda los resultados ya calculados.
 * Requisitos: `fn` debe ser PURA (mismo argumento → mismo resultado, sin efectos).
 * @param {Function} fn
 * @param {Function} [clave] Cómo convertir los argumentos en clave de caché.
 */
export function memoizar(fn, clave = (...args) => args.join('|')) {
  const cache = new Map();

  return function (...args) {
    const k = clave(...args);
    if (cache.has(k)) return cache.get(k);      // acierto: 0 trabajo

    const valor = fn.apply(this, args);
    cache.set(k, valor);
    return valor;
  };
}

Y su uso natural en Nómada Tareas: el formateo de fechas con Intl de util/formato.js, que es sorprendentemente caro porque crear un Intl.DateTimeFormat implica cargar datos de localización.

// js/util/formato.js
import { memoizar } from './memoizar.js';

const formateador = new Intl.DateTimeFormat('es-ES', { dateStyle: 'long' });

/** '2026-09-05' → '5 de septiembre de 2026' */
export const formatearFecha = memoizar((iso) =>
  formateador.format(new Date(`${iso}T00:00:00`))
);

Con 600 tareas y unas 200 fechas distintas, memoizar convierte 600 formateos en 200: 4,6 ms → 1,7 ms. Poco, pero gratis y sin riesgo.

Ahora las tres condiciones que hay que verificar antes de memoizar cualquier cosa, porque memoizar mal produce errores muy difíciles de encontrar:

  1. La función debe ser pura. Si depende de algo que cambia (la hora, el tablero, Math.random), la caché devuelve mentiras.
  2. La clave debe capturar todas las entradas. formatearFecha(iso) está bien; una función que además dependiera del idioma necesitaría el idioma en la clave.
  3. La caché debe tener límite o invalidación. Una Map que crece indefinidamente es una fuga de memoria de manual (09-03). Para claves acotadas —200 fechas— no hay problema; para claves ilimitadas hay que poner un tope o usar WeakMap.

Memoizar formatearFecha cumple las tres. Memoizar resumen() no cumple la primera, y ahí está el caso interesante.

  1. Una caché bien invalidada en Tablero

Tablero.resumen(hoy) recorre las tareas para calcular total, abiertas, horas totales, horas abiertas, vencidas y esfuerzo. Con 600 tareas cuesta 1,4 ms. La vista lo llama tres veces por render (la barra de resumen, la barra lateral y el exportador), y un filtrado en vivo de 23 pulsaciones dispara 23 renders: 1,4 × 3 × 23 = 96 ms tirados a la basura recalculando exactamente lo mismo.

resumen() no es pura: depende del estado interno del tablero. Pero es determinista mientras el tablero no cambie, y esa es justo la condición que permite una caché con invalidación explícita.

La técnica correcta es un contador de versión: cada mutación lo incrementa, y la caché guarda con qué versión se calculó.

// js/modelo/tablero.js
export class Tablero {
  #tareas = [];
  #porId = new Map();
  #version = 0;                 // se incrementa en CADA mutación
  #cacheResumen = null;         // { version, hoy, valor }

  // ─────────────── mutaciones: todas invalidan ───────────────

  #invalidar() {
    this.#version += 1;
  }

  agregar(tarea) {
    this.#tareas.push(tarea);
    this.#porId.set(tarea.id, tarea);
    this.#invalidar();
    return this;
  }

  eliminar(id) {
    const i = this.#tareas.findIndex((t) => t.id === id);
    if (i === -1) return false;
    this.#tareas.splice(i, 1);
    this.#porId.delete(id);       // el índice, sincronizado
    this.#invalidar();
    return true;
  }

  cambiarEstado(id, destino) {
    const tarea = this.buscarPorId(id);
    if (tarea === null) throw new ErrorDeDatos(`No existe la tarea ${id}`);
    tarea.cambiarEstado(destino);  // valida la R6 y puede lanzar
    this.#invalidar();             // ← si esto falta, la caché miente
    return tarea;
  }

  // ─────────────── lectura con caché ───────────────

  /**
   * Resumen del tablero. Se recalcula solo si el tablero ha cambiado
   * o si se pide para otra fecha.
   */
  resumen(hoy = HOY) {
    const c = this.#cacheResumen;
    if (c !== null && c.version === this.#version && c.hoy === hoy) {
      return c.valor;                                  // acierto: O(1)
    }

    const valor = this.#calcularResumen(hoy);
    this.#cacheResumen = { version: this.#version, hoy, valor };
    return valor;
  }

  #calcularResumen(hoy) {
    let horasTotales = 0, horasAbiertas = 0, abiertas = 0, vencidas = 0, esfuerzo = 0;

    for (const t of this.#tareas) {                    // UN solo recorrido
      horasTotales += t.horasEstimadas;
      esfuerzo += t.esfuerzo();
      if (t.estaAbierta()) {
        abiertas += 1;
        horasAbiertas += t.horasEstimadas;
        if (t.estaVencida(hoy)) vencidas += 1;
      }
    }

    // Object.freeze: la caché devuelve SIEMPRE el mismo objeto; que nadie lo mute
    return Object.freeze({
      total: this.#tareas.length, abiertas, horasTotales, horasAbiertas, vencidas, esfuerzo
    });
  }

  horasAbiertas(hoy = HOY) {
    return this.resumen(hoy).horasAbiertas;            // reutiliza la caché
  }
}

Cinco decisiones de ese código que son las que separan una caché correcta de un generador de errores sutiles:

  • La clave incluye hoy. Sin eso, pedir el resumen para otra fecha devolvería el de ayer. Es el fallo de invalidación más común: olvidar un parámetro.
  • #invalidar() está en todas las mutaciones, incluida cambiarEstado, que no toca el array sino el interior de una tarea. Si una sola ruta de mutación se olvida, la caché miente y el error aparecerá días después.
  • El objeto devuelto está congelado. Como la caché entrega siempre la misma referencia, si alguien hiciera r.vencidas = 0 corrompería el estado de todos los futuros lectores. Object.freeze lo impide (y en modo estricto, lanza).
  • Un solo recorrido en lugar de seis. La versión anterior encadenaba filter y reduce; recorrer una vez acumulando todo baja el coste incluso en los fallos de caché.
  • horasAbiertas se apoya en resumen. Una sola fuente de verdad y una sola caché.

Y la prueba, porque una caché sin prueba de invalidación es una bomba de relojería (08-03):

// test/modelo/tablero-cache.test.js
describe('caché de resumen', () => {
  test('devuelve el mismo objeto mientras el tablero no cambie', () => {
    const tablero = new Tablero('Taller Nómada', crearBacklog());
    expect(tablero.resumen(HOY)).toBe(tablero.resumen(HOY));       // toBe: misma referencia
  });

  test('se invalida al cambiar el estado de una tarea', () => {
    const tablero = new Tablero('Taller Nómada', crearBacklog());
    expect(tablero.resumen(HOY).horasAbiertas).toBe(45);

    tablero.cambiarEstado(3, 'en-curso');   // 'Actualizar la web de reservas', 14 h
    tablero.cambiarEstado(3, 'hecha');
    expect(tablero.resumen(HOY).horasAbiertas).toBe(31);           // 45 − 14
  });

  test('se recalcula para otra fecha', () => {
    const tablero = new Tablero('Taller Nómada', crearBacklog());
    expect(tablero.resumen('2026-09-20').vencidas).toBe(1);
    expect(tablero.resumen('2026-12-31').vencidas).toBe(5);        // otra fecha, otro valor
  });
});

Resultado de la medición 6 de la línea base:

Medida Antes Después
resumen() por llamada (fallo de caché) 1,4 ms 0,9 ms (un recorrido en vez de seis)
resumen() por llamada (acierto) 1,4 ms 0,0008 ms
Total en un filtrado de 23 pulsaciones 96 ms 1,5 ms

  1. Manejadores frecuentes: debounce y throttle

Los 96 ms anteriores solo eran la mitad del problema del buscador. La otra mitad es que hay 23 renders donde debería haber 2. Ningún cálculo es tan rápido como el cálculo que no se hace.

Ya usaste debounce en 06-07 y 07-01, y en 06-07 se avisó de que no hay que confundirlo con throttle. Aquí va la distinción completa.

flowchart TB
    subgraph E["Eventos: el usuario escribe 'serigrafía'"]
        direction LR
        e1["s"] --- e2["e"] --- e3["r"] --- e4["i"] --- e5["g"] --- e6["..."] --- e7["a"]
    end
    E --> D["debounce(300)<br/>espera a que PAREN"]
    E --> T["throttle(100)<br/>como mucho 1 cada 100 ms"]
    D --> D1["1 ejecución<br/>al terminar"]
    T --> T1["ejecuciones regulares<br/>durante el proceso"]
  • Debounce («amortiguar»): pospone la ejecución hasta que hayan pasado N ms sin eventos nuevos. Si el usuario sigue escribiendo, se sigue posponiendo. Ejecuta una vez, al final.
  • Throttle («estrangular»): ejecuta como mucho una vez cada N ms, descartando las llamadas intermedias. Ejecuta regularmente, durante.
Criterio Debounce Throttle
Cuándo ejecuta Al terminar la ráfaga A intervalos, durante la ráfaga
¿Interesa el resultado intermedio? No
Casos típicos Buscador, autoguardado, validación remota, resize final scroll, mousemove, pointermove, barra de progreso
Riesgo Si N es alto, se siente lento Ejecuta más veces: hay que asegurar que cada ejecución sea barata
En Nómada Tareas Filtro del buscador (300 ms), guardado en localStorage (300 ms) Cabecera pegajosa al desplazar (100 ms), posición de la ventana virtual (09-04)

Y la implementación completa de util/tiempo.js, ampliando el debounce que ya tenías:

// js/util/tiempo.js

/**
 * Ejecuta `fn` solo cuando han pasado `espera` ms desde la ÚLTIMA llamada.
 * @param {Function} fn
 * @param {number} espera
 * @returns {Function} con método .cancelar() para limpieza (09-03)
 */
export function debounce(fn, espera = 300) {
  let temporizador = null;

  const envuelta = function (...args) {
    clearTimeout(temporizador);
    temporizador = setTimeout(() => fn.apply(this, args), espera);
  };

  envuelta.cancelar = () => clearTimeout(temporizador);   // imprescindible al destruir la vista
  return envuelta;
}

/**
 * Ejecuta `fn` como mucho una vez cada `intervalo` ms.
 * Ejecuta de inmediato la primera vez (borde de entrada) y una última
 * al final de la ráfaga (borde de salida), que es lo que casi siempre se quiere.
 */
export function throttle(fn, intervalo = 100) {
  let ultima = 0;
  let pendiente = null;

  const envuelta = function (...args) {
    const ahora = performance.now();
    const restante = intervalo - (ahora - ultima);

    if (restante <= 0) {                 // ha pasado el intervalo: ejecuta ya
      clearTimeout(pendiente);
      pendiente = null;
      ultima = ahora;
      fn.apply(this, args);
    } else if (pendiente === null) {     // programa la del borde de salida
      pendiente = setTimeout(() => {
        ultima = performance.now();
        pendiente = null;
        fn.apply(this, args);
      }, restante);
    }
  };

  envuelta.cancelar = () => { clearTimeout(pendiente); pendiente = null; };
  return envuelta;
}

El borde de salida del throttle es el detalle que la mayoría de implementaciones caseras olvida: sin él, si el usuario deja de desplazarse justo después de una ejecución, la última posición nunca se procesa y la interfaz se queda desactualizada.

Aplicado al controlador de 06-04:

// js/vista/controlador.js
import { debounce, throttle } from '../util/tiempo.js';

// Buscador: solo interesa el resultado final → debounce
const filtrar = debounce((texto) => vista.actualizar({ filtros: { texto } }), 300);
$('#buscador').addEventListener('input', (e) => filtrar(e.target.value));

// Cabecera pegajosa: interesa el resultado durante el desplazamiento → throttle
const actualizarCabecera = throttle(() => {
  document.body.classList.toggle('desplazado', window.scrollY > 120);
}, 100);
window.addEventListener('scroll', actualizarCabecera, { passive: true });

Ese { passive: true } no es decorativo: le promete al navegador que el manejador no llamará a preventDefault(), y así puede desplazar sin esperar a que tu código termine. Para scroll, touchstart y wheel es prácticamente obligatorio.

Medición del buscador con las dos optimizaciones (caché + debounce), escribiendo «serigrafía» a velocidad normal:

Medida Antes Con caché Con caché + debounce
Renders disparados 23 23 2
Tiempo total de JavaScript 412 ms 316 ms 28 ms
INP medido 480 ms 372 ms 96 ms

El INP ya cumple el objetivo de ≤ 200 ms. Y fíjate en el orden de las columnas: la caché sola no bastaba, porque el problema dominante no era el cálculo, sino el render (que arreglará 09-04). El debounce ha funcionado porque elimina renders enteros.

Un último matiz sobre scroll: para trabajo que afecta a lo que se ve, requestAnimationFrame suele ser mejor que throttle con un número fijo de milisegundos, porque se sincroniza con el ritmo real de la pantalla. Eso es materia de 09-04.

  1. Trocear el trabajo largo para no bloquear el hilo

En 05-07 quedó establecido: JavaScript tiene un solo hilo para ejecutar tu código y pintar la interfaz; un bucle pesado la congela, y await no lo arregla porque no cede el hilo si no hay nada asíncrono de verdad detrás. La solución que se anunció allí era trocear.

El patrón consiste en partir el trabajo en lotes y ceder el control al navegador entre lote y lote, para que pueda atender eventos y pintar.

// js/util/lotes.js

/**
 * Cede el hilo al navegador para que pinte y atienda eventos.
 * Se elige la mejor API disponible en el navegador actual.
 */
export function ceder() {
  // 1 · Lo mejor si existe: cede sin perder la prioridad de la tarea
  if (typeof scheduler !== 'undefined' && typeof scheduler.yield === 'function') {
    return scheduler.yield();
  }
  // 2 · Respaldo universal: una macrotarea (05-07). NO vale una microtarea
  return new Promise((resolve) => setTimeout(resolve, 0));
}

/**
 * Procesa `elementos` en lotes, cediendo el hilo entre ellos.
 * @param {Iterable} elementos
 * @param {Function} trabajo        Qué hacer con cada elemento.
 * @param {object} opciones
 * @param {number} opciones.presupuesto  ms de trabajo antes de ceder.
 * @param {AbortSignal} opciones.signal  Para cancelar (07-03).
 * @param {Function} opciones.alProgresar
 */
export async function porLotes(elementos, trabajo, {
  presupuesto = 8, signal, alProgresar
} = {}) {
  const lista = [...elementos];
  let inicio = performance.now();

  for (let i = 0; i < lista.length; i += 1) {
    signal?.throwIfAborted();          // cancelación cooperativa
    trabajo(lista[i], i);

    // Cede cuando se agota el presupuesto de este fotograma, no cada N elementos:
    // así se adapta solo a máquinas rápidas y lentas
    if (performance.now() - inicio >= presupuesto) {
      alProgresar?.(i + 1, lista.length);
      await ceder();
      inicio = performance.now();
    }
  }
  alProgresar?.(lista.length, lista.length);
}

Tres detalles importantes:

Ceder por tiempo, no por número de elementos. if (i % 100 === 0) es lo que se ve en todas partes, y está mal: en un portátil rápido cede demasiado (y va lento por exceso de idas y venidas), y en un móvil lento cede demasiado poco (y bloquea igualmente). Medir el presupuesto se adapta solo.

setTimeout(0) sí cede; await Promise.resolve() no. Es exactamente la distinción entre macrotareas y microtareas de 05-07: las microtareas se vacían antes de que el navegador pinte, así que ceder a una microtarea no permite pintar nada. Este es uno de los errores más frecuentes al implementar troceado.

El presupuesto de 8 ms sale del apartado 2 de 09-01: menos de 10 ms de JavaScript por fotograma.

Y las tres formas de ceder, comparadas:

API Cuándo ejecuta la continuación Uso adecuado Cuidado
setTimeout(fn, 0) Siguiente macrotarea (mínimo real ~4 ms con anidamiento) Respaldo universal Compite con otras tareas; puede colarse trabajo por delante
requestAnimationFrame Justo antes del siguiente pintado Trabajo visual (09-04) No se ejecuta si la pestaña está oculta
requestIdleCallback Cuando el navegador está ocioso Trabajo no urgente: precalcular, enviar analítica Puede tardar mucho, o no llegar nunca si hay actividad
scheduler.yield() Enseguida, conservando la prioridad La opción moderna para trocear Disponibilidad todavía desigual: usa respaldo

requestIdleCallback merece un ejemplo, porque es la herramienta correcta para el trabajo que puede esperar:

// Precalcular el índice de etiquetas cuando el navegador no tenga nada que hacer
requestIdleCallback((plazo) => {
  // plazo.timeRemaining() dice cuántos ms quedan del hueco de inactividad
  if (plazo.timeRemaining() > 5 || plazo.didTimeout) {
    indiceEtiquetas = agruparPorEtiqueta([...tablero]);
  }
}, { timeout: 2000 });   // si en 2 s no hay hueco, ejecútalo igualmente

Aplicando porLotes a la reconstrucción del tablero tras cargar 600 tareas:

Medida En un bucle Por lotes de 8 ms
Tarea más larga 1.180 ms 41 ms
Tiempo total hasta terminar 1.180 ms 1.310 ms
¿Responde a un clic mientras tanto? No Sí, en < 50 ms
¿Se puede mostrar progreso? No

Lee bien la segunda fila: el trabajo total ha aumentado un 11 %, por el coste de ir y volver del bucle de eventos. Y aun así es una mejora enorme, porque el rendimiento percibido no es el tiempo total, sino el tiempo durante el cual el usuario no puede hacer nada. Esta es una de las lecciones más contraintuitivas del módulo.

Pero trocear tiene un límite: si el trabajo son 940 ms de cálculo puro, trocearlo lo reparte pero sigue robando 940 ms al hilo que tiene que pintar. Para eso hace falta otro hilo.

  1. Web Workers: otro hilo de verdad

Un Web Worker ejecuta JavaScript en un hilo separado del principal. No es una simulación ni un truco de planificación: es paralelismo real, en otro núcleo si lo hay.

A cambio, vive en un mundo aparte:

Tiene No tiene
Su propio hilo y su propio bucle de eventos Acceso al DOM (nada de document)
fetch, WebSocket, IndexedDB, caches window, localStorage, alert
postMessage, import de módulos ES Variables compartidas con el hilo principal
performance, temporizadores, crypto Acceso directo a tus objetos

Esa última fila es la clave: worker y hilo principal no comparten memoria. Se comunican pasándose mensajes, y los mensajes se copian.

flowchart LR
    subgraph P["Hilo principal"]
        A["app.js"] --> B["DOM · pintado · eventos"]
    end
    subgraph W["Worker"]
        C["planificador.worker.js<br/>cálculo pesado"]
    end
    A -->|"postMessage(datos)<br/>copia estructurada"| C
    C -->|"postMessage(resultado)<br/>copia estructurada"| A

El esqueleto mínimo:

// js/planificacion/planificador.worker.js
// Un worker de tipo módulo puede importar como cualquier otro módulo (05-04)
import { calcularInforme } from './informe.js';

self.addEventListener('message', (evento) => {
  const { tipo, id, datos } = evento.data;

  if (tipo !== 'informe') return;

  try {
    const resultado = calcularInforme(datos);        // 940 ms de cálculo… en OTRO hilo
    self.postMessage({ tipo: 'informe:ok', id, resultado });
  } catch (error) {
    // Los objetos Error sí sobreviven a la copia estructurada, pero no las clases propias
    self.postMessage({ tipo: 'informe:error', id, mensaje: error.message });
  }
});
// js/planificacion/cliente-planificador.js

/** Envuelve el worker en una API de promesas, que es como se quiere usar (05-06). */
export class Planificador {
  #worker = null;
  #pendientes = new Map();      // id → { resolve, reject }
  #siguienteId = 0;

  #asegurarWorker() {
    if (this.#worker !== null) return this.#worker;

    this.#worker = new Worker(
      new URL('./planificador.worker.js', import.meta.url),
      { type: 'module' }                                 // imprescindible para usar import
    );

    this.#worker.addEventListener('message', ({ data }) => {
      const pendiente = this.#pendientes.get(data.id);
      if (pendiente === undefined) return;
      this.#pendientes.delete(data.id);                  // ← si falta, hay fuga (09-03)

      if (data.tipo === 'informe:ok') pendiente.resolve(data.resultado);
      else pendiente.reject(new Error(data.mensaje));
    });

    // Errores del propio worker (un import roto, una excepción no capturada)
    this.#worker.addEventListener('error', (e) => {
      for (const { reject } of this.#pendientes.values()) reject(new Error(e.message));
      this.#pendientes.clear();
    });

    return this.#worker;
  }

  /** @returns {Promise<object>} el informe calculado fuera del hilo principal. */
  informe(datos) {
    const worker = this.#asegurarWorker();
    const id = this.#siguienteId += 1;

    return new Promise((resolve, reject) => {
      this.#pendientes.set(id, { resolve, reject });
      worker.postMessage({ tipo: 'informe', id, datos });
    });
  }

  /** Limpieza explícita: un worker vivo retiene memoria y un hilo (09-03). */
  destruir() {
    this.#worker?.terminate();
    this.#worker = null;
    this.#pendientes.clear();
  }
}

El id por mensaje no es un lujo: sin él no puedes tener dos peticiones en vuelo y emparejar cada respuesta con su promesa. Es el mismo problema que resuelve el id de una petición HTTP.

El new URL(..., import.meta.url) es obligatorio si usas un empaquetador (09-05): es el patrón que Vite y compañía reconocen para incluir el worker en la compilación. Una ruta en cadena de texto no funcionaría tras el empaquetado.

  1. El coste de cruzar la frontera: serialización y transferibles

Aquí está la trampa de los workers, y por qué a veces no compensan. Cuando llamas a postMessage(datos), el navegador hace una clonación estructurada de los datos —el mismo algoritmo de structuredClone que viste en 04-08— y entrega la copia al otro hilo. Copiar cuesta tiempo, y ese tiempo se paga en el hilo emisor.

Primero, la comparación de mecanismos de copia sobre las 600 tareas (unos 182 kB de JSON):

Mecanismo Mediana Qué preserva
JSON.parse(JSON.stringify(x)) 8,4 ms Solo tipos JSON: pierde undefined, Date, Map, Set, funciones
structuredClone(x) 3,1 ms Date, Map, Set, RegExp, ArrayBuffer, referencias cíclicas
postMessage (ida) 3,3 ms Igual que structuredClone

structuredClone es más rápido y más fiel: es la respuesta correcta a «cómo copio en profundidad», y el viaje por JSON de 04-08 queda relegado a cuando necesitas específicamente texto (para localStorage o para la red).

Una limitación crucial que hay que tener siempre presente: la clonación estructurada no conserva las clases. Un objeto Tarea llega al worker como un objeto plano, sin métodos ni campos privados:

// ✗ Esto falla en el worker
worker.postMessage({ datos: [...tablero] });     // instancias de Tarea
// dentro del worker: datos[0].estaVencida is not a function

// ✓ Serializa a datos planos con el toJSON que ya tienes (05-02), y reconstruye si hace falta
worker.postMessage({ datos: [...tablero].map((t) => t.toJSON()) });

Segundo, los objetos transferibles. Algunos tipos —ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas— pueden transferirse en lugar de copiarse: la memoria cambia de dueño, sin copia, en tiempo prácticamente nulo. El precio es que el emisor pierde el acceso (el búfer queda desvinculado).

// Transferir un búfer de 4,8 MB con los datos numéricos del informe
const buffer = new Float64Array(600 * 5).buffer;

worker.postMessage({ tipo: 'calcular', buffer }, [buffer]);   // ← 2.º argumento: la lista
console.log(buffer.byteLength);   // 0 ← ya no es tuyo
Envío de un ArrayBuffer de 4,8 MB Coste en el hilo emisor
Copiado (postMessage(buffer)) 6,2 ms
Transferido (postMessage(buffer, [buffer])) 0,08 ms

La regla operativa completa para decidir si un worker compensa:

Un worker compensa cuando el tiempo de cálculo es mucho mayor que el de serialización. Si vas a calcular 5 ms y serializar 3 ms de ida y 3 de vuelta, no lo hagas. Si vas a calcular 940 ms, hazlo sin dudar.

  1. El informe de planificación, en un worker

El informe trimestral de Taller Nómada agrupa las 600 tareas por responsable, semana y etiqueta, calcula percentiles de carga y busca la mejor distribución de horas. Cuesta 940 ms en el hilo principal. Mientras se calcula, Marta ve la pantalla congelada: no responde a clics, no se desplaza, no pinta nada. Es exactamente el escenario de 05-07.

Con el Planificador del apartado 11:

// js/vista/controlador.js
import { Planificador } from '../planificacion/cliente-planificador.js';

const planificador = new Planificador();

$('#generar-informe').addEventListener('click', async () => {
  const boton = $('#generar-informe');
  boton.disabled = true;
  boton.textContent = 'Calculando…';           // la interfaz SIGUE VIVA

  try {
    // toJSON: datos planos, porque las clases no sobreviven a la copia (apartado 12)
    const informe = await planificador.informe([...tablero].map((t) => t.toJSON()));
    mostrarInforme(informe);
  } catch (error) {
    mostrarError(`No se ha podido generar el informe: ${error.message}`);
  } finally {
    boton.disabled = false;
    boton.textContent = 'Generar informe';
  }
});

Fíjate en lo que ha cambiado y lo que no: el código de la vista es prácticamente idéntico a como sería con una función asíncrona normal. Envolver el worker en promesas (05-06) hace que la complejidad del paso de mensajes quede encerrada en Planificador y no se propague.

Medición del antes y el después (CPU 4×, mediana de 9 ejecuciones, pulsando el botón y desplazando la lista mientras se calcula):

Medida Hilo principal Web Worker
Tiempo total hasta ver el informe 940 ms 1.012 ms (+8 %)
Bloqueo del hilo principal 940 ms 52 ms (serializar ida + vuelta)
Tarea más larga en el hilo principal 940 ms 31 ms
INP durante el cálculo 620 ms 28 ms
Fotogramas perdidos al desplazar 56 0
¿Se puede cancelar? No Sí (terminate())

Otra vez el patrón contraintuitivo: el trabajo total ha aumentado y la experiencia ha mejorado radicalmente. Ochenta milisegundos de coste de mensajería a cambio de 888 ms en los que la aplicación deja de estar muerta. Y una capacidad nueva que antes era imposible: cancelar, porque terminate() mata el hilo de verdad, cosa que un bucle en el hilo principal no permite hacer de ninguna manera.

Y la comparación honesta con las alternativas del módulo, para el mismo problema:

Enfoque Bloqueo Total Complejidad añadida Veredicto
Bucle directo 940 ms 940 ms Ninguna Inaceptable
porLotes con cesión 41 ms 1.070 ms Baja Aceptable si no hay worker
Web Worker 52 ms 1.012 ms Media La solución
WebAssembly (07-07) 940 ms 380 ms Alta No resuelve el bloqueo por sí solo

La última fila cierra la discusión de 07-07: Wasm hacía el cálculo 2,5× más rápido, pero seguía bloqueando el hilo principal, porque el problema nunca fue la velocidad bruta sino quién ocupa el hilo que pinta. Un worker en JavaScript normal gana a Wasm en el hilo principal. Y si algún día hiciera falta lo mejor de los dos, un módulo Wasm dentro de un worker es una combinación perfectamente normal.

  1. Mitos desmentidos

Buena parte de lo que se repite sobre «JavaScript rápido» es folclore de hace quince años, cuando los motores no tenían JIT. Aquí está medido, en el portátil de referencia, sobre las 600 tareas, con banco() y 15 repeticiones.

Mito Realidad medida Veredicto
«for es mucho más rápido que forEach/map» 0,038 ms frente a 0,061 ms sobre 600 elementos: 23 microsegundos de diferencia Irrelevante. Escribe lo que se lea mejor
«++i es más rápido que i++» Idénticos; el compilador genera lo mismo cuando no usas el valor Falso
«Concatenar cadenas con + es lento; usa array.join» Cierto en 2005. Hoy los motores optimizan la concatenación con cuerdas (ropes): 600 concatenaciones, 0,09 ms frente a 0,11 ms Falso hoy
«delete es igual que asignar undefined» delete cambia la forma oculta y puede degradar el objeto a diccionario Cierto que son distintos, y delete es peor
«Las variables locales son más rápidas que las globales» Cierto, pero la diferencia es de nanosegundos salvo en bucles muy calientes Irrelevante salvo medición
«try/catch impide la optimización» Cierto hasta ~2015. Hoy TurboFan optimiza funciones con try/catch sin problema Obsoleto
«Las funciones flecha son más rápidas» Idénticas en tiempo de ejecución; la diferencia es semántica (this), no de velocidad Falso
«Hay que cachear array.length en el for» El motor lo hace solo desde hace más de una década Obsoleto
«Map siempre es más rápido que un objeto» Para claves de cadena y pocas entradas, un objeto puede ganar. Map gana en inserción/borrado frecuentes y claves no textuales Depende: mide
«Menos líneas es más rápido» Sin relación alguna Falso

La conclusión práctica no es «nada importa»: es que el eje que importa cambió de sitio. Ya no ganas nada eligiendo entre for y forEach; ganas 48× eligiendo entre O(n²) y O(n), 130× no repitiendo un cálculo, y todo el rendimiento percibido decidiendo quién ocupa el hilo principal.

La legibilidad gana por defecto. Solo una medición concreta, sobre datos reales, justifica escribir código menos claro. Y cuando lo hagas, deja un comentario con el número que lo justificó y la fecha, porque dentro de dos años el motor habrá cambiado.

Errores Comunes y Consejos

  • Micro-optimizar antes de mirar la complejidad. Cambiar forEach por for en un algoritmo cuadrático es pintar una pared que se está cayendo.
  • No detectar el find dentro del bucle. Es la causa número uno de lentitud con volumen, y se ve a simple vista una vez sabes buscarla.
  • Memoizar una función impura. Si depende del tablero, del reloj o del azar, la caché devuelve mentiras y el error aparecerá muy lejos de su causa.
  • Olvidar una ruta de invalidación. cambiarEstado no toca el array de tareas, pero cambia el resultado de resumen. Si no invalida, la caché miente.
  • Dejar la clave de caché incompleta. Olvidar hoy en la clave del resumen es un error de datos, no de rendimiento, y de los que tardan semanas en descubrirse.
  • Cachés sin límite. Un Map que crece con cada clave nueva es una fuga (09-03).
  • Confundir debounce con throttle. Debounce en scroll deja la interfaz congelada hasta que el usuario para; throttle en un buscador dispara peticiones a mitad de palabra.
  • Poner un debounce demasiado largo. Por encima de ~400 ms se percibe como lentitud. 250–300 ms es el rango habitual para un buscador.
  • Crear la función amortiguada dentro del manejador. input con debounce(fn, 300) creado en cada evento no amortigua nada: cada llamada tiene su propio temporizador. Créala una vez, fuera.
  • Ceder con await Promise.resolve(). Es una microtarea: se ejecuta antes de pintar. No cede nada (05-07).
  • Trocear por número de elementos en vez de por tiempo. No se adapta a máquinas distintas. Mide el presupuesto.
  • Meter en un worker un cálculo de 5 ms. La serialización costará más que el cálculo.
  • Enviar instancias de clase por postMessage. Llegan como objetos planos, sin métodos. Usa toJSON().
  • Crear un worker por cada operación. Arrancarlo cuesta entre 10 y 40 ms. Reutiliza uno, o un pequeño grupo.
  • No llamar a terminate(). Un worker vivo retiene su hilo y toda su memoria (09-03).
  • Consejo: antes de optimizar un cálculo, pregúntate si hace falta hacerlo. El cálculo más rápido es el que no se ejecuta: caché, debounce o cargarlo bajo demanda (09-05).
  • Consejo: escribe una prueba que fije la complejidad. Comprobar que el tiempo con 1.200 tareas es menos del triple que con 600 detecta una regresión a cuadrático.
  • Consejo: envuelve siempre el worker en una API de promesas. El paso de mensajes es un detalle de implementación que no debe contaminar la vista.

Ejercicios

Ejercicio 1 — Cazar el cuadrático. Este código calcula, para cada tarea, cuántas otras comparten responsable y prioridad. Con 600 tareas tarda 214 ms.

export function companerasDeCarga(tareas) {
  return tareas.map((t) => ({
    id: t.id,
    companeras: tareas.filter((o) =>
      o.id !== t.id && o.responsable === t.responsable && o.prioridad === t.prioridad
    ).length
  }));
}

Indica (a) la complejidad actual y cuántas comparaciones hace con 600 tareas, (b) una reescritura O(n) con Map, (c) qué complejidad tiene la nueva versión, y (d) qué mejora esperas si el tablero creciera a 6.000 tareas.

Ejercicio 2 — Caché con invalidación por responsable. Tablero necesita cargaDe(responsable), que devuelva las horas abiertas de esa persona (los canónicos: Iván 25 h, Lucía 14 h, Marta 6 h). Se llama una vez por tarjeta durante el render, es decir, 600 veces. Escribe una versión con caché correctamente invalidada, indica qué debe formar parte de la clave, escribe las pruebas de invalidación que garanticen que no miente, y calcula la mejora esperada sabiendo que un cálculo cuesta 0,9 ms.

Ejercicio 3 — ¿Worker, lotes o nada? Para cada uno de estos cuatro trabajos de Nómada Tareas, decide entre «hilo principal directo», «porLotes con cesión» y «Web Worker», justificando con el criterio de coste de serialización frente a coste de cálculo. Datos: el tablero serializado son 182 kB, cuya clonación cuesta 3,3 ms por trayecto.

  1. Validar el formulario de nueva tarea (10 reglas, 0,05 ms).
  2. Reconstruir 600 instancias de Tarea desde localStorage al arrancar (103 ms).
  3. Calcular el informe trimestral (940 ms).
  4. Recalcular el resumen tras cada cambio de estado (0,9 ms).

Soluciones

Solución 1

(a) Es O(n²). El map recorre las 600 tareas y, para cada una, el filter recorre las 600: 360.000 comparaciones, cada una con tres condiciones. De ahí los 214 ms.

(b) La observación clave: el filter siempre busca lo mismo, la combinación responsable + prioridad. Se puede contar una vez cuántas tareas hay de cada combinación y después consultar en tiempo constante.

export function companerasDeCarga(tareas) {
  // 1 · Un recorrido: contar cuántas tareas hay por combinación → O(n)
  const conteo = new Map();
  for (const t of tareas) {
    const clave = `${t.responsable}|${t.prioridad}`;
    conteo.set(clave, (conteo.get(clave) ?? 0) + 1);
  }

  // 2 · Otro recorrido: consultar el conteo y restarse uno mismo → O(n)
  return tareas.map((t) => ({
    id: t.id,
    companeras: conteo.get(`${t.responsable}|${t.prioridad}`) - 1
  }));
}

El - 1 sustituye al o.id !== t.id del original: cada tarea se ha contado a sí misma.

(c) O(n): dos recorridos completos y accesos O(1) al Map. Medido: 214 ms → 4,2 ms, unas 51× más rápido.

(d) Con 6.000 tareas, la versión cuadrática haría 36 millones de comparaciones: en torno a 21 segundos (100× los 214 ms, porque el trabajo crece con el cuadrado). La lineal haría 12.000 operaciones: unos 42 ms (10× los 4,2 ms). La mejora pasaría de 51× a unas 500×. Ese es el punto esencial de la complejidad: el beneficio de arreglarla crece con los datos, mientras que el de una micro-optimización se queda donde está.

Solución 2

La clave debe incluir el responsable y hoy (porque «abierta» no depende de la fecha, pero si mañana quisiéramos filtrar por vencimiento sí; incluirlo desde el principio evita el error clásico), y la caché entera debe invalidarse con el contador de versión del tablero.

// js/modelo/tablero.js
export class Tablero {
  #version = 0;
  #cacheCarga = { version: -1, hoy: null, valores: new Map() };

  /** Horas abiertas de una persona. Cacheado por responsable. */
  cargaDe(responsable, hoy = HOY) {
    const c = this.#cacheCarga;

    // Un cambio de versión o de fecha invalida el mapa ENTERO, no una entrada
    if (c.version !== this.#version || c.hoy !== hoy) {
      c.version = this.#version;
      c.hoy = hoy;
      c.valores = new Map();
    }

    if (c.valores.has(responsable)) return c.valores.get(responsable);

    let horas = 0;
    for (const t of this.#tareas) {
      if (t.responsable === responsable && t.estaAbierta()) horas += t.horasEstimadas;
    }
    c.valores.set(responsable, horas);
    return horas;
  }
}

Vaciar el Map entero al cambiar la versión es lo correcto: una mutación puede afectar a cualquier responsable (un cambio de responsable afecta a dos), así que invalidar solo una entrada dejaría datos rancios. Y como el número de responsables está acotado, el Map no crece sin límite.

// test/modelo/tablero-carga.test.js
describe('cargaDe con caché', () => {
  let tablero;
  beforeEach(() => { tablero = new Tablero('Taller Nómada', crearBacklog()); });

  test('los valores canónicos', () => {
    expect(tablero.cargaDe('Iván')).toBe(25);
    expect(tablero.cargaDe('Lucía')).toBe(14);
    expect(tablero.cargaDe('Marta')).toBe(6);
    expect(tablero.cargaDe('Iván') + tablero.cargaDe('Lucía') + tablero.cargaDe('Marta'))
      .toBe(tablero.resumen(HOY).horasAbiertas);        // 45
  });

  test('se invalida al completar una tarea', () => {
    expect(tablero.cargaDe('Iván')).toBe(25);
    tablero.cambiarEstado(6, 'en-curso');               // carpintería, 5 h, de Iván
    tablero.cambiarEstado(6, 'hecha');
    expect(tablero.cargaDe('Iván')).toBe(20);           // 25 − 5
  });

  test('se invalida para TODOS los responsables, no solo el afectado', () => {
    tablero.cargaDe('Marta');                            // siembra la caché
    tablero.agregar(new Tarea({ ...DATOS_VALIDOS, id: 99, responsable: 'Marta',
                                horasEstimadas: 4, estado: 'pendiente' }));
    expect(tablero.cargaDe('Marta')).toBe(10);           // 6 + 4
    expect(tablero.cargaDe('Iván')).toBe(25);            // sigue correcto
  });

  test('caché caliente: la segunda llamada no recalcula', () => {
    const t0 = performance.now(); tablero.cargaDe('Iván'); const frio = performance.now() - t0;
    const t1 = performance.now(); tablero.cargaDe('Iván'); const caliente = performance.now() - t1;
    expect(caliente).toBeLessThan(frio);
  });
});

Mejora esperada: el render llama a cargaDe 600 veces, pero solo hay tres responsables. Sin caché: 600 × 0,9 ms = 540 ms. Con caché: 3 cálculos + 597 aciertos ≈ 2,7 ms. Unas 200× más rápido, y —lo que de verdad importa— pasa de dominar el render a ser invisible en él.

Solución 3

# Trabajo Cálculo Serialización Decisión Justificación
1 Validar el formulario 0,05 ms Hilo principal directo 0,05 ms es invisible; un worker costaría 130× más solo en mensajería
2 Reconstruir 600 Tarea 103 ms 6,6 ms porLotes Bloquea de más (103 ms > 50 ms), pero el resultado son instancias de clase, que no sobreviven a la copia: habría que reconstruirlas igualmente en el hilo principal, y el worker no ahorraría nada
3 Informe trimestral 940 ms 6,6 ms Web Worker Cálculo 142× mayor que la serialización, y el resultado son datos planos. Caso de libro
4 Recalcular el resumen 0,9 ms 3,3 ms Hilo principal directo (con caché) La serialización de ida ya cuesta casi cuatro veces más que el cálculo entero. Aquí la optimización correcta es la caché del apartado 8, no otro hilo

El criterio general, resumido en una frase: si el cálculo no supera con holgura los ~50 ms, no salgas del hilo principal; si los supera pero el resultado no viaja bien, trocea; si los supera y viaja bien, usa un worker. Y observa que en dos de los cuatro casos la respuesta correcta no es «paralelizar» sino «no calcular»: el número 4 se resuelve cacheando y el número 1 no se resuelve porque no es un problema.

Conclusión

Has atacado el primer frente de la línea base y los números lo confirman: el INP del buscador ha bajado de 480 ms a 96 ms, los 96 ms de recálculos son ahora 1,5 ms, agruparPorEtiqueta ha pasado de 148 ms a 3,1 ms, la tarea larga del arranque de 1.180 ms a 41 ms troceando, y el informe que congelaba la pantalla 940 ms ahora solo ocupa el hilo principal 52 ms. Todo ello sin tocar una sola línea del DOM.

Sabes cómo trabaja el motor —análisis perezoso, bytecode que arranca ya, compilación de lo caliente, y desoptimización cuando una suposición falla— y conoces las formas ocultas y la diferencia entre accesos monomórficos, polimórficos y megamórficos. Con ello has entendido por qué class Tarea, diseñada en 05-02 pensando en la claridad, resulta ser también la forma más rápida: todos los campos en el constructor, en el mismo orden, tipos estables. Y has recibido el aviso que ahorra años de tonterías: no escribas código raro para ayudar al JIT; escribe código estable y legible, que es lo mismo.

Tienes clarísimo cuál es el eje que de verdad manda: la complejidad. Reconoces la señal —un find, filter o includes dentro de un bucle sobre la misma colección— y sabes convertir O(n²) en O(n) con un índice Map: 48× en agruparPorEtiqueta, 43× al aplicar un lote de 600 actualizaciones, con la mejora creciendo a medida que crecen los datos. Y sabes el precio del índice: memoria y la obligación de mantenerlo sincronizado en cada mutación.

Sabes no repetir trabajo: memoizar funciones puras con las tres condiciones verificadas (pureza, clave completa, límite de tamaño), y poner una caché con invalidación por contador de versión cuando la función no es pura pero sí determinista entre mutaciones —con hoy en la clave, #invalidar() en todas las rutas de mutación, el resultado congelado para que nadie lo corrompa y pruebas de invalidación que impidan que la caché mienta—. Sabes no ejecutar de más con debounce (esperar a que paren los eventos: buscador, autoguardado) y throttle (ejecutar a intervalos durante la ráfaga: scroll, pointermove), con su borde de salida y su { passive: true }.

Y sabes quitarte del hilo principal: trocear con porLotes cediendo por presupuesto de tiempo y no por número de elementos, distinguiendo las macrotareas que sí ceden de las microtareas que no (05-07), con requestIdleCallback para lo que puede esperar y scheduler.yield() cuando esté disponible; y, cuando eso no basta, un Web Worker envuelto en una API de promesas, con su id por mensaje, su manejo de errores y su destruir(). Conoces el coste de la frontera: la clonación estructurada es más rápida y más fiel que el viaje por JSON de 04-08, pero no conserva las clases, y hay transferibles que cambian de dueño en lugar de copiarse. De ahí la regla que decide: un worker compensa cuando el cálculo supera con holgura a la serialización. Con eso queda cerrada también la comparación de 07-07: WebAssembly aceleraba el cálculo pero seguía bloqueando; el worker no bloquea, que era el problema real.

Por último, tienes la tabla de mitos desmontada con mediciones: for frente a forEach son 23 microsegundos, ++i es idéntico a i++, concatenar cadenas ya no es lento, try/catch ya no impide optimizar y cachear .length lleva una década sobrando. La legibilidad gana por defecto, y solo una medición concreta sobre datos reales justifica lo contrario.

Quedan tres frentes. Y hay uno que se ha insinuado varias veces sin desarrollarse: al hablar del Map de índices dijimos «si olvidas el delete, tienes una fuga»; al hablar de memoización, «una caché sin límite es una fuga»; al hablar de debounce, «hace falta .cancelar() para limpiar»; y al hablar del worker, «un worker vivo retiene su hilo y toda su memoria». Cuatro avisos, un mismo tema. La fila 8 de la línea base sigue intacta: 37,7 MB retenidos tras 200 filtrados, memoria que la aplicación reserva y no devuelve nunca, hasta que la pestaña se vuelve lenta y acaba muriendo. Eso es una fuga de memoria, y encontrarla exige entender cómo el navegador decide qué conservar y qué tirar. Ese es el tema de Gestión de Memoria.

Curso de JavaScript: De Principiante a Avanzado

Módulo 1: Introducción a JavaScript

Módulo 2: Estructuras de Control

Módulo 3: Funciones

Módulo 4: Objetos y Arrays

Módulo 5: Objetos y Funciones Avanzadas

Módulo 6: El Modelo de Objetos del Documento (DOM)

Módulo 7: APIs del Navegador y Temas Avanzados

Módulo 8: Pruebas y Depuración

Módulo 9: Rendimiento y Optimización

Módulo 10: Frameworks y Librerías de JavaScript

Módulo 11: Proyecto Final

© Copyright 2026. Todos los derechos reservados