Cada tecla dispara trabajo en el único carril que también atiende clics. La salida no es escribir más rápido, sino hacer menos trabajo por tecla.
El navegador hace el trabajo de tu página en el hilo principal: un solo carril donde corre tu JavaScript, se recalculan los estilos, se calcula el layout y se pinta. Un carril, no cuatro.
Una tarea es un tramo de trabajo que el navegador empieza y termina sin soltar el carril. Una tarea larga es la que pasa de 50 ms, el umbral con el que se dice "esto ya se nota". Por debajo, el usuario no lo distingue de la normalidad. Por encima, el clic que llega mientras corre se queda en cola: no se pierde, espera. Si espera 400 ms, la página parece muerta.
Un buscador de 43.200 productos recorre la lista y compara: normaliza cada nombre (minúsculas, acentos fuera) y comprueba si contiene lo escrito. Ese recorrido cuesta lo mismo con una letra que con veinte, porque siempre mira todos.
Escribes "cami": cuatro teclas, cuatro recorridos completos. Si cada uno tarda 120 ms, la cuarta llega cuando el navegador va por la tercera, y la cola crece. A 400 ms por búsqueda, la página parece congelada mientras terminas la palabra.
Aquí se confunden dos cosas: hacer menos veces y hacer menos trabajo. El debounce es lo primero: espera 250 ms de silencio y ejecuta una sola vez. Pasas de seis ejecuciones a una o dos. Pero esa ejecución sigue tardando 400 ms, así que el congelamiento no desaparece: se muda a después de la última tecla. El debounce no arregla el algoritmo; solo cambia cuándo lo llamas.
Bajar el debounce a 50 ms "para que responda antes" devuelve el problema: vuelves a ejecutar en casi cada tecla.
La primera palanca es un índice precalculado: en vez de recorrer 43.200 productos por tecla, construyes una vez un mapa de palabra a ids. Pagas 150-200 ms al cargar y cada búsqueda es una consulta a una tabla: del orden de un milisegundo.
La segunda es la estructura de datos. Si las búsquedas son por prefijo, un índice de prefijos o un árbol de letras evita mirar los que no empiezan así; si la lista está ordenada, una búsqueda binaria encuentra el rango en pocos saltos. Cambiar la forma de guardar suele rendir más que optimizar el bucle.
La tercera es un Web Worker, que se entiende como otro pasillo. El navegador
abre un hilo aparte y ejecuta ahí un archivo tuyo, en paralelo al principal. Lo que no puede
hacer es tocar el DOM: el documento pertenece al hilo principal y el worker no lo ve. Se
hablan por mensajes: postMessage envía y onmessage recibe, y los
datos se copian en el camino (clonado estructurado), no se comparten.
Esa copia es la factura: mandar 43.200 objetos por tecla puede costar más que buscarlos, así que se manda el índice una vez y el worker devuelve solo ids. Por eso un worker no siempre vale la pena: si la tarea dura 20 ms o el cuello es pintar 1.000 resultados, el trabajo sigue en el carril principal. Si es enorme pero divisible, a veces basta cortarla en trozos de 8 ms.
// Una vez, al cargar: normaliza y arma el índice const indice = new Map(); // palabra -> ids de productos for (const p of productos) { // ~43.200 productos, una vez for (const palabra of normalizar(p.nombre).split(' ')) { if (!indice.has(palabra)) indice.set(palabra, []); indice.get(palabra).push(p.id); } } function normalizar(texto) { // "Camión" y "camion" son iguales return texto.toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, ''); } // En cada tecla: una consulta, no 43.200 comparaciones function buscar(consulta) { const clave = normalizar(consulta.trim()); if (!clave) return []; // decenas de ids, no la lista entera return indice.get(clave) ?? []; } // Un prefijo como "cam" no está en el índice: se recorren // las claves (muchas menos que productos) o se guardan.
Antes de elegir arreglo, mide el tamaño de la tarea: no es lo mismo 40 ms que 400.
| Herramienta | Qué se mira | Cómo se ve el síntoma |
|---|---|---|
| Performance › pista Main | La barra de la tarea que dispara la tecla y su triángulo rojo. | Un bloque de 200-600 ms por tecla; los clics aparecen como espera antes de su handler. |
| Performance › CPU 4x más lenta | La misma grabación con el procesador frenado. | La tarea pasa de 100 a 400 ms y el problema se reproduce en tu máquina rápida. |
performance.now() |
Los ms entre el inicio y el fin del handler, cinco veces. | Números estables de cientos de ms, sin mirar el panel. |
| Performance › Bottom-Up | Qué función consume el tiempo dentro de la tarea. | El 80 % en la normalización y el recorrido, no en pintar: ahí está el algoritmo. |
Mide en frío y con caché caliente. El primer trazo siempre sale peor; el número que importa es el de la segunda y tercera tecla, cuando el usuario ya va escribiendo.
“The main thread is a single lane: any task over 50 ms blocks input and paint.”
“Debouncing reduces how often the work runs; it does not reduce how long one run takes.”
“I precompute an index so a keystroke is a lookup, and move work to a worker only when the copy is cheaper than the compute.”
Comprueba que lo tienes
Cuatro preguntas. Al comprobar se marca la lección como completada.
Escribes "cami" y la página se queda muda ~400 ms. ¿Qué pasa?
Por qué: el silencio es del carril: si fuera red, Network mostraría una espera; si fuera pintado, la pista de render estaría cargada. El handler se queda con el único hilo y todo hace cola.
Pones un debounce de 300 ms. ¿Qué mejora y qué no?
Por qué: el debounce cambia la frecuencia, no la complejidad. El área roja de la ejecución que sí corre es la misma, y ahora llega cuando el usuario cree que terminó de escribir.
Mueves la búsqueda a un Web Worker. ¿Cuál es el costo real de esa decisión?
Por qué: los hilos no comparten memoria: lo que envías se clona. El worker gana solo si el cálculo pesa más que la copia; por eso se manda el índice una vez y se devuelven ids. Y pintar los resultados sigue en el hilo principal.
El índice tarda ~180 ms en construirse al cargar. ¿Cómo se justifica ese costo?
Por qué: son 180 ms una vez contra 400 ms por tecla, y el índice puede construirse fuera del camino crítico. El precio real es memoria: puede pesar decenas de MB.