Inicio / Artículos / Veinticinco escenarios de entrevista en JavaScript basados en errores reales de producción.

Veinticinco escenarios de entrevista en JavaScript basados en errores reales de producción.

Carreras, estrés, pagos idempotentes, fugas de datos, hidratación, cachés WeakMap, pruebas inestables y capas compartidas de fetch: con las respuestas que realmente quieren los entrevistadores.

4513 palabras

De IA

Vigentes preguntas de JavaScript relacionadas con la producción

Las tarjetas de estudio para entrevistas siguen preguntando qué devuelve typeof null, cómo funciona el elevamiento de variables o cómo polyfill bind. Esas preguntas son fáciles de calificar y también fáciles de falsificar después de memorizarlas durante un fin de semana. Luego, el mismo candidato entrega una caja de búsqueda que muestra resultados para una consulta que el usuario ya eliminó.

Los equipos con tráfico real cambiaron el formato. En lugar de pedir una explicación sobre el bucle de eventos, muestran una pantalla de análisis congelada después de un cambio en el rango de fechas, entregan el fragmento de código y preguntan qué está saliendo mal. El mismo conocimiento subyacente; una señal diferente. Uno verifica la memorización. El otro comprueba si alguien puede razonar sobre un sistema desconocido mientras es observado.

La brecha se manifiesta más rápidamente en tres zonas: el comportamiento asíncrono en redes reales (respuestas fuera de orden, promesas que se resuelven pero ya no son relevantes), la memoria (nada falla si una laptop se deja encendida durante seis minutos) y los fallos (un proveedor devuelve HTTP 200 con un cuerpo de error en HTML y response.json() falla a las 2 a.m.).

A continuación se presentan veinticinco escenarios extraídos de errores que realmente llegan a la producción: paneles lentos, solicitudes duplicadas, aumento de la memoria con los cambios de ruta, resultados de búsqueda obsoletos, cargos duplicados, suposiciones de autenticación incorrectas, tablas con veinte mil filas y APIs que están “activas” el 96 % del tiempo. Cada caso incluye la situación, la respuesta que buscan los entrevistadores, un breve esquema de código, un enfoque común erróneo, una posible acción posterior y lo que se está midiendo.

Los ejemplos se presentan primero en JavaScript, con notas de TypeScript únicamente cuando los tipos modifican el resultado. Niveles: principiante para quienes están listos para un nivel intermedio, intermedio para la mayoría de las pantallas avanzadas, avanzado para conversaciones con personal directivo.

Principiante — fundamentos de producción

Esto distingue a quienes ya han lanzado productos de aquellos que solo han completado tutoriales. Nada complejo; todo esto puede fallar en aplicaciones reales.

Debounce vs throttle para búsquedas y desplazamiento

Escenario. Un campo de búsqueda envía una solicitud con cada tecla pulsada. El producto requiere menos llamadas sin que la escritura parezca lenta. Los controladores de desplazamiento en otras partes necesitan actualizaciones continuas pero limitadas.

Pregunta. ¿Cuándo es debounce la herramienta adecuada frente a throttle, y cómo se implementa de forma segura en React?

Respuesta. El control de tasa garantiza una llamada a un ritmo fijo, lo cual es adecuado para el desplazamiento y el cambio de tamaño. El retraso en la llamada espera hasta que cese la escritura, lo cual se ajusta a la intención de búsqueda. Comience con un tiempo entre un cuarto de segundo y trescientos milisegundos, y ajuste según las métricas. Combine el retraso en la llamada con una longitud mínima de consulta y con la cancelación de solicitudes; el retraso por sí solo no soluciona las respuestas fuera de orden.

function debounce(fn, wait = 250) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), wait);
  };
}

const search = debounce((q) => {
  if (q.length < 2) return;
  fetchResults(q);
});

Enfoque incorrecto. Crear un envoltorio con retraso en cada renderizado. Los temporizadores se reinician con nuevos cierres, por lo que el retraso en la llamada nunca se activa realmente. Estabilice la situación con useMemo/useRef y borre los valores al desmontar el componente.

Pregunta adicional. El usuario escribe rápidamente y luego borra el campo. ¿Seguirá realizándose la llamada con retraso usando el último valor no vacío? ¿Cómo interactúan la cancelación al desmontar y al borrar el campo?

Medido. La elección de la herramienta se basa en la intención de experiencia de usuario, además de la comodidad con cierres que permanecen válidos en todas las renderizaciones.

Veinte mil listeners de clic

Escenario. Una cuadrícula de datos asigna onClick a cada acción de fila. Con 20,000 filas, la página tarda segundos en volverse interactiva y el consumo de memoria aumenta al navegar entre páginas.

Pregunta. ¿Cómo se debería reestructurar el manejo de eventos?

Respuesta. Utilizar la delegación de eventos. Un único listener en el contenedor lee el elemento objetivo a medida que los eventos ascienden; una sola función en memoria, sin necesidad de volver a vincular cuando cambian las filas, y que funciona también con filas añadidas posteriormente. Identificar la fila y la acción mediante atributos de datos y closest, ya que los clics suelen producirse en un ícono dentro del botón y no directamente en el propio botón.

grid.addEventListener('click', (event) => {
  const button = event.target.closest('[data-action]');
  if (!button || !grid.contains(button)) return;

  const { action } = button.dataset;
  const rowId = button.closest('tr')?.dataset.rowId;
  handleAction(action, rowId);
});

Enfoque incorrecto. Sigue siendo necesario conectar miles de listeners de fila con la esperanza de que un bucle de limpieza los elimine. El costo de configuración permanece, y las referencias a funciones no coincidentes fallan silenciosamente al intentar desconectarse.

Seguimiento. En React 17+, ¿dónde registra la biblioteca su listener raíz, y cómo deben coexistir los controladores nativos delegados con el mecanismo de propagación de eventos sintéticos?

Evaluación práctica. Si el candidato considera la cantidad de listeners como un verdadero límite de rendimiento, y no solo como una elección estilística.

Una promesa que se resuelve no es lo mismo que una promesa que sigue siendo relevante.

Nivel intermedio: async, memoria y rendimiento

Es aquí donde se deciden la mayoría de las entrevistas para puestos avanzados. Las preguntas parecen sesiones de depuración porque ese es precisamente su objetivo.

El panel de control que se congela durante dos segundos

Escenario. Al cambiar el rango de fechas, la pestaña se congela. Los clics no surten efecto, las animaciones se detienen y el spinner nunca gira. La llamada a la red en sí tarda 180 ms.

Pregunta. ¿Por qué el spinner no se anima y dónde se ha ido ese tiempo?

Respuesta. El hilo principal multiplexa las tareas de script, diseño, dibujo e entrada. Si se mantiene la pila de llamadas el tiempo suficiente, ni siquiera un spinner puede dibujarse, ya que el frame en el que debería mostrarse nunca se ejecuta. Los 180 ms corresponden a la red; el congelamiento se debe al trabajo síncrono posterior: analizar un enorme payload JSON y luego aplicar operaciones map/filter a decenas de miles de filas, asignando un nuevo array en cada una.

Transfiera las transformaciones complejas a un Web Worker, divida el trabajo en partes y haga pausas entre ellas; además, solicite los datos agregados al backend cuando sea posible.

// Yield to the event loop between chunks so input and paint can run
async function processInChunks(items, fn, chunkSize = 500) {
  const out = [];
  for (let i = 0; i < items.length; i += chunkSize) {
    for (const item of items.slice(i, i + chunkSize)) out.push(fn(item));
    await new Promise((r) => setTimeout(r, 0));
  }
  return out;
}

Enfoque incorrecto. Utilizar async/await dentro de un bucle que consume muchos recursos de la CPU y considerarlo no bloqueante. No aparece ningún hilo adicional; el trabajo síncrono sigue congelando la pestaña.

Seguimiento. Explicar la diferencia entre microtareas y macrotareas en este caso de congelamiento, y por qué usar await Promise.resolve() sigue generando períodos largos de trabajo síncrono.

Medición. Entender la concurrencia en JS como la programación de tareas, y saber cuándo se necesitan trabajadores adicionales.

De la IA

Resultados de búsqueda para una consulta que el usuario ya eliminó

Escenario. Al escribir “sam” y luego ajustar la búsqueda a “samantha”, a veces se muestran resultados de “sam” después de que ya hayan aparecido los de “samantha”. Es difícil reproducirlo con un enlace rápido.

Pregunta. ¿Qué está sucediendo y cuál es la solución adecuada?

Respuesta. Una carrera entre respuestas fuera de orden. Se envían dos solicitudes; la más lenta corresponde a la consulta anterior; aquella que se resuelve por última ganará la actualización de estado. Es preferible utilizar ambas medidas: cancelar la solicitud anterior con AbortController y proteger la escritura de estado para que solo la respuesta de la entrada actual pueda confirmarse.

const controllerRef = useRef(null);

async function search(query) {
  controllerRef.current?.abort();
  const controller = new AbortController();
  controllerRef.current = controller;

  try {
    const res = await fetch(`/api/customers?q=${encodeURIComponent(query)}`, {
      signal: controller.signal,
    });
    setResults(await res.json());
  } catch (err) {
    if (err.name !== 'AbortError') throw err;
  }
}

Enfoque incorrecto. Alargar el tiempo de espera hasta un segundo completo y declarar victoria. Las carreras se vuelven más raras, la experiencia de usuario empeora, y las redes lentas siguen reordenando las respuestas.

Pregunta adicional. ¿Cuándo un ID de solicitud que aumenta de forma monótona superaría a AbortController para descartar cargas de búsqueda obsoletas?

Medición. Nombrar correctamente las carreras y evitar que el ruido generado por los abortos aparezca en los paneles de errores.

La misma solicitud, cinco veces

Caso de uso. Cinco componentes solicitan cada uno /api/current-user al cargarse. La pestaña de red muestra cinco llamadas idénticas; ocasionalmente una respuesta queda obsoleta tras una actualización de perfil.

Pregunta. ¿Cómo se eliminan las solicitudes en curso sin tener que reescribir toda la capa de datos?

Respuesta. Se debe cachear la promesa, no el resultado. Se utiliza una clave basada en la identidad de la solicitud, se devuelve la misma promesa a todos los que la solicitan, y se elimina la clave cuando esta se resuelve para poder intentar nuevamente las solicitudes fallidas y obtener datos actualizados más tarde. Bibliotecas como TanStack Query y SWR añaden reglas de invalidación y obsolescencia sobre esta idea.

const inFlight = new Map();

export function dedupedFetch(key, fetcher) {
  if (inFlight.has(key)) return inFlight.get(key);

  const promise = fetcher().finally(() => inFlight.delete(key));
  inFlight.set(key, promise);
  return promise;
}

// All five callers receive the same promise
const user = await dedupedFetch('/api/me', () => fetch('/api/me').then((r) => r.json()));

Enfoque incorrecto. Almacenar permanentemente el payload ya procesado en una variable a nivel de módulo. Las solicitudes duplicadas desaparecen, pero la interfaz muestra los datos del usuario de ayer hasta que alguien realiza un refresco forzado.

Seguimiento. Si dos llamadas adjuntan señales de aborto distintas a una promesa compartida en ejecución, ¿qué política de cancelación mantiene la integridad de ambas?

Medido. Compartir promesas en ejecución de manera cuidadosa y planificar su invalidación con antelación.

Un proveedor inestable hace que la página deje de funcionar

Escenario. El perfil, la facturación y un widget de recomendaciones de terceros se cargan al mismo tiempo. El proveedor está disponible en un ~96% de los casos. Cuando falla, toda la página muestra errores y la sección de facturación desaparece.

Pregunta. ¿Cómo debería reestructurarse la solicitud de datos, y en qué se diferencian Promise.all y Promise.allSettled en este caso?

Respuesta. Promise.all rechaza la promesa cuando alguna de las entradas lo hace, descartando a las demás que hayan tenido éxito. Promise.allSettled, en cambio, siempre se resuelve con el estado de cada entrada: se muestra lo que tuvo éxito y se maneja adecuadamente el resto. Mantenga los procesos de facturación y el perfil en la ruta crítica; establezca plazos cortos para las recomendaciones para que un proveedor con problemas no pueda bloquear el sistema, incluso si posteriormente devuelve un resultado exitoso.

const [profile, billing, recs] = await Promise.allSettled([
  getProfile(),
  getBilling(),
  withTimeout(getRecommendations(), 2000),
]);

if (profile.status === 'rejected' || billing.status === 'rejected') {
  return renderError();
}
render({
  profile: profile.value,
  billing: billing.value,
  recs: recs.status === 'fulfilled' ? recs.value : [],
});

Enfoque incorrecto. Asignar valores null a los fallos para que Promise.all siga funcionando sin problemas. De esta manera, la telemetría pierde información sobre el motivo del rechazo y todos los fallos parecen idénticos.

Seguimiento. Elija entre usar Promise.any y Promise.race, y explique qué ocurre con las promesas que no logran tener éxito.

Métricas relevantes. Dominio de las combinaciones de promesas más la capacidad para evaluar el impacto potencial de los fallos.

El pago que se cargó dos veces

Situación. El proceso de pago se agota en el gateway. La interfaz frontal vuelve a intentarlo. Al cliente se le cobra dos veces.

Pregunta. ¿Qué estrategia de reintentos es segura para un endpoint de pago?

Respuesta. Los reintentos solo son seguros para operaciones idempotentes. Una solicitud POST que crea un cargo no es idempotente por defecto; hay que hacerla así mediante una clave de idempotencia generada por el cliente para que el servidor la elimine en caso de duplicados. Solo realice reintentos por fallos de transmisión y respuestas 5xx/429; nunca por respuestas 4xx. Espacie los intentos con un retroceso exponencial más variabilidad aleatoria para que el servidor no se sobrecargue. Respete las cabeceras Retry-After y establezca un límite máximo para el número de intentos.

async function postWithRetry(url, body, key, attempts = 3) {
  for (let i = 0; i < attempts; i++) {
    const res = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Idempotency-Key': key },
      body: JSON.stringify(body),
    });
    if (res.ok) return res.json();
    if (res.status < 500 && res.status !== 429) throw new HttpError(res);

    const backoff = 2 ** i * 300 + Math.random() * 300; // jitter
    await new Promise((r) => setTimeout(r, backoff));
  }
  throw new Error('Payment could not be confirmed');
}

Enfoque incorrecto. Volver a intentar todo tres veces de forma ciega según un temporizador fijo. Los tiempos de espera pueden causar cargos duplicados, los bucles 4xx nunca tienen éxito y las interrupciones se agravan hasta provocar sobrecargas.

Seguimiento. El navegador alcanzó el tiempo de espera, pero el cobro se procesó en el servidor; describa el proceso de recuperación visible para el usuario.

Medido. Diseño idempotente ante incertidumbres en el lado del cliente.

La API que se queda atascada en lugar de fallar

Escenario. Un proveedor deja de responder sin cerrar las conexiones. fetch nunca se resuelve; los manejadores se acumulan; los indicadores de carga siguen funcionando durante minutos.

Pregunta. ¿Cómo se controla esto, y qué ocurre más allá del tiempo de espera?

Respuesta. Los navegadores no proporcionan un tiempo de espera predeterminado para fetch; es necesario especificar uno. AbortSignal.timeout representa el enfoque moderno; AbortSignal.any combina el tiempo de espera con la cancelación por parte del usuario. Añada un mecanismo de protección: tras fallos consecutivos, detenga las llamadas durante un período de enfriamiento y sirva inmediatamente una solución alternativa, protegiendo tanto la aplicación como la dependencia que está intentando recuperarse.

const signal = AbortSignal.any([
  AbortSignal.timeout(3000),
  userController.signal,
]);

try {
  const res = await fetch(url, { signal });
  breaker.recordSuccess();
  return res.json();
} catch (err) {
  breaker.recordFailure();
  if (err.name === 'TimeoutError') return cachedFallback();
  throw err;
}

Enfoque incorrecto. Competir con un temporizador contra fetch y fingir que el perdedor se detuvo. La llamada HTTP sigue ejecutándose, mantiene los sockets abiertos y aún puede modificar el estado cuando finalmente termine.

Seguimiento. Implemente tiempos de espera en el navegador, la pasarela API y las llamadas a fuentes externas en Node del mismo proveedor, sin dejar tareas sin resolver.

Métricas relevantes. Desconfianza predeterminada hacia las dependencias y cancelación real frente a tareas abandonadas.

La memoria aumenta con cada cambio de ruta

Situación. Una herramienta de soporte dejada abierta todo el día alcanza los 1,4 GB. Las capturas del heap muestran que los nodos DOM separados aumentan en cantidad con cada navegación por un ticket.

Pregunta. Causas habituales y cómo confirmarlas?

Respuesta. Los nodos separados permanecen activos porque algo sigue haciendo referencia a ellos: los listeners de window/document que nunca se eliminan, setInterval que sigue funcionando, IntersectionObserver/ResizeObserver que nunca se desconectan, los listeners del almacén global y las cierres en cachés de larga duración que capturan el DOM. Para confirmarlo, se debe tomar una captura del heap, navegar, forzar la recolección de basura, tomar otra captura y luego examinar los retentores hasta identificar claramente quién los mantiene activos.

useEffect(() => {
  const onResize = () => recalcLayout();
  const observer = new ResizeObserver(onResize);
  const id = setInterval(pollTicket, 5000);

  window.addEventListener('resize', onResize);
  observer.observe(panelRef.current);

  return () => {
    window.removeEventListener('resize', onResize);
    observer.disconnect();
    clearInterval(id);
  };
}, []);

Enfoque incorrecto. Se ponen en null los valores locales durante la limpieza con la esperanza de que la memoria disminuya. La accesibilidad sigue existiendo a través de los listeners activos y los temporizadores que acceden a los mismos datos.

Seguimiento. Se denomina a este patrón de fugas un problema que se soluciona fácilmente con WeakMap, y a situaciones en las que las referencias débiles no son la herramienta adecuada.

Medición. Depuración práctica del heap y conocimientos sobre la accesibilidad de los datos.

El contador que siempre registra cero

Escenario. Un widget hace consultas cada cinco segundos y agrega alertas. Siempre muestra una sola alerta; un registro dentro del intervalo imprime el estado inicial de forma permanente.

Pregunta. ¿Por qué el intervalo muestra un estado obsoleto, y cómo solucionarlo?

Respuesta. El efecto se ejecutó una vez con [], por lo que el callback conservó el estado de la primera renderización. Las actualizaciones crean nuevos valores; el closure anterior sigue apuntando al antiguo. Es preferible utilizar actualizadores funcionales para que React proporcione el valor más reciente de la cola. Cuando el callback necesita datos que un actualizador no puede proporcionar, reflejelos en un ref en cada renderización.

// Broken: `alerts` is frozen at the first render
useEffect(() => {
  const id = setInterval(() => setAlerts([...alerts, poll()]), 5000);
  return () => clearInterval(id);
}, []);

// Fixed: functional update, no stale capture
useEffect(() => {
  const id = setInterval(() => setAlerts((prev) => [...prev, poll()]), 5000);
  return () => clearInterval(id);
}, []);

Enfoque incorrecto. Incluir alerts como dependencia del efecto. Los closures obsoletos desaparecen, pero el intervalo se reinicia en cada adición, por lo que la cadencia de cinco segundos se ve afectada.

Pregunta adicional. ¿Cómo podría un hook personalizado mantener una cadencia de consultas cada cinco segundos mientras siempre lee el estado más reciente?

Análisis. Closures que viajan en el tiempo: un error clásico de nivel intermedio en React.

Veinte mil filas en el DOM

Escenario. Una tabla de inventario muestra cada registro de la API. La pintura inicial tarda seis segundos; el filtrado genera retrasos; la pestaña consume cientos de megabytes.

Pregunta. ¿Cómo hacer que la tabla sea utilizable y qué medir primero?

Respuesta. Primero realice un perfilamiento: script, estilo, diseño y proceso de pintura. En tablas de este tamaño, el número de nodos DOM suele ser el factor determinante. Virtualice la lista para que solo estén en el DOM el área visible más un pequeño buffer de sobrescaneo. Combine esto con identidades estables para las filas, una memorización cuidadosa y filtros/ordenamientos en el servidor cuando los conjuntos de datos del cliente se vuelvan demasiado grandes.

// Row identity matters as much as row count
{visibleRows.map((row) => (
  <Row key={row.id} data={row} />   // stable id, not the array index
))}

Enfoque incorrecto. Recubrir todas las filas con React.memo y detenerse ahí. Los propios valores inline anulan el efecto de memoización, y el diseño y la pintura siguen siendo sobrecargados con decenas de miles de nodos.

Seguimiento. ¿Qué sucede con el enfoque y las entradas controladas cuando las claves de fila son índices de array y se elimina una fila intermedia?

Medición. Hábitos de medir primero y límites realistas de la memorización.

De la IA

Problemas de diseño causados por bucles de filtrado/redimensionamiento

Escenario. Después de cargar los datos, un panel de control redimensiona los contenedores de gráficos. Los perfiles muestran largos bloques de estilo/diseño morados que se repiten cientos de veces en un mismo frame.

Pregunta. ¿Qué patrón causa esto y cómo solucionarlo?

Respuesta. Problemas de layout. Al acceder a las API de geometría como los desplazamientos en altura, los rectángulos delimitadores o las posiciones de desplazamiento horizontal, se fuerza una actualización síncrona del estilo y el layout para que el motor pueda responder con precisión. Alternar esas lecturas con escrituras de estilo dentro de un bucle implica pagar ese costo adicional en cada iteración. Recoja primero las mediciones, modifique los estilos después y programe las escrituras con requestAnimationFrame. Para la lógica de mostrar/ocultar, utilice IntersectionObserver para que la visibilidad se establezca de forma asíncrona sin forzar el layout.

// Broken: read, write, read, write
panels.forEach((p) => { p.style.height = p.offsetHeight * 1.2 + 'px'; });

// Fixed: batch reads, then batch writes
const heights = panels.map((p) => p.offsetHeight);
requestAnimationFrame(() => {
  panels.forEach((p, i) => { p.style.height = heights[i] * 1.2 + 'px'; });
});

Enfoque incorrecto. Programar cada escritura en su propio frame de animación mientras las lecturas siguen intercalándose. El costo se distribuye a lo largo de los frames y las interrupciones en la animación duran más tiempo.

Pregunta adicional. ¿Qué propiedades de CSS permanecen en el compositor y cuándo will-change genera más costo del que ahorra?

Medido. Los costos del pipeline del navegador se presentan como una secuencia, no como algo misterioso.

Estar esperando un cálculo síncrono no libera el bucle de eventos.

Avanzado: seguridad, Node, pruebas y diseño

Los responsables a nivel de equipo se preocupan menos por la solución definitiva y más por los compromisos, el alcance de las consecuencias y la responsabilidad asignada.

El campo del CMS que ejecutó un script

Escenario. El departamento de marketing almacena texto enriquecido en un CMS sin interfaz. Una revisión de seguridad detecta que <img onerror=...> se ejecuta en cada página de producto.

Pregunta. ¿Cómo ocurrió esto y cómo solucionarlo en toda la estructura?

Respuesta. El valor se insertó en innerHTML o en dangerouslySetInnerHTML de React sin ser sanitizado. Por defecto, los caracteres especiales se escapan ({value} en React, textContent en el DOM). Cuando se necesita HTML, se debe sanitizar con una biblioteca que mantenga una lista de elementos permitidos, como DOMPurify, también en el servidor, ya que el cliente no constituye un límite de confianza. Se debe añadir una Política de Seguridad de Contenido para que un payload no procesado no pueda ejecutar scripts incrustados.

import DOMPurify from 'dompurify';

const clean = DOMPurify.sanitize(cmsHtml, {
  ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a', 'ul', 'li'],
  ALLOWED_ATTR: ['href', 'title'],
});
<div dangerouslySetInnerHTML={{ __html: clean }} />

Enfoque incorrecto. Eliminar las etiquetas <script> con expresiones regulares. Los atributos de los manejadores de eventos, las URLs javascript:, los vectores SVG y las codificaciones anidadas pasan desapercibidos.

Seguimiento. Las herramientas de análisis insertan scripts incrustados y la CSP los bloquea; se debe restaurar la etiqueta sin activar unsafe-inline.

Medido. Defensas en capas contra XSS con sanitización en el momento de la renderización.

El token en localStorage

Escenario. Tras un ataque XSS, las notas de seguridad indican que los tokens de sesión se almacenan en localStorage mediante un interceptor de Axios. Quieren un plan.

Pregunta. ¿Cuáles son las ventajas y desventajas de usar localStorage frente a cookies para los tokens de autenticación, y qué recomendación hay?

Respuesta. Los scripts en el origen pueden leer localStorage, por lo que un único ataque XSS puede exportar toda la sesión. Las cookies HttpOnly Secure con SameSite=Lax permanecen invisibles para JavaScript, pero los navegadores las añaden automáticamente, por lo que las solicitudes modificadas siguen necesitando protección CSRF. Un diseño común: un token de acceso de corta duración en memoria, un token de actualización en una cookie HttpOnly, tokens CSRF o la opción SameSite para las modificaciones, y rotación al actualizar. Nada escapa intacto de un ataque XSS; la prevención de XSS sigue siendo prioritaria.

// Server side, Express
res.cookie('refresh_token', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'lax',
  path: '/auth/refresh',
  maxAge: 1000 * 60 * 60 * 24 * 7,
});

Enfoque incorrecto. Encriptar los tokens en localStorage con una clave que también se encuentra en el JavaScript de la página. Cualquiera que pueda leer el almacenamiento puede leer también la clave: es pura fachada.

Pregunta adicional. Si los tokens de acceso solo permanecen en memoria, ¿cómo obtiene una pestaña recién abierta una sesión?

Medido. Elecciones de almacenamiento de tokens basadas en modelos de amenazas en lugar de eslóganes.

El punto final de Node que ralentiza a todos los demás

Escenario. Un procesador de informes PDF de Express, al ser llamado, hace que las verificaciones de estado no relacionadas se agoten y aumente el valor p99 en todo el servicio.

Pregunta. ¿Por qué un punto final afecta a todos los demás y cómo solucionarlo?

Respuesta. Node ejecuta el JavaScript de la aplicación en un solo hilo. El trabajo intensivo en CPU bloquea el bucle de eventos, impidiendo que se procesen otras solicitudes, temporizadores o devoluciones de llamada de E/S. Transfiere el trabajo que consume mucha CPU a worker_threads o a una cola externa para que el procesador HTTP solo se encargue de encolar solicitudes y responder. Transmite cargas grandes por streaming en lugar de almacenarlas en búfer. Prefiere las API criptográficas asíncronas a las variantes Sync; nunca uses readFileSync en una ruta de solicitud.

import { Worker } from 'node:worker_threads';

app.post('/reports', async (req, res) => {
  const job = await queue.add('generate-report', req.body); // returns immediately
  res.status(202).json({ jobId: job.id, status: 'queued' });
});

// Or for in process CPU work
const worker = new Worker('./report-worker.js', { workerData: params });

Enfoque incorrecto. Se espera que el trabajo intensivo en la CPU termine para que el bucle de eventos pueda seguir funcionando. La escalabilidad horizontal multiplica las instancias facturables, pero todas siguen estancándose ante el mismo tipo de solicitud.

Seguimiento. ¿Qué indicadores en producción revelan que el bucle de eventos de Node está bloqueado antes de que los clientes se quejen?

Medición. Separar el trabajo intensivo en E/S del que lo es en la CPU, con indicadores de producción que correspondan.

Mostrar contenido incorrecto al cargar (hidratación)

Escenario. Una aplicación Next.js muestra “Iniciar sesión” en el servidor y luego cambia al nombre del usuario después de cargar la página. Aparecen advertencias de desajuste en la hidratación; a veces se muestra El contenido de texto no coincide con el HTML generado en el servidor.

Pregunta. ¿Qué causa estos desajustes en la hidratación y cómo solucionar este problema?

Respuesta. El servidor carece de estados exclusivos del navegador: cookies leídas por el cliente, localStorage, window.matchMedia, Date.now(). La fase de hidratación espera que la primera renderización del cliente coincida con el árbol del servidor. Si hay discrepancia, React descarta el marcado del servidor para ese subárbol y emite una advertencia. Para mostrar la sesión durante la renderización en el servidor, se deben leer las cookies allí y pasar ese valor al árbol. Para los valores que realmente existen solo en el navegador, se debe mostrar un marcador temporal estable y actualizarlo después del montaje.

// Server component reads the session, so no mismatch
export default async function Layout({ children }) {
  const session = await getSession(cookies());
  return <AuthProvider value={session}>{children}</AuthProvider>;
}

Enfoque incorrecto. Silenciar las advertencias de hidratación en un contenedor o dinamizar el encabezado para omitir la SSR. La discrepancia persiste, y los beneficios de la SSR desaparecen.

Pregunta adicional. ¿Cómo se pueden renderizar marcas de tiempo relativas en el servidor sin que la hidratación entre en conflicto con el reloj del cliente?

Medido. Corregir la hidratación de forma adecuada en lugar de silenciar las advertencias.

El caché que nunca suelta

Escenario. Una biblioteca de creación de gráficos almacena en caché los canvis según los elementos DOM. La memoria aumenta después de que los gráficos abandonen la página. Las capturas muestran buffers retenidos por un Map.

Pregunta. ¿Por qué no se recogen las entradas, y cómo cambia WeakMap el resultado?

Respuesta. El recolector de basura accede a los elementos desde las raíces. Las entradas normales de Map fijan tanto la clave como el valor, por lo que un nodo DOM separado utilizado como clave mantiene su buffer de canvas accesible. Un WeakMap almacena las claves de forma débil: cuando nada más hace referencia al elemento, la entrada puede desaparecer junto con él. WeakMap no es enumerable y no tiene tamaño; este compromiso hace que su uso sea seguro.

// Leaks: the Map keeps removed nodes alive
const cache = new Map();

// Collectable: entry dies with the element
const cache = new WeakMap();
cache.set(chartEl, renderedCanvas);

Enfoque incorrecto. Procesos similares a Cron que eliminan las entradas de caché cuyos nodos han dejado el documento. Funciona bien hasta que se olvida de ejecutarlos; WeakMap se encarga de eliminar esa tarea.

Seguimiento. ¿Cuándo es apropiado usar FinalizationRegistry, y por qué la corrección nunca debe depender del momento en que se ejecuta?

Métricas. El recolector de basura como medida de alcanzabilidad, sin pretender que el tiempo de recopilación sea controlable.

La prueba que falla una vez cada veinte ejecuciones

Escenario. Una prueba de un componente de búsqueda pasa localmente pero falla en aproximadamente el 5% en los entornos CI al buscar el texto “Samantha”. Alguien ya agregó waitFor(3000) y los entornos CI intentan ejecutarla nuevamente.

Pregunta. ¿Cómo hacer que la prueba sea fiable, y qué nos indica su inestabilidad?

Respuesta. Las pruebas asíncronas inestables suelen probar el tiempo de ejecución en lugar del comportamiento. Controla la atenuación con temporizadores falsos, simula las solicitudes HTTP con herramientas como MSW para cargas estables, y utiliza consultas que esperen las actualizaciones del DOM en lugar de esperar un número fijo de milisegundos. Si se requiere una pausa arbitraria para que pase la prueba, eso suele indicar un verdadero problema de concurrencia en el componente; la prueba está funcionando correctamente.

test('shows results for the final query', async () => {
  vi.useFakeTimers();
  render(<Search />);

await userEvent.type(screen.getByRole('searchbox'), 'samantha');
  await vi.advanceTimersByTimeAsync(300); // debounce window
  expect(await screen.findByText('Samantha')).toBeInTheDocument();
});

Enfoque incorrecto. Reintentos en CI y tiempos de espera cada vez mayores. La señal se convierte en ruido, y un conjunto de pruebas reintentado tres veces puede ocultar un problema de concurrencia que luego afecte al entorno de producción.

Pregunta adicional. ¿Cómo podría una prueba forzar que la respuesta de búsqueda anterior llegue después de la más reciente?

Solución. Tratar las pruebas inestables como señales y hacer que las pruebas asíncronas sean deterministas.

Sesenta lugares que llaman a fetch

Escenario. Un código con cuatro años de antigüedad utiliza fetch en sesenta componentes. El manejo de errores es inconsistente; la actualización de autenticación se copió y pegó once veces; nadie conoce los conteos de tiempo de espera diarios.

Pregunta. ¿Cómo diseñar una capa compartida de acceso a datos y cómo migrar sin interrumpir el funcionamiento?

Respuesta. Agrupe las funcionalidades compartidas en un único cliente: URL base y encabezados, actualización de autenticación única para que una ola de errores 401 provoque solo una actualización, tiempo de espera definido, intentos de reintentar únicamente si son idempotentes, normalización de errores tipada y conectores para telemetría. Mantenga la interfaz simple para que su adopción supere cualquier intento de eludirla. Migrar de forma incremental: envíe primero el cliente, traslade las rutas con mayor tráfico y más propensas a errores, prohíba el uso de fetch sin procesamiento en el código nuevo mediante herramientas de análisis, para así evitar que los límites cambien.

type ApiError =
  | { kind: 'network' }
  | { kind: 'timeout' }
  | { kind: 'http'; status: number; body: unknown }
  | { kind: 'parse' };

export async function apiRequest<T>(
  path: string,
  init: RequestInit & { timeoutMs?: number } = {},
): Promise<{ ok: true; data: T } | { ok: false; error: ApiError }> {
  // timeout, auth, retry, telemetry all live here
}

Enfoque incorrecto. Proponer una migración desde cero o envolver las respuestas de manera tan estricta que los casos excepcionales terminen utilizando el fetch en bruto. Ambos enfoques dejan dos capas de datos en competencia.

Seguimiento. Seis meses después, ¿cómo evitan las reglas de validación y las revisiones de código que el fetch en bruto vuelva a utilizarse?

Métricas. Diseño de API a escala de equipo y migración incremental bajo presión de entrega.

Cierre: prepárese sin memorizar

La preparación para pruebas triviales tiene un límite: se memorizan tablas de coerción y los paneles de control dejan de funcionar sin explicación. La preparación para escenarios tiene otro modo de fallar: conocer la historia pero no el mecanismo. Evite ambos casos trabajando con código real.

Reproducir intencionadamente los fallos. Envíe un campo de búsqueda que funcione lentamente debido a la limitación de ancho de banda, luego abra las pestañas de Rendimiento y Red hasta que sea evidente el problema. Deje un intervalo sin resolver, navegue fuera y busque en la sección de Memoria el árbol desvinculado. Los caminos en los DevTools, aprendidos de forma práctica, permanecen más tiempo en la memoria que cualquier guía escrita.

Acostúmbrese a medir antes de dar respuestas preestablecidas. Las pestañas de Rendimiento, Memoria y Red de Chrome, junto con el React Profiler, convierten muchas preguntas “avanzadas” en cuestiones de instrumentación comunes.

Practique nombrar en voz alta las compensaciones implicadas. Casi cada escenario permite más de una solución viable, cada una con costos diferentes. Los entrevistadores experimentados notan si la elección fue intencionada.

Lea sobre incidentes en producción. Cualquiera que ya haya lanzado un producto conoce al menos cinco escenarios de este tipo. Esas historias son mejores que las tomadas prestadas, ya que los análisis posteriores pueden profundizar indefinidamente.

Mantén una lista breve de fallos con sus causas y soluciones: notas, no un portafolio. Cuando te pregunten sobre un error grave, una diagnosis específica siempre es mejor que una explicación general.

Tanto para principiantes como para avanzados, el patrón se repite: identifica el modo de fallo, propone una solución acorde a la amenaza y entiende qué haría que una solución incorrecta pareciera viable bajo presión. Esa es la habilidad que realmente buscan en las entrevistas de contratación: no una tabla perfecta de coerción, sino la capacidad de mantener los sistemas honestos cuando las redes mienten, la memoria se agota y los proveedores fallan.

Los equipos que realizan entrevistas de esta manera también tienden a llevar a cabo análisis posteriores más eficaces: el mismo vocabulario (carreras, estrés extremo, idempotencia, radio de impacto) aparece tanto en los procesos de contratación como en las revisiones de incidentes. Por lo tanto, estudiar estos escenarios rinde doble beneficio: una vez en la sala de entrevistas y otra vez cuando un panel se congela durante dos segundos mientras el indicador permanece completamente inmóvil.

Los procesos de entrevista basados en situaciones reales de producción también revelan las habilidades de comunicación. Narrar una situación de estrés extremo sin saturarse de jerga, dibujar rápidamente un diagrama de secuencias para AbortController o explicar por qué allSettled reduce el radio de impacto demuestran cómo actuará una persona en un canal de incidentes. Las respuestas a preguntas triviales rara vez demuestran esa capacidad, mientras que las respuestas a escenarios casi siempre lo hacen, y por eso este formato sigue extendiéndose entre los equipos de contratación de nivel medio y senior.

Lecturas relacionadas