Si tu paleta de búsqueda Cmd+K se siente “bien” en un portátil rápido y “misteriosamente maldita” en otros sitios, no te lo imaginas. Los modales de búsqueda son una intersección extraña entre pulido de interfaz, normas de accesibilidad y latencia de producción: puedes lanzar una lista bonita que se desploma en cuanto el conjunto de datos crece o el foco del teclado se va por otro lado.
Esta es la guía de campo para construir un modal Cmd+K que se comporte como un buen ingeniero de guardia: predecible bajo estrés, claro cuando no puede ayudar y que nunca atrape al usuario en una habitación oscura sin la tecla Escape.
Alcance patrones de UI centrados en HTML/CSS para listas de resultados, pistas de teclado y estados vacíos, además de diagnósticos operativos y modos de fallo.
Cómo debe comportarse un modal Cmd+K
Un modal de búsqueda Cmd+K no es una característica de juguete. Se convierte en la puerta trasera oculta de tu producto. Cuando es bueno, los usuarios forman memoria muscular y dejan de buscar en la navegación. Cuando es malo, dejan de confiar en él—y entonces dejan de usarlo. Y una vez que la gente deja de usar la búsqueda, tu arquitectura de información cuidadosamente curada se vuelve la única salida, lo cual es… ambicioso.
Comportamiento no negociable
- Retroalimentación instantánea en cada pulsación. Si necesitas cargar, muestra carga. No te congeles.
- Primero el teclado: ↑/↓ mueve la selección; Enter activa; Esc cierra; Tab no teletransporta el foco al vacío.
- Ranking predecible: la misma consulta produce los mismos resultados superiores, a menos que los datos subyacentes hayan cambiado.
- Semántica accesible: los lectores de pantalla deben recibir una historia coherente: “Buscar, 12 resultados, resultado seleccionado X.”
- Estado vacío claro que diga al usuario qué hacer a continuación, no lo que hizo mal.
Cómo falla en producción
- Fugas de foco: el modal se abre, pero el foco queda atrás. Los usuarios que usan teclado escriben en lo que estaba enfocado antes.
- Prisión de scroll: la página detrás del modal sigue desplazándose; la lista de resultados no; alguien abre un ticket “la búsqueda está rota”.
- Mentiras de latencia: la UI muestra “Sin resultados” antes de que llegue la respuesta de la red; luego aparecen resultados. Los usuarios aprenden a ignorarlo.
- Tormentas de eventos: cada pulsación desencadena una búsqueda en backend; tu API se convierte en un registrador involuntario de teclas con facturas al final.
- Desajuste entre pista y realidad: el pie dice Enter para abrir, pero Enter envía un formulario y cierra el modal.
“La esperanza no es una estrategia.”
Una verdad seca: un modal de búsqueda es como un buscapersonas. Está silencioso cuando todo va bien, y cuando se necesita, se necesita ahora. Construyelo como si lo fueras a depurar a las 2 a.m. con un ojo abierto.
Hechos y contexto histórico para tomar decisiones
A continuación hay fragmentos concretos de historia y la deriva de la industria que explican por qué los usuarios esperan que Cmd+K funcione de cierta manera. No son trivialidades. Son restricciones disfrazadas de curiosidades.
- Las paletas de comandos no nacieron en la web. Editores para usuarios avanzados (notablemente IDEs) normalizaron “escribir para buscar comandos” mucho antes de que lo hicieran los navegadores, por lo que los usuarios llegan con expectativas fuertes sobre el comportamiento del teclado.
- Spotlight popularizó “buscar como lanzador”. La búsqueda a nivel de SO enseñó a la gente que buscar no es solo encontrar documentos; es un selector universal de acciones.
- Cmd+K se convirtió en convención de facto en la web moderna porque es memorable, no entra en conflicto con “Buscar en la página” (Cmd+F), y las plataformas necesitaban un atajo compartido de memoria muscular.
- Los patrones WAI-ARIA para combobox/listbox evolucionaron despacio porque “typeahead + lista” accesible es sorprendentemente complejo; muchos patrones iniciales rompían lectores de pantalla o la navegación por teclado.
- Los diálogos modales tienen larga historia de bugs de foco porque el atrapado de foco no es un primitivo nativo del navegador; estás aproximando un gestor de ventanas de escritorio dentro de una página.
- Las expectativas de “búsqueda instantánea” siguen al progreso del hardware. A medida que los dispositivos se hicieron más rápidos, la tolerancia a “esperar después de cada pulsación” desapareció; las normas UX se endurecieron.
- Los CDN hicieron barato entregar assets, no estado. Enviar un gran índice al cliente puede sentirse “rápido” localmente y luego freír dispositivos de gama baja con presión de memoria.
- El móvil cambió el significado de las pistas de teclado. Un pie lleno de teclas es ruido en dispositivos táctiles; las pistas deben adaptarse.
Uso para decisiones: trata Cmd+K como una función de memoria muscular. Tu KPI principal no es “tiempo de implementación”, es “tiempo de éxito por consulta bajo estrés”.
Anatomía: entrada, lista de resultados, pistas en el pie y estados
Un modal Cmd+K es una UI pequeña, pero tiene subsistemas distintos. Si no los nombras, los depurarás como una masa amorfa. Nómbralos. Ayuda.
| Subsistema | Trabajo | Modo de fallo | Filosofía de arreglo |
|---|---|---|---|
| Trigger | Abrir de forma fiable desde cualquier lugar | Conflictos de atajos, bloqueado dentro de inputs | Respetar convenciones de plataforma; no secuestrar campos de escritura |
| Dialog shell | Atrapar foco; prevenir interacción con el fondo | Fuga de foco, scroll del fondo | Usar semántica correcta; bloquear scroll |
| Search input | Capturar consulta; mostrar carga; limpiar | Bugs con IME, debounces que fallan | Manejar composición; no exagerar el debounce |
| Results list | Mostrar; permitir selección; activar | Scroll entrecortado, selección incorrecta | DOM simple, keys estables, resalte predecible |
| Hints footer | Enseñar interacción; mostrar alcance | Pistas que mienten o saturan | Mostrar solo lo que es verdad; adaptarse al dispositivo |
| States | Vacío, error, cargando, sin conexión | “No pasó nada” ambiguo | Comunicar siempre la siguiente acción |
La mayoría de las paletas son 70% comportamiento de lista de resultados, 20% semántica de foco, 10% todo lo demás. La lista es donde los sueños UX van a morir—porque es donde latencia, ranking, accesibilidad e impaciencia humana se encuentran.
Estructura HTML/CSS-first (mejora progresiva)
HTML/CSS-first no significa “sin JavaScript”. Significa que el marcado expresa intención, los estados son visibles y JS mejora el comportamiento en lugar de inventarlo desde cero. En términos de fiabilidad: quieres degradación elegante y estados observables.
Marcado base: dialog + input + lista + pie
Usa un dialog real si puedes, pero trátalo como un primitivo de UI, no como un hechizo mágico. Aún necesitas gestión de foco y bloqueo de scroll alrededor. Tu HTML debe tener sentido incluso si la lógica de selección falla.
Demo: lista de resultados con pistas de teclado
Esto es “HTML-first” en espíritu: estructura de lista legible, selección visible y pistas que coinciden con el comportamiento.
Demo: estado vacío que ofrece un siguiente paso
No hay coincidencias para “kafak”.
- Prueba kafka o queue.
- Usa prefijos / como /runbook para acotar el alcance.
- Si esperabas un servicio, puede estar oculto por permisos.
Los estados vacíos deberían reducir la incertidumbre: “¿soy yo, el sistema o el control de acceso?”
El estado vacío es una superficie de producto. Trátalo como tal.
Semántica de accesibilidad que no debes improvisar
Elige un patrón ARIA conocido y síguelo. Para un “typeahead + lista de resultados” generalmente acabarás con un patrón tipo combobox o uno más simple textbox + listbox con active descendant. El patrón específico depende de si los resultados son “sugerencias” o una lista de resultados separada. Hagas lo que hagas, asegúrate de que los lectores de pantalla puedan anunciar:
- Dónde está el foco (input vs lista)
- Cuántos resultados existen
- Qué elemento está seleccionado
Opinión: si tu app ya tiene infraestructura a11y madura, implementa el patrón active-descendant completo. Si no, mantenlo más simple pero correcto: no lances medio combobox.
Un chiste, corto y práctico: un modal sin gestión de foco es como un centro de datos sin puertas—técnicamente “abierto”, operacionalmente aterrador.
Lista de resultados que sobrevive a la realidad
Las listas de resultados en paletas de comando tienden a construirse como un feed social: muchos elementos anidados, iconos, metadatos, etiquetas, resaltados, botones, menús hover. Entonces alguien pregunta por qué la navegación con flechas tartamudea. Porque construiste una mini catedral DOM y le pides que vuelva a fluir 60 veces por segundo.
Reglas para una lista que se mantiene rápida
- Mantén el DOM de la fila superficial. Una fila debe ser: título, fragmento opcional, pista alineada a la derecha. No un UI framework anidado.
- Identidad estable. No claves resultados por índice. Usa un identificador estable o la ruta URL. De lo contrario la selección salta cuando los resultados se actualizan.
- La selección es un estado, no un efecto hover. Usa
aria-selectedy un estilo visible que funcione sin hover. - Desplaza la lista, no la página. Dale al contenedor de resultados una altura máxima y
overflow:auto. - No resaltes reconstruyendo innerHTML. Usa una función de renderizado que divida el texto de forma segura, o precomputar rangos de resaltado. InnerHTML es donde XSS y problemas de rendimiento se dan la mano.
Comportamiento del teclado: elige un modelo
Hay dos modelos comunes. Elige explícitamente:
- El foco queda en el input, y las flechas cambian el “active descendant” en la lista. Esto mantiene la escritura estable y facilita IME/composición. También es más complejo en accesibilidad.
- El foco se mueve a la lista en la primera flecha hacia abajo, y vuelve al input al escribir. Semántica más simple, pero debes asegurar que la escritura no se trague y que la restauración del foco sea correcta.
Desde la perspectiva de fiabilidad, “el foco queda en el input” suele producir menos bugs de “ya no puedo escribir”. Es más difícil de implementar correctamente, pero falla menos catastróficamente.
Ranking + agrupación sin confundir al usuario
Agrupar es útil: elementos recientes, mejores coincidencias, comandos vs documentos. Pero agrupar también puede hacer que la navegación por teclado se sienta rota si la selección salta entre encabezados de grupo. Si muestras encabezados de grupo, hazlos no seleccionables y visualmente discretos.
Además: mantiene el ranking consistente. Si mezclas “reciente” y “mejor coincidencia”, etiquétenlo. La gente perdona un ranking extraño si está explicado. No perdonan una lista que cambia de orden mientras escriben sin decir por qué.
Pistas de teclado: mostrarlas sin gritar
Las pistas de teclado son documentación de UI que envías a producción. Eso significa que deben ser:
- Verdaderas (coinciden con el comportamiento real)
- Contextuales (no muestres “Enter para abrir” si no hay nada seleccionado)
- Adaptativas (no fuerces arte de teclas en dispositivos táctiles; no muestres Cmd en Windows)
Renderizado de teclas que no parezca una nota de rescate
Usa CSS simple para las teclas. Evita SVG inline por tecla. Inflarás el DOM y los harás difíciles de tematizar. Mantén los componentes de tecla consistentes: borde, fondo y una ligera sombra interior. Es pequeño, pero hace que la paleta se sienta “nativa”.
Pistas como máquina de estados
Las pistas deben reflejar el estado:
- Inactivo (todavía no hay consulta): muestra ejemplos (/ para scope, o “Escribe para buscar…”)
- Buscando: muestra “Buscando…” y Esc para cerrar
- Resultados disponibles: muestra navegación + teclas de acción
- Vacío: muestra cómo ampliar la consulta y, opcionalmente, una alternativa “buscar en todo”
- Error/sin conexión: muestra tecla de reintento o alternativa “Abrir en búsqueda del navegador”
El otro chiste (y el último, relájate): Si tu pie dice “Presiona Esc para cerrar” y Esc no cierra, felicidades—has inventado una prueba de regresión de confianza.
Estados vacíos: el silencio es un error
Un estado vacío no es “sin resultados”. Es una rama en la historia del usuario. Y en sistemas en producción, cada rama necesita observabilidad porque es donde vive la confusión.
Tres estados vacíos que necesitas (no uno)
- Sin coincidencias: la consulta devolvió cero resultados. Proporciona sugerencias y explica el alcance.
- No indexado aún: los datos existen pero no son buscables. Dilo. Ofrece una ruta alternativa.
- Acceso limitado: el usuario podría no ver elementos por permisos. Reconócelo sin filtrar información sensible.
Contenido de estado vacío que reduce tickets
Los buenos estados vacíos responden tres preguntas:
- ¿El sistema me escuchó? Eco de la consulta (sanitizada si hace falta).
- ¿Dónde buscó? “Solo docs” vs “Todo”.
- ¿Y ahora qué? Sugerencias, operadores de alcance o una forma de solicitar acceso.
Ángulo operativo: si no puedes diferenciar “sin resultados” de “el backend de búsqueda agotó el tiempo”, pasarás semanas persiguiendo reportes de “la búsqueda es inestable” que en realidad son ambigüedad de UX.
Cuando “Sin resultados” es una mentira
Dos causas clásicas:
- Condiciones de carrera: muestras el estado vacío por una respuesta rápida a una consulta antigua, luego la sobrescribes con resultados de la última consulta, o al revés.
- Debounce demasiado agresivo: tu UI demora las solicitudes, pero tu estado vacío se activa de inmediato basado en filtrado local, así que muestra brevemente “Sin resultados” en cada pulsación.
Arréglalo con secuenciación de solicitudes (IDs monotónicos) y vinculando los estados al mismo ciclo de vida: si haces debounce de solicitudes, haz debounce también de las transiciones de estado vacío.
Rendimiento y fiabilidad: las restricciones aburridas
Cmd+K se siente como una característica frontend hasta que tumba un endpoint de búsqueda, y de pronto es cosa de SRE. Necesitas presupuestos y contrapresión.
Presupuestos de latencia: a qué aspirar
- Abrir modal: menos de 100 ms, incluyendo la colocación del foco.
- Primeros resultados tras teclear: 150–250 ms percibidos, idealmente con resultados locales optimistas si están disponibles.
- Navegación con flechas: debe sentirse instantánea; cualquier tartamudeo es un bug.
Contrapresión: no te hagas un DDOS
Teclear genera tráfico en ráfaga. Si llamas al backend en cada pulsación, debes usar:
- Debounce (pequeño, como 80–150 ms) y/o throttle
- Cancelación (abort de solicitudes en vuelo)
- Cache en el cliente para consultas recientes
- Límites de tasa en el servidor que devuelvan una respuesta amigable, no una pataleta 429
Observabilidad: mide lo que importa
Mide:
- Tiempo hasta abrir (keydown → input enfocado)
- Tiempo hasta primeros resultados (cambio de consulta → lista poblada)
- Tasa de estados vacíos, por alcance y por familia de agente de usuario
- Tasa de errores y tasa de timeouts (y si la UI mostró “Sin resultados” en su lugar)
- Tasa de consultas al backend por usuario (picos significan que falló el debounce o la caché)
Truco de SRE: trata el modal como un cliente que produce carga. No es “la UI”. Es un generador de tráfico con un teclado conectado.
Tres mini-historias corporativas desde las trincheras
1) Incidente causado por una suposición errónea: “Los resultados de búsqueda son públicos de todos modos”
Una compañía mediana construyó una paleta Cmd+K unificada que buscaba docs, tickets y paneles de servicios internos. El equipo supuso que si un elemento aparece en la navegación, es seguro mostrarlo en los resultados. Esa suposición se mantuvo para los docs. Fracasó estrepitosamente para tickets y metadatos de servicios.
Lo que falló no fue una filtración de contenido. Fue una fuga de metadatos. La lista de resultados mostraba títulos y nombres de proyectos para elementos que el usuario no podía abrir. “Acceso denegado” aparecía tras la selección, lo que el equipo pensó aceptable. En la práctica, el título por sí solo contenía pistas sensibles: nombres de incidentes, nombres de clientes, nombres de proyectos de adquisición. La paleta se convirtió en una canal de chismes accidental.
El bug sobrevivió a la revisión de código porque todos probaron con cuentas de admin. Sobrevivió a staging porque los datos de staging estaban sanitizados. Sobrevivió al lanzamiento porque “nadie se quejó” hasta que una persona lo hizo, en voz alta, en el tipo de reunión a la que no quieres asistir.
La solución no fue solo añadir comprobaciones de permisos. Cambiaron el contrato de UI: los resultados solo devolvían ítems que puedes abrir, y para casos limítrofes devolvían una entrada genérica “Elemento restringido” sin revelar metadatos identificativos. También añadieron una pista en el estado vacío: “Si esperabas algo, puede que no tengas acceso.” Esa simple frase redujo los tickets de “la búsqueda está rota” y dejó la postura de seguridad clara.
2) Optimización que salió mal: preindexado en cliente para “hacerlo instantáneo”
Otro equipo decidió enviar un índice precalculado al navegador para que la paleta Cmd+K funcionara offline y se sintiera instantánea. Funcionó en demos. El índice estaba comprimido, servido vía CDN y cacheado agresivamente. La UI se sentía ágil en un MacBook.
Luego llegó a dispositivos de gama baja y sesiones de navegador largas. El uso de memoria subió. La GC del app aumentó. La paleta en sí era rápida, pero todo lo que la rodeaba se volvió más lento. Los usuarios no dijeron “el índice es pesado”. Dijeron “la app se siente lenta después del almuerzo”. Clásico.
Peor aún, la estrategia de actualización del índice era frágil. Debido al cache agresivo, los usuarios obtuvieron resultados obsoletos tras cambios de rol y actualizaciones de contenido. El estado vacío se volvió engañoso: “Sin resultados” para algo que definitivamente existía… ayer.
La lección de rollback: “offline” no es una característica gratis. El equipo pasó a un híbrido: caché local pequeño para ítems recientes y fijados, búsqueda en servidor para la larga cola, e invalidación de caché estricta ligada a la versión de autorización. La paleta quedó un poco menos mágica, pero la aplicación dejó de comerse RAM como hobby.
3) Práctica aburrida pero correcta que salvó el día: feature flag + canary + presupuesto de errores
Una gran empresa desplegó una nueva implementación de paleta de comandos en varias apps internas. El equipo de UI hizo las cosas aburridas pero correctas: feature flag, cohortes canary y un SLO claro: “Tasa de error de solicitudes de búsqueda < X, latencia p95 < Y.” Nada heroico. Solo disciplina.
Durante el canary, las métricas mostraron un pico extraño: las tasas de error aumentaron solo en cierta región y solo los lunes por la mañana. La UI en sí se veía bien. El equipo podría haber culpado al backend y seguir. En cambio, correlacionaron el pico con un comportamiento específico del teclado: gente abriendo la paleta y pegando cadenas largas (paquetes de tickets, notas de reuniones) que explotaban la complejidad de la consulta aguas abajo.
La solución no fue glamorosa: límites de longitud de entrada con un mensaje amigable, y protecciones en el backend para consultas. El canary evitó una caída generalizada, y el feature flag permitió una desactivación rápida en las apps afectadas mientras se cocinaba el parche.
No fue una “victoria UI guay”. Fue una pequeña victoria operativa que evitó el tipo de incidente que te genera más trabajo de observabilidad del que querías para el trimestre.
Tareas prácticas: comandos, salidas y decisiones
Los bugs de UI suelen vivir en la costura entre comportamiento frontend y realidad del backend. Estas tareas son las que ejecutas cuando alguien dice “La búsqueda Cmd+K es lenta” o “la búsqueda no muestra nada” y quieres dejar de adivinar. Cada tarea incluye un comando, qué significa la salida y la decisión que tomas a partir de ello.
1) Confirma que el bundle de UI no haya crecido
cr0x@server:~$ ls -lh /srv/web/assets/search-palette.*.js
-rw-r--r-- 1 root root 412K Dec 18 09:12 /srv/web/assets/search-palette.8b2c1a.js
Qué significa: El chunk de la paleta pesa 412K en disco (pre-compresión). Si antes era 120K, probablemente enviaste una bomba de dependencias.
Decisión: Si el chunk creció significativamente, audita imports (librerías de fuzzy search, packs de íconos) y separa características “raras” (resaltado, elementos recientes) detrás de límites asíncronos.
2) Revisa la eficacia de gzip/brotli para el chunk
cr0x@server:~$ gzip -c /srv/web/assets/search-palette.8b2c1a.js | wc -c
118902
Qué significa: El tamaño gzipped es ~119KB. Es plausible. Si el tamaño gzip está cerca del tamaño bruto, el archivo podría estar mal minificado o contener muchos datos incompresibles.
Decisión: Si la compresión es inefectiva, elimina datasets embebidos, grandes blobs JSON o assets base64 del bundle JS.
3) Valida que el servidor sirva brotli cuando debe
cr0x@server:~$ curl -I -H 'Accept-Encoding: br' https://app.example.internal/assets/search-palette.8b2c1a.js
HTTP/2 200
content-type: application/javascript
content-encoding: br
cache-control: public, max-age=31536000, immutable
Qué significa: Brotli está activo. Si no ves content-encoding: br, tu UI “rápida” puede estar pagando costes extra de descarga.
Decisión: Arregla la configuración del CDN/origen antes de tocar el código de UI. El ancho de banda es el cuello de botella más tonto y el más común.
4) Detecta timeouts del backend vs ambigüedad “Sin resultados”
cr0x@server:~$ tail -n 20 /var/log/nginx/access.log | grep "/api/search" | tail -n 5
10.2.4.19 - - [28/Dec/2025:10:31:44 +0000] "GET /api/search?q=kafka HTTP/2.0" 200 4821 "-" "Mozilla/5.0"
10.2.4.19 - - [28/Dec/2025:10:31:45 +0000] "GET /api/search?q=kafak HTTP/2.0" 504 164 "-" "Mozilla/5.0"
10.2.4.19 - - [28/Dec/2025:10:31:45 +0000] "GET /api/search?q=kaf HTTP/2.0" 200 9912 "-" "Mozilla/5.0"
10.2.4.19 - - [28/Dec/2025:10:31:46 +0000] "GET /api/search?q=kafak%20runbook HTTP/2.0" 504 164 "-" "Mozilla/5.0"
10.2.4.19 - - [28/Dec/2025:10:31:47 +0000] "GET /api/search?q=kafka%20runbook HTTP/2.0" 200 10532 "-" "Mozilla/5.0"
Qué significa: Estás viendo 504s para ciertas consultas. Si la UI traduce esto a “Sin resultados”, los usuarios pensarán que la indexación está rota.
Decisión: Actualiza la UI para mostrar un estado de error en respuestas no 200, y añade protecciones backend para la complejidad de consultas.
5) Mide la distribución de latencia de la API rápidamente
cr0x@server:~$ awk '$7 ~ /\/api\/search/ {print $(NF-1)}' /var/log/nginx/access.log | tail -n 10
0.021
0.034
0.112
0.487
1.902
0.029
0.041
0.055
0.078
0.090
Qué significa: Esos números (si tu formato de log incluye el tiempo de petición como el penúltimo campo) muestran respuestas ocasionales de varios segundos. Una paleta se sentirá “inestable” incluso si la media está bien.
Decisión: Si existe latencia en la cola, trabaja en mejoras p95/p99: caché, límites de consulta o resultados precomputados populares.
6) Confirma que el rate limiting no castiga ráfagas de tecleo
cr0x@server:~$ grep -E " 429 " /var/log/nginx/access.log | grep "/api/search" | tail -n 5
10.2.4.19 - - [28/Dec/2025:10:32:02 +0000] "GET /api/search?q=kafka%20runb HTTP/2.0" 429 89 "-" "Mozilla/5.0"
10.2.4.19 - - [28/Dec/2025:10:32:02 +0000] "GET /api/search?q=kafka%20runbo HTTP/2.0" 429 89 "-" "Mozilla/5.0"
10.2.4.19 - - [28/Dec/2025:10:32:02 +0000] "GET /api/search?q=kafka%20runboo HTTP/2.0" 429 89 "-" "Mozilla/5.0"
10.2.4.19 - - [28/Dec/2025:10:32:02 +0000] "GET /api/search?q=kafka%20runbook HTTP/2.0" 200 10532 "-" "Mozilla/5.0"
Qué significa: Una ráfaga de consultas queda limitada hasta que la última entra. La UI “parpadeará” mostrando vacío/error.
Decisión: Haz debounce en el cliente, pero también ajusta limits para permitir ráfagas cortas por usuario. El rate limiting debe proteger infraestructura, no castigar el tecleo.
7) Comprueba si las solicitudes de búsqueda son cacheables y están cacheadas
cr0x@server:~$ curl -I "https://app.example.internal/api/search?q=runbook"
HTTP/2 200
content-type: application/json
cache-control: private, max-age=0
vary: authorization
Qué significa: No está cacheado. A veces correcto (resultados personalizados), a veces ineficiente (docs públicos).
Decisión: Si los resultados son seguros para cachear por usuario, considera caché de corta duración claveada por contexto de auth. Si no, al menos añade memoización servidor-side para consultas idénticas en una ventana temporal.
8) Confirma que el backend de búsqueda está sano (systemd)
cr0x@server:~$ systemctl status search-api.service --no-pager
● search-api.service - Search API
Loaded: loaded (/etc/systemd/system/search-api.service; enabled)
Active: active (running) since Sun 2025-12-28 09:41:10 UTC; 52min ago
Main PID: 2147 (search-api)
Tasks: 24
Memory: 612.4M
CPU: 18min 22.118s
Qué significa: El servicio está activo; la memoria es significativa. Si la memoria sube continuamente, podrías tener una fuga de caché o conjuntos de resultados sin límite.
Decisión: Si la memoria es sospechosa, inspecciona heap/métricas; limita cachés; impone tamaños máximos de respuesta.
9) Identifica saturación de CPU que correlacione con tormentas de tecleo
cr0x@server:~$ mpstat -P ALL 1 3
Linux 6.1.0 (search01) 12/28/2025 _x86_64_ (8 CPU)
10:34:21 AM CPU %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle
10:34:22 AM all 78.12 0.00 9.34 0.17 0.00 0.42 0.00 0.00 0.00 11.95
10:34:23 AM all 82.55 0.00 10.02 0.12 0.00 0.38 0.00 0.00 0.00 6.93
10:34:24 AM all 80.10 0.00 9.77 0.09 0.00 0.40 0.00 0.00 0.00 9.64
Qué significa: La CPU está muy usada en espacio de usuario. Es consistente con ranking costoso, matching fuzzy o carga de índices por solicitud.
Decisión: Perfila el manejo de consultas del backend; precarga índices; reduce asignaciones por solicitud; limita algoritmos fuzzy para consultas cortas.
10) Determina si estás limitado por I/O (latencia de almacenamiento)
cr0x@server:~$ iostat -xz 1 3
Linux 6.1.0 (search01) 12/28/2025 _x86_64_ (8 CPU)
avg-cpu: %user %nice %system %iowait %steal %idle
71.20 0.00 8.90 9.80 0.00 10.10
Device r/s w/s rkB/s wkB/s rrqm/s wrqm/s %util await
nvme0n1 980.0 220.0 7840.0 1960.0 0.0 0.0 92.0 7.80
Qué significa: Alto %util y await significativo. Si tu backend de búsqueda toca disco en cada consulta (índice en disco, caché fría), tendrás p95 que los usuarios sienten.
Decisión: Mantén índices calientes en memoria; ajusta page cache; reduce lecturas aleatorias; o cambia a un motor de búsqueda diseñado para esta carga.
11) Revisa problemas a nivel de red entre UI y API
cr0x@server:~$ ss -s
Total: 1382
TCP: 921 (estab 612, closed 245, orphaned 3, synrecv 0, timewait 245/0), ports 0
Transport Total IP IPv6
RAW 0 0 0
UDP 42 36 6
TCP 676 498 178
INET 718 534 184
FRAG 0 0 0
Qué significa: Alto timewait puede indicar muchas conexiones de corta duración. Si keepalive HTTP está mal configurado, cada pulsación podría abrir una nueva conexión.
Decisión: Arregla keepalive en el load balancer o Nginx; prefier HTTP/2 donde sea posible; reduce overhead por petición.
12) Verifica que la UI no llame a búsqueda cuando el modal está cerrado
cr0x@server:~$ journalctl -u search-api.service --since "10 minutes ago" | grep "q=" | tail -n 8
Dec 28 10:28:01 search01 search-api[2147]: request_id=2b7b q=runbook user=anon status=200
Dec 28 10:28:02 search01 search-api[2147]: request_id=2b7c q=runboo user=anon status=200
Dec 28 10:28:02 search01 search-api[2147]: request_id=2b7d q=runbook user=anon status=200
Dec 28 10:28:03 search01 search-api[2147]: request_id=2b7e q= user=anon status=400
Dec 28 10:28:03 search01 search-api[2147]: request_id=2b7f q= user=anon status=400
Dec 28 10:28:04 search01 search-api[2147]: request_id=2b80 q= user=anon status=400
Dec 28 10:28:05 search01 search-api[2147]: request_id=2b81 q= user=anon status=400
Dec 28 10:28:06 search01 search-api[2147]: request_id=2b82 q= user=anon status=400
Qué significa: Se están enviando consultas vacías (q=) repetidamente. Eso suele ser un bug del ciclo de vida UI: limpiar input dispara una petición incluso cuando el modal se cierra.
Decisión: Añade una guardia cliente: no consultar string vacío; no consultar cuando el modal no está abierto; cancelar trabajo pendiente al cerrar.
13) Inspecciona consultas lentas de DB si la búsqueda usa SQL
cr0x@server:~$ sudo -u postgres psql -c "select now() - query_start as age, state, left(query,120) from pg_stat_activity where datname='searchdb' order by query_start asc limit 5;"
age | state | left
---------+--------+--------------------------------------------------------------
00:00:05| active | select id,title from docs where title ilike '%kafka runbook%' l
00:00:02| active | select id,title from docs where title ilike '%kafka runb%' limi
00:00:01| active | select id,title from docs where title ilike '%kafka run%' limit
Qué significa: Estás haciendo ILIKE con comodines en cada pulsación. Es una forma predecible de aumentar factura de DB y tristeza.
Decisión: Pasa a un índice de texto completo adecuado, o al menos restringe consultas (búsqueda por prefijo, índices trigram) y débounce más fuerte.
14) Comprueba proporción hit/miss de cache si usas Redis
cr0x@server:~$ redis-cli info stats | egrep "keyspace_hits|keyspace_misses"
keyspace_hits:1829012
keyspace_misses:712334
Qué significa: Los misses son altos respecto a hits. Si la caché no ayuda, estás haciendo trabajo extra por nada.
Decisión: Revisa claves de caché (normaliza consultas), TTL y si la caché es por usuario vs global. Si la personalización mata la caché, cachea solo el subconjunto público.
Guía rápida de diagnóstico
Cuando “La búsqueda Cmd+K es lenta” aparece en el chat, tienes dos trabajos: detener la hemorragia e identificar el cuello de botella real. No empieces por reescribir el ranking. Empieza por probar dónde se va el tiempo.
Primero: decide si es jank en UI o latencia del backend
- Síntomas de jank en UI: la navegación con flechas tartamudea, escribir se siente lento, el resalte de selección “teletransporta”, picos de CPU en el cliente.
- Síntomas de latencia backend: escribir es fluido pero los resultados aparecen tarde; “cargando” persiste; reintentos ayudan; errores se correlacionan con picos de tráfico.
Chequeo rápido: busca 5xx/429 en logs de acceso para /api/search y compáralo con los reportes de usuarios.
Segundo: verifica patrones de solicitud (puede que estés spameando)
- ¿Envías solicitudes para consultas vacías?
- ¿Envías solicitudes mientras el modal está cerrado?
- ¿Cancelas solicitudes en vuelo?
- ¿Hay caché para consultas parciales repetidas (
k,ka,kaf)?
Tercero: aisla la capa lenta
- Si el backend es lento: revisa CPU (
mpstat), I/O (iostat), actividad DB (pg_stat_activity), tasa de aciertos de caché (Redis), luego logs por picos de complejidad de consulta. - Si la UI es lenta: reduce complejidad de render de la lista, evita re-renderizar toda la lista en cambios de selección y asegura que la lógica de resaltado no sea O(n*m) por pulsación.
Heurística de on-call: si ves 429s, arregla la tasa de solicitudes del cliente primero. Si ves 504s, arregla timeouts y guardas de consulta. Si no ves ninguno, probablemente tienes jank en la UI.
Errores comunes (síntomas → causa raíz → solución)
1) “Escribir se siente retrasado”
Síntoma: los caracteres aparecen tarde; el cursor se traba.
Causa raíz: trabajo síncrono en cada pulsación (renderizar demasiados nodos DOM; resaltado costoso; parseo JSON; matching fuzzy en el hilo principal).
Solución: mantén DOM pequeño; limita resultados mostrados; precomputar resaltados; mueve búsqueda pesada a un web worker si es client-side; debounce llamadas de red pero no el render local de entrada.
2) “Las flechas a veces dejan de funcionar”
Síntoma: la navegación funciona y luego aleatoriamente no; parece que se pierde el foco.
Causa raíz: el foco se movió a un elemento no focalizable, o el modal se cierra y reabre sin restaurar el foco; encabezados de grupo se vuelven focalizables por accidente.
Solución: mantiene el foco en el input (modelo active-descendant) o asegura que los ítems de la lista sean consistentemente focalizables; al abrir, fija foco determinísticamente; al cerrar, restaura foco al trigger.
3) “Esc cierra, pero el fondo aún se puede desplazar”
Síntoma: el usuario desplaza y la página detrás se mueve; el modal queda fijo.
Causa raíz: lock de scroll en body no aplicado, o aplicado incorrectamente para iOS/containers con overflow.
Solución: bloquea scroll usando un enfoque probado; asegura que la región desplazable sea el contenedor de resultados. Verifica en Safari móvil específicamente.
4) “Sin resultados” parpadea mientras se cargan resultados
Síntoma: el estado vacío aparece brevemente, luego aparecen resultados.
Causa raíz: el estado vacío está ligado a “results array length === 0” sin considerar “cargando”; carrera entre solicitudes.
Solución: estados explícitos: inactivo, cargando, cargado, error. Vincula cada vista renderizada a un ID de solicitud.
5) “Los resultados parecen correctos pero abren el elemento equivocado”
Síntoma: el resaltado está en el ítem A; Enter abre el ítem B.
Causa raíz: claves basadas en índice; la lista se reordena; el índice de selección apunta al orden antiguo.
Solución: almacena la selección por ID estable del resultado; actualiza la selección cuando cambian los resultados; evita mapear activación por índice.
6) “La búsqueda funcionó ayer; hoy está vacía para algunos usuarios”
Síntoma: un subconjunto de usuarios ve el estado vacío para ítems conocidos.
Causa raíz: mismatch en búsqueda sensible a permisos, índice cliente obsoleto o caché que no varía por auth.
Solución: asegura que las respuestas de búsqueda respeten autorización; varía cachés por contexto de auth; versiona privilegios de usuario e invalida en consecuencia.
7) “Cmd+K deja de funcionar dentro de áreas de texto”
Síntoma: el atajo entra en conflicto con edición o está bloqueado.
Causa raíz: el handler global de teclas ignora el contexto del target del evento o previene default incorrectamente.
Solución: no dispares dentro de elementos editables; permite opt-out; mantén el manejo de teclas acotado y conservador.
8) “Usuarios de lectores de pantalla no saben qué está seleccionado”
Síntoma: la lista se actualiza pero no hay anuncio; la selección es silenciosa.
Causa raíz: faltan roles/relaciones ARIA; active descendant no enlazado; la selección solo se indica visualmente.
Solución: implementa un patrón conocido; asegura anuncios; prueba con lectores de pantalla reales, no solo auditorías.
Listas de verificación / plan paso a paso
Plan de construcción paso a paso (HTML/CSS-first)
- Define el contrato: qué se puede buscar (docs, comandos, personas), qué metadatos mostrar y qué ocurre en límites de permisos.
- Marca el shell del diálogo: incluye título, input, contenedor de resultados y pistas en el pie. Hazlo legible sin JS.
- Implementa reglas de foco: al abrir enfoca el input; al cerrar restaura foco; atrapa foco dentro del modal; asegura que Esc siempre cierre.
- Implementa navegación de lista: decide el modelo de foco (active descendant vs mover foco). Haz que ↑/↓ sea determinista.
- Añade estados: inactivo, cargando, cargado, vacío, error/sin conexión. Haz cada estado visualmente distinto y con copy editado.
- Añade pistas de teclado: muestra solo teclas que funcionen en el estado actual. Adapta modificadores por plataforma.
- Guardarraíles de rendimiento: cap los resultados mostrados; limita la tasa de peticiones; cancela solicitudes en vuelo; cachea consultas recientes de forma segura.
- Observabilidad: loggea request IDs y duraciones; instrumenta tiempo-a-abrir y tiempo-a-resultados; rastrea tasa de estados vacíos.
- Pruebas de accesibilidad: verifica solo con teclado; luego con lector de pantalla; luego modo alto contraste; luego reducción de movimiento.
- Plan de despliegue: ship detrás de feature flag; canary; vigila presupuestos de error; avanza o retrocede con intención.
Checklist pre-lanzamiento (cosas que fallan a escala)
- Apertura del modal en menos de 100 ms en hardware de gama media.
- Navegación de resultados sin thrash de layout (sin salto de scroll; selección permanece en vista).
- La API puede manejar ráfagas de tecleo sin tormentas 429.
- Timeouts producen un estado de error, no “Sin resultados”.
- Modelo de permisos: no filtras títulos de objetos restringidos.
- Estado vacío explica el alcance y ofrece una acción siguiente.
- Las pistas son correctas en macOS y Windows, y no molestan en móvil.
- Cerrar el modal cancela trabajo y restaura foco de forma fiable.
Checklist de on-call (cuando ya está roto)
- Revisa logs de acceso por 429/504 en
/api/search. - Revisa saturación de CPU e I/O del backend.
- Revisa si la UI está emitiendo consultas vacías o consultas en background.
- Desactiva el feature flag si está causando picos de carga.
- Comunica un workaround al usuario (búsqueda en navegación, página de búsqueda dedicada) mientras arreglas la paleta.
Preguntas frecuentes
¿La búsqueda Cmd+K debería ser “paleta de comandos” o “cuadro de búsqueda”?
Haz que sea ambos, pero separa visual y semánticamente los tipos de resultados. Los usuarios escribirán “crear factura” tan fácilmente como “factura 10492”. No los obligues a adivinar en qué modo están.
¿Necesito ARIA combobox, o puedo usar solo una lista bajo un input?
Puedes usar un modelo más simple textbox + listbox, pero aún debes proporcionar roles correctos y anuncios de selección. Un combobox medio implementado es peor que un modelo simple bien implementado.
¿Cuántos resultados debo mostrar?
Empieza con 8–12 filas visibles y permite scroll para más. Una paleta es para acciones rápidas; mostrar 50 resultados sin virtualización es un impuesto de rendimiento y cognitivo.
¿La lista debe actualizarse en cada pulsación?
Sí, pero eso no significa que llames al backend en cada pulsación. Actualiza la UI inmediatamente (estado de carga), luego trae con debounce + cancelación.
¿Cómo manejo IME/composición de entrada?
No trates las actualizaciones de composición como consultas finalizadas. Evita disparar solicitudes durante la composición; lanza la búsqueda cuando la composición termine, o cuando recibas un evento de entrada comprometida.
¿Cuál es el mejor copy para un estado vacío?
Refleja la consulta, indica el alcance, da dos sugerencias y reconoce los permisos como posibilidad. Evita culpar al usuario. No eres su profesor de idiomas.
¿Debo mostrar “búsquedas recientes” o “ítems recientes”?
Prefiere ítems recientes (lo que abrieron) sobre consultas recientes (lo que escribieron). Las consultas pueden ser sensibles. Los ítems suelen ser menos personales y más accionables. Si almacenas algo, mantenlo local y acotado.
¿Cómo evito que la paleta golpee el backend?
Debounce en cliente (pequeño), cancela solicitudes en vuelo, cachea consultas recientes y añade guardas en el backend. Luego instrumenta consultas por usuario para detectar regresiones.
¿Debería virtualizar la lista de resultados?
Sólo si realmente muestras listas grandes. La virtualización aumenta la complejidad y puede dañar la accesibilidad. Para una paleta, a menudo es mejor limitar resultados y mantener DOM simple.
¿Cómo hago que las pistas de teclado sean correctas por plataforma?
Detecta la plataforma en tiempo de ejecución y renderiza Cmd vs Ctrl. Además, no muestres pistas con muchos modificadores si el usuario está en un dispositivo solo táctil.
Conclusión: próximos pasos que realmente puedes hacer
Si quieres un modal Cmd+K que se sienta como una herramienta nativa en lugar de una superposición frágil, haz tres cosas esta semana:
- Haz los estados explícitos: inactivo, cargando, cargado, vacío, error. Deja de permitir que “Sin resultados” cubra timeouts.
- Audita el DOM de la lista de resultados: filas superficiales, IDs estables, selección como estado y sin hacks de innerHTML para el resaltado.
- Ponte el sombrero de SRE: mide tasas de petición, latencia en cola y frecuencia de estados vacíos. Añade contrapresión antes de necesitarla.
Luego lanza detrás de una feature flag. Haz canary. Vigílalo como vigilarías cualquier servicio que pueda generar picos de carga. Porque eso es exactamente lo que es: un servicio, con un teclado.