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
- Dónde va el tiempo, exactamente
- Cómo trabaja el motor: análisis, interpretación y JIT
- Formas ocultas, monomorfismo y desoptimización
- Reglas prácticas para no sabotear al motor
- El coste que de verdad manda: la complejidad
- Del bucle cuadrático al índice con
Map - Calcular una sola vez: memoización
- Una caché bien invalidada en
Tablero - Manejadores frecuentes: debounce y throttle
- Trocear el trabajo largo para no bloquear el hilo
- Web Workers: otro hilo de verdad
- El coste de cruzar la frontera: serialización y transferibles
- El informe de planificación, en un worker
- Mitos desmentidos
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- 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 | Sí |
agruparPorEtiqueta() |
148 ms | Sí, y es cuadrático |
render() inicial |
310 ms | Sí, pero es DOM (09-04) |
resumen() × 3 |
13 ms | Sí |
| Diseño y pintado del navegador | 244 ms | No directamente (09-04) |
| Resto (eventos, enrutador, repositorio) | 107 ms | Sí |
| 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.
- 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.
- 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 dCuando 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 | 1× |
| 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.
- 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.
- 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.
- Del bucle cuadrático al índice con
Map
MapEste 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 | 1× |
Í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.
- 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:
- La función debe ser pura. Si depende de algo que cambia (la hora, el tablero,
Math.random), la caché devuelve mentiras. - 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. - La caché debe tener límite o invalidación. Una
Mapque 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 usarWeakMap.
Memoizar formatearFecha cumple las tres. Memoizar resumen() no cumple la primera, y ahí está el caso interesante.
- Una caché bien invalidada en
Tablero
TableroTablero.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, incluidacambiarEstado, 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 = 0corrompería el estado de todos los futuros lectores.Object.freezelo impide (y en modo estricto, lanza). - Un solo recorrido en lugar de seis. La versión anterior encadenaba
filteryreduce; recorrer una vez acumulando todo baja el coste incluso en los fallos de caché. horasAbiertasse apoya enresumen. 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 |
- 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 | Sí |
| 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.
- 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 igualmenteAplicando 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 | Sí |
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.
- 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.
- 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 tuyoEnví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.
- 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.
- 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
forEachporforen un algoritmo cuadrático es pintar una pared que se está cayendo. - No detectar el
finddentro 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.
cambiarEstadono toca el array de tareas, pero cambia el resultado deresumen. Si no invalida, la caché miente. - Dejar la clave de caché incompleta. Olvidar
hoyen 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
Mapque crece con cada clave nueva es una fuga (09-03). - Confundir debounce con throttle. Debounce en
scrolldeja la interfaz congelada hasta que el usuario para; throttle en un buscador dispara peticiones a mitad de palabra. - Poner un
debouncedemasiado 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.
inputcondebounce(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. UsatoJSON(). - 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.
- Validar el formulario de nueva tarea (10 reglas, 0,05 ms).
- Reconstruir 600 instancias de
TareadesdelocalStorageal arrancar (103 ms). - Calcular el informe trimestral (940 ms).
- 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
- ¿Qué es JavaScript?
- Configuración de tu Entorno de Desarrollo
- Tu Primer Programa en JavaScript
- Sintaxis y Conceptos Básicos de JavaScript
- Variables y Tipos de Datos
- Operadores Básicos
- Conversión de Tipos y Comparaciones
- El Proyecto del Curso: Nómada Tareas
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for, while, do-while
- Sentencias Switch
- Control del Flujo: break, continue y Bucles Anidados
- Manejo de Errores con try-catch
Módulo 3: Funciones
- Definición y Llamada de Funciones
- Expresiones de Función y Funciones Flecha
- Parámetros y Valores de Retorno
- Ámbito y Closures
- Hoisting y el Contexto de Ejecución
- Funciones de Orden Superior
- Recursividad
Módulo 4: Objetos y Arrays
- Introducción a los Objetos
- Métodos de Objeto y la Palabra Clave
this - Arrays: Conceptos Básicos y Métodos
- Iteración sobre Arrays
- Buscar, Ordenar y Agregar Datos: find, sort y reduce
- Desestructuración de Arrays
- Desestructuración de Objetos, Spread y Rest
- JSON y Copias de Objetos
Módulo 5: Objetos y Funciones Avanzadas
- Prototipos y Herencia
- Clases y Programación Orientada a Objetos
- Encapsulación: Getters, Setters y Campos Privados
- Módulos e Importación/Exportación
- JavaScript Asíncrono: Callbacks
- Promesas y Async/Await
- El Bucle de Eventos y la Cola de Microtareas
- Iteradores y Generadores
Módulo 6: El Modelo de Objetos del Documento (DOM)
- Introducción al DOM
- Selección y Manipulación de Elementos del DOM
- Manejo de Eventos
- Propagación, Delegación y Eventos Personalizados
- Creación y Eliminación de Elementos del DOM
- Renderizado de Listas y Plantillas HTML
- Manejo y Validación de Formularios
Módulo 7: APIs del Navegador y Temas Avanzados
- Almacenamiento Local y de Sesión
- Fetch API y AJAX
- Peticiones Robustas: Errores, Timeouts y AbortController
- WebSockets
- Service Workers y Aplicaciones Web Progresivas (PWAs)
- APIs del Navegador Esenciales
- Introducción a WebAssembly
Módulo 8: Pruebas y Depuración
- Depuración de JavaScript
- Calidad de Código: ESLint, Prettier y Convenciones
- Pruebas Unitarias con Jest
- Dobles de Prueba: Mocks, Stubs y Spies
- Pruebas de Integración
- Pruebas de Extremo a Extremo con Cypress
Módulo 9: Rendimiento y Optimización
- Medir Antes de Optimizar: DevTools y Web Vitals
- Optimización del Rendimiento de JavaScript
- Gestión de Memoria
- Manipulación Eficiente del DOM
- Carga Perezosa y División de Código
Módulo 10: Frameworks y Librerías de JavaScript
- Por Qué Existen los Frameworks
- Introducción a React
- Gestión de Estado con Redux
- Conceptos Básicos de Vue.js
- Conceptos Básicos de Angular
- Elegir el Framework Adecuado
