Escribes un fetch(), le agregas un .catch() «por si algo sale mal», y sigues adelante confiado. Tu API responde con un error 404 porque el usuario pidió un producto que ya no existe. Esperas que ese .catch() se active. No lo hace. Tu código sigue como si nada, intenta leer datos que nunca llegaron, y la pantalla muestra contenido vacío o roto sin ningún mensaje de error visible en ningún lado. El motivo tiene una explicación exacta, documentada por Mozilla, y contradice lo que la mayoría de principiantes asume sobre cómo funciona fetch() — una suposición razonable que, sin corregir, genera exactamente el tipo de bug silencioso más difícil de rastrear en producción.
Lo que la mayoría asume (y por qué es razonable asumirlo)
Es natural pensar que una Promise «rechaza» cuando algo sale mal — es, literalmente, para lo que existe .catch(). Un error 404 o 500 claramente representa «algo salió mal», así que parece lógico que fetch() rechace la Promise en esos casos también.
fetch("https://api.ejemplo.com/producto/999")
.then(response => response.json())
.then(data => mostrarProducto(data))
.catch(error => mostrarError("Producto no encontrado"));Este código se ve razonable. Se comporta exactamente como esperas… hasta que el producto 999 no existe.
Lo que realmente pasa, según la documentación oficial
La documentación oficial de fetch() en MDN Web Docs es explícita al respecto: la Promise de fetch() solo rechaza cuando ocurre un error de red — una URL mal formada, o una falla de conexión. No rechaza cuando el servidor responde con un código de error HTTP como 404 o 500. En esos casos, la Promise se resuelve con normalidad, entregando un objeto Response con la propiedad ok en false.
Esto significa que en el ejemplo anterior, cuando el producto 999 no existe, el servidor probablemente respondió con un 404 — y fetch() considera eso una respuesta exitosa desde su propio punto de vista. El .then() se ejecuta con normalidad. El código intenta convertir a JSON el cuerpo del error (que puede no ser JSON válido, o ser un JSON con una estructura completamente distinta a la esperada), y el .catch() nunca llega a activarse por la razón que realmente te importaba: el producto no existía.
¿Por qué fetch() fue diseñado para comportarse así?
Porque, desde la perspectiva del navegador, la petición HTTP en sí funcionó: se envió, llegó al servidor, y volvió una respuesta completa. El código de estado (200, 404, 500) es parte del contenido de esa respuesta, no una indicación de que la comunicación falló. La guía de uso de Fetch API de MDN confirma este mismo criterio: la Promise se cumple en cuanto el navegador recibe estado y encabezados del servidor, sin importar cuál sea ese estado — sos vos quien decide, revisando response.ok o response.status, si esa respuesta representa éxito o fracaso para tu aplicación.
El riesgo real: fallos silenciosos que nadie nota hasta producción
Este comportamiento no es solo una curiosidad técnica — genera un tipo específico de bug que es especialmente difícil de detectar: la aplicación no se rompe visiblemente, simplemente muestra información incorrecta, vacía, o parcialmente cargada, sin ningún indicio de que ocurrió un error.
| Escenario | Qué esperas que pase | Qué pasa realmente (sin corregir) | Mitigación |
|---|---|---|---|
| API responde 404 | .catch() se activa, muestra mensaje de error | .then() se ejecuta, intenta procesar un cuerpo de error como si fueran datos válidos | Verificar response.ok antes de procesar el cuerpo |
| API responde 500 | .catch() se activa | Igual que arriba — la app puede mostrar undefined o romperse al leer propiedades inexistentes | Lanzar un error manualmente si !response.ok |
| Usuario pierde conexión a internet | .catch() se activa | Esto sí funciona como se espera — es un error de red genuino | Ningún cambio necesario, ya se maneja bien |
| URL mal escrita en el código | .catch() se activa | Esto también funciona como se espera | Ningún cambio necesario |
La fila que realmente importa es la primera y la segunda: los errores más comunes en el uso diario de una API —recursos que no existen, errores del servidor— son exactamente los que .catch() no está capturando por defecto.
Cómo se ve esto en un incidente real de producción
Imaginemos un carrito de compras que consulta el stock disponible de un producto antes de permitir agregarlo. El servidor responde con un 404 cuando el producto fue descontinuado.
fetch(`/api/stock/${productoId}`)
.then(response => response.json())
.then(data => {
if (data.disponible) {
habilitarBotonComprar();
}
})
.catch(() => mostrarError("No se pudo verificar el stock"));Cuando el producto fue descontinuado, el servidor responde 404 con un cuerpo de error como {"mensaje": "Producto no encontrado"}. Ese cuerpo sí es JSON válido —response.json() no falla—, así que el .then() continúa. La condición data.disponible evalúa a undefined, que es falsy, así que el botón de comprar simplemente no se habilita. Ningún error visible, ninguna pista en la consola, y quien prueba manualmente el sitio ve un botón deshabilitado sin saber si es un bug de disponibilidad real o un fallo silencioso de la petición. Este tipo de incidente puede pasar desapercibido en pruebas manuales durante semanas, porque el síntoma —»el botón no aparece»— es indistinguible de un producto legítimamente sin stock.
La corrección: verificar response.ok explícitamente
La solución no requiere ninguna biblioteca adicional — solo una verificación explícita antes de continuar.
fetch("https://api.ejemplo.com/producto/999")
.then(response => {
if (!response.ok) {
throw new Error(`Error ${response.status}: producto no encontrado`);
}
return response.json();
})
.then(data => mostrarProducto(data))
.catch(error => mostrarError(error.message));Ahora sí: si response.ok es false, se lanza un error manualmente dentro del then(), y ese error lanzado sí es capturado por el .catch() siguiente en la cadena. La diferencia es sutil pero crítica — fetch() nunca lanza ese error por ti; tenés que hacerlo vos mismo, explícitamente, cada vez.
La misma trampa existe con async/await (mucha gente asume que no)
Es común pensar que async/await, al usar try/catch en lugar de .then()/.catch(), se comporta de forma más «inteligente» ante errores HTTP. No es así — la regla de fondo es exactamente la misma, porque async/await es solo una forma distinta de escribir el mismo mecanismo de Promises que ya vimos en nuestra guía de programación asíncrona en JavaScript.
async function obtenerProducto(id) {
try {
const response = await fetch(`https://api.ejemplo.com/producto/${id}`);
// Sin esta verificación, un 404 sigue sin activar el catch:
if (!response.ok) {
throw new Error(`Error ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error("No se pudo obtener el producto:", error.message);
}
}La verificación de response.ok sigue siendo necesaria, línea por línea, sin importar qué sintaxis uses para manejar la Promise por fuera.
Esto conecta con un patrón más amplio de JavaScript
Si ya revisaste nuestra guía sobre por qué JavaScript adivina en vez de parar, este comportamiento de fetch() es exactamente otra instancia del mismo patrón: en lugar de detenerse ante algo ambiguo (¿un 404 es éxito o fracaso?), JavaScript avanza con su propia interpretación —»la comunicación funcionó, aquí está la respuesta»— y te deja a vos la responsabilidad de decidir si esa respuesta representa lo que necesitabas o no.
Una alternativa que sí lanza automáticamente: Axios
Vale la pena mencionar, sin necesidad de adoptarla, que no todas las herramientas para hacer peticiones HTTP se comportan igual. Axios, una biblioteca externa popular, sí rechaza su Promise automáticamente ante códigos de estado fuera del rango 200-299, sin requerir la verificación manual de response.ok. Esa diferencia de comportamiento es, de hecho, la razón por la que tanta gente se sorprende al usar fetch() por primera vez después de haber usado Axios: cada herramienta definió su propio criterio de qué cuenta como «error», y ninguna de las dos es incorrecta — simplemente no son intercambiables sin ajustar tu código.
Esto no significa que debas cambiar a Axios solo por este comportamiento. fetch() viene incluido de forma nativa en cualquier navegador moderno, sin necesidad de instalar ni cargar nada adicional, y una vez que incorporás el hábito de verificar response.ok, el comportamiento se vuelve tan predecible como el de cualquier otra herramienta.
Lista de verificación antes de dar por buena tu función fetch
- ¿Verificás
response.ok(oresponse.status) antes de procesar los datos? - ¿El mensaje de error que mostrás al usuario distingue entre «no se encontró» (404) y «error del servidor» (500)?
- ¿Probaste manualmente qué pasa cuando la API responde con un error, no solo cuando responde con éxito?
- Si usás
async/await, ¿la verificación deresponse.oksigue presente dentro del bloquetry?
Preguntas Frecuentes
¿Por qué fetch() no simplemente rechaza en cualquier error HTTP, como esperaría la mayoría?
Porque, técnicamente, la comunicación con el servidor fue exitosa — se envió la petición y se recibió una respuesta completa. El código de estado es parte del contenido de esa respuesta, no una falla de comunicación, así que fetch() lo trata como una resolución normal.
¿response.ok cubre todos los códigos de éxito?
Sí, response.ok es true para cualquier código de estado entre 200 y 299 inclusive, cubriendo todas las variantes comunes de «éxito» en HTTP, no solo el 200.
¿Necesito usar una biblioteca como Axios para evitar este problema?
No es necesario. Verificar response.ok manualmente resuelve el problema por completo con fetch() nativo, sin agregar ninguna dependencia externa a tu proyecto.
¿Este comportamiento es distinto en Node.js?
No — la implementación de fetch() en Node.js moderno sigue la misma especificación web, así que response.ok se comporta igual tanto en el navegador como en el servidor.
Antes de seguir con APIs: si todavía no revisaste cómo manejar datos de formularios que también dependen de validación explícita, nuestra guía de validar formularios con JavaScript cubre el mismo principio de fondo aplicado a otro contexto.

Martin Rojas escribe sobre tecnología, programación y desarrollo web. En Skydutz Academy comparte explicaciones prácticas sobre herramientas digitales, conceptos de programación y recursos para aprender de forma progresiva. Su objetivo es hacer que los temas técnicos sean más claros, útiles y accesibles para lectores de distintos niveles.
