Fetch API: cómo consumir APIs REST desde el navegador
Casi toda aplicación web moderna necesita comunicarse con un servidor para obtener datos: cargar publicaciones de un blog, buscar productos, enviar un formulario, autenticar a un usuario. La Fetch API es la herramienta nativa del navegador para hacer esas peticiones HTTP desde JavaScript, sin necesitar librerías externas. Es más moderna que XMLHttpRequest, está basada en Promesas y se lee de forma natural junto con async/await.
En esta guía aprenderás a hacer peticiones GET, POST, PUT y DELETE, a manejar errores correctamente y a construir un cliente HTTP reutilizable para tus proyectos.
La petición más básica: GET con fetch
fetch() recibe una URL y devuelve una Promesa que se resuelve con un objeto Response. Ese objeto representa la respuesta HTTP completa: el código de estado, las cabeceras y el cuerpo.
// Petición GET básica
fetch('https://jsonplaceholder.typicode.com/posts/1')
.then(respuesta => respuesta.json()) // parsear el cuerpo como JSON
.then(datos => console.log(datos))
.catch(error => console.error('Error:', error));
// Con async/await (más legible)
async function obtenerPost() {
const respuesta = await fetch('https://jsonplaceholder.typicode.com/posts/1');
const post = await respuesta.json();
console.log(post);
}
obtenerPost();
// { id: 1, title: "sunt aut facere...", body: "...", userId: 1 }
Fíjate en el doble await: el primero espera la respuesta HTTP (las cabeceras llegan primero), y el segundo espera a que el cuerpo se descargue y parsee como JSON. Son dos operaciones asíncronas distintas.
El error más común: asumir que 404 es un error
Esta es la confusión más frecuente con Fetch y es importante entenderla bien. La Promesa de fetch() solo se rechaza cuando hay un error de red (sin conexión, DNS que no resuelve, CORS bloqueado). Los errores HTTP como 404 o 500 no rechazan la Promesa: la Promesa se resuelve, pero el objeto Response tiene ok: false.
// ❌ Trampa común: asumir que un 404 lanza un error
async function obtenerUsuario(id) {
try {
const respuesta = await fetch(`/api/usuarios/${id}`);
const usuario = await respuesta.json(); // esto funciona aunque sea 404
return usuario; // devuelves el mensaje de error del servidor, no un usuario
} catch (error) {
// Este catch solo se ejecuta con errores de RED, no con 404 o 500
console.error('Error de red:', error);
}
}
// ✅ Verificar siempre respuesta.ok
async function obtenerUsuario(id) {
try {
const respuesta = await fetch(`/api/usuarios/${id}`);
if (!respuesta.ok) {
throw new Error(`HTTP ${respuesta.status}: ${respuesta.statusText}`);
}
const usuario = await respuesta.json();
return usuario;
} catch (error) {
console.error('Error:', error.message);
throw error;
}
}
// El objeto Response tiene varias propiedades útiles
async function inspeccionarRespuesta(url) {
const respuesta = await fetch(url);
console.log(respuesta.status); // 200, 201, 404, 500...
console.log(respuesta.statusText); // "OK", "Not Found", "Internal Server Error"
console.log(respuesta.ok); // true si status está entre 200 y 299
console.log(respuesta.url); // la URL final (puede cambiar por redirecciones)
console.log(respuesta.redirected); // true si hubo alguna redirección
// Leer las cabeceras de la respuesta
console.log(respuesta.headers.get('Content-Type'));
console.log(respuesta.headers.get('X-Total-Count'));
}
Métodos para leer el cuerpo de la respuesta
El objeto Response tiene varios métodos para leer el cuerpo según el tipo de contenido:
async function leerRespuesta(url) {
const respuesta = await fetch(url);
// JSON: el más habitual para APIs REST
const datos = await respuesta.json();
// Texto plano: HTML, CSV, texto sin formato
const texto = await respuesta.text();
// Blob: imágenes, archivos binarios, PDFs
const blob = await respuesta.blob();
const urlImagen = URL.createObjectURL(blob);
document.querySelector('img').src = urlImagen;
// ArrayBuffer: datos binarios en crudo (audio, video, datos de bajo nivel)
const buffer = await respuesta.arrayBuffer();
// FormData: respuestas con tipo multipart/form-data
const formData = await respuesta.formData();
}
// ⚠️ El cuerpo solo se puede leer UNA VEZ
// Si llamas a .json() y luego a .text(), el segundo fallará
// Solución: clonar la respuesta si necesitas leerla dos veces
async function leerDosVeces(url) {
const respuesta = await fetch(url);
const copia = respuesta.clone();
const texto = await respuesta.text(); // leer como texto
const json = await copia.json(); // leer la copia como JSON
}
Petición POST: enviar datos al servidor
// POST con cuerpo JSON
async function crearUsuario(datos) {
const respuesta = await fetch('/api/usuarios', {
method: 'POST',
headers: {
'Content-Type': 'application/json', // decirle al servidor qué formato enviamos
},
body: JSON.stringify(datos), // convertir el objeto a string JSON
});
if (!respuesta.ok) {
const error = await respuesta.json(); // el servidor puede devolver detalles del error
throw new Error(error.mensaje || `HTTP ${respuesta.status}`);
}
return respuesta.json(); // devuelve el usuario creado (con id asignado)
}
// Llamada
const nuevoUsuario = await crearUsuario({
nombre: 'Ana García',
email: 'ana@mail.com',
edad: 28
});
console.log(nuevoUsuario.id); // id asignado por el servidor
// POST con FormData: para subir archivos o enviar formularios
async function subirFoto(archivo) {
const formData = new FormData();
formData.append('foto', archivo); // el archivo
formData.append('nombre', archivo.name); // datos adicionales
// Con FormData NO pongas Content-Type: el navegador lo establece automáticamente
// con el boundary correcto para multipart/form-data
const respuesta = await fetch('/api/fotos', {
method: 'POST',
body: formData,
});
return respuesta.json();
}
// Conectar con un input de archivo del HTML
const inputArchivo = document.querySelector('input[type="file"]');
inputArchivo.addEventListener('change', async (e) => {
const archivo = e.target.files[0];
if (archivo) {
const resultado = await subirFoto(archivo);
console.log('Foto subida:', resultado.url);
}
});
PUT y PATCH: actualizar recursos
// PUT: reemplaza el recurso completo
async function actualizarUsuario(id, datos) {
const respuesta = await fetch(`/api/usuarios/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(datos),
});
if (!respuesta.ok) throw new Error(`HTTP ${respuesta.status}`);
return respuesta.json();
}
// PATCH: actualización parcial (solo los campos que envías)
async function cambiarEmail(id, nuevoEmail) {
const respuesta = await fetch(`/api/usuarios/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: nuevoEmail }), // solo el campo que cambia
});
if (!respuesta.ok) throw new Error(`HTTP ${respuesta.status}`);
return respuesta.json();
}
DELETE: eliminar recursos
async function eliminarUsuario(id) {
const respuesta = await fetch(`/api/usuarios/${id}`, {
method: 'DELETE',
});
// Muchas APIs devuelven 204 No Content en los DELETE exitosos
// 204 tiene ok: true pero no tiene cuerpo
if (!respuesta.ok) throw new Error(`HTTP ${respuesta.status}`);
if (respuesta.status === 204) {
return true; // eliminado, sin cuerpo que parsear
}
return respuesta.json(); // algunas APIs devuelven el objeto eliminado
}
Cabeceras: autenticación y otras configuraciones
// La mayoría de APIs requieren autenticación
// El método más habitual hoy en día: Bearer Token (JWT)
async function peticionAutenticada(url, opciones = {}) {
const token = localStorage.getItem('token'); // o de donde lo tengas guardado
const respuesta = await fetch(url, {
...opciones,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`, // cabecera de autenticación
...opciones.headers, // mantener otras cabeceras si las hay
},
});
// Si el token expiró, la API devuelve 401 Unauthorized
if (respuesta.status === 401) {
// Redirigir al login, intentar refrescar el token, etc.
window.location.href = '/login';
return;
}
if (!respuesta.ok) throw new Error(`HTTP ${respuesta.status}`);
return respuesta.json();
}
// Uso
const perfil = await peticionAutenticada('/api/perfil');
const posts = await peticionAutenticada('/api/mis-posts', { method: 'GET' });
// Otras cabeceras habituales
const opciones = {
method: 'GET',
headers: {
'Accept': 'application/json', // qué formato acepta el cliente
'Accept-Language': 'es-ES,es;q=0.9', // idioma preferido
'X-API-Key': 'tu-api-key-aqui', // autenticación por API key
'X-Request-ID': crypto.randomUUID(), // identificador único de la petición
}
};
Cancelar peticiones con AbortController
Si el usuario navega a otra página o hace una nueva búsqueda mientras la petición anterior todavía está en curso, puedes cancelarla para evitar procesar una respuesta que ya no necesitas.
let controladorActual = null;
async function buscar(termino) {
// Cancelar la petición anterior si existe
if (controladorActual) {
controladorActual.abort();
}
controladorActual = new AbortController();
try {
const respuesta = await fetch(
`/api/buscar?q=${encodeURIComponent(termino)}`,
{ signal: controladorActual.signal } // pasar la señal a fetch
);
const resultados = await respuesta.json();
mostrarResultados(resultados);
} catch (error) {
if (error.name === 'AbortError') {
// Petición cancelada: ignorar, no es un error real
return;
}
console.error('Error en la búsqueda:', error);
}
}
// En un campo de búsqueda: cancelar la anterior al escribir
const campoBusqueda = document.querySelector('#busqueda');
campoBusqueda.addEventListener('input', (e) => {
buscar(e.target.value);
});
Timeout: cancelar si tarda demasiado
// fetch no tiene timeout nativo, pero puedes implementarlo con AbortController
function fetchConTimeout(url, opciones = {}, timeoutMs = 5000) {
const controlador = new AbortController();
const timeoutId = setTimeout(() => {
controlador.abort();
}, timeoutMs);
return fetch(url, { ...opciones, signal: controlador.signal })
.then(respuesta => {
clearTimeout(timeoutId); // limpiar el timeout si la respuesta llegó a tiempo
return respuesta;
})
.catch(error => {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
throw new Error(`La petición a ${url} superó el timeout de ${timeoutMs}ms`);
}
throw error;
});
}
// Uso
try {
const respuesta = await fetchConTimeout('/api/datos-lentos', {}, 3000);
const datos = await respuesta.json();
} catch (error) {
console.error(error.message); // "La petición... superó el timeout de 3000ms"
}
Un cliente HTTP reutilizable
En lugar de repetir la lógica de cabeceras, verificación de errores y parseo en cada petición, lo habitual es crear un cliente HTTP que encapsule todo eso:
// cliente-http.js
class ClienteHTTP {
constructor(baseURL, opciones = {}) {
this.baseURL = baseURL;
this.opciones = opciones;
}
async peticion(ruta, opciones = {}) {
const url = `${this.baseURL}${ruta}`;
const token = localStorage.getItem('token');
const config = {
headers: {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
...this.opciones.headers,
...opciones.headers,
},
...this.opciones,
...opciones,
};
// Serializar el body si es un objeto
if (config.body && typeof config.body === 'object') {
config.body = JSON.stringify(config.body);
}
const respuesta = await fetch(url, config);
// Manejar 401: token expirado
if (respuesta.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
return;
}
// Leer el cuerpo (puede estar vacío en 204)
const texto = await respuesta.text();
const datos = texto ? JSON.parse(texto) : null;
if (!respuesta.ok) {
const error = new Error(datos?.mensaje || `HTTP ${respuesta.status}`);
error.status = respuesta.status;
error.datos = datos;
throw error;
}
return datos;
}
get(ruta, params = {}) {
const queryString = new URLSearchParams(params).toString();
const rutaCompleta = queryString ? `${ruta}?${queryString}` : ruta;
return this.peticion(rutaCompleta, { method: 'GET' });
}
post(ruta, body) {
return this.peticion(ruta, { method: 'POST', body });
}
put(ruta, body) {
return this.peticion(ruta, { method: 'PUT', body });
}
patch(ruta, body) {
return this.peticion(ruta, { method: 'PATCH', body });
}
delete(ruta) {
return this.peticion(ruta, { method: 'DELETE' });
}
}
// Crear una instancia para tu API
const api = new ClienteHTTP('https://api.ejemplo.com');
// Uso limpio sin repetir lógica
const usuarios = await api.get('/usuarios', { activo: true, pagina: 1 });
const nuevo = await api.post('/usuarios', { nombre: 'Ana', email: 'ana@mail.com' });
const actualiz = await api.patch(`/usuarios/${nuevo.id}`, { bio: 'Dev' });
await api.delete(`/usuarios/${nuevo.id}`);
Manejo de errores de red y reintentos
// Reintentar automáticamente en caso de error de red o 5xx
async function fetchConReintentos(url, opciones = {}, maxReintentos = 3) {
let ultimoError;
for (let intento = 1; intento <= maxReintentos; intento++) {
try {
const respuesta = await fetch(url, opciones);
// No reintentar en errores del cliente (4xx): son errores permanentes
if (respuesta.status >= 400 && respuesta.status < 500) {
return respuesta;
}
// Reintentar en errores del servidor (5xx)
if (!respuesta.ok) {
throw new Error(`HTTP ${respuesta.status}`);
}
return respuesta;
} catch (error) {
ultimoError = error;
if (intento < maxReintentos) {
// Espera exponencial: 1s, 2s, 4s... (backoff exponencial)
const espera = Math.pow(2, intento - 1) * 1000;
console.warn(`Intento ${intento} fallido. Reintentando en ${espera}ms...`);
await new Promise(resolve => setTimeout(resolve, espera));
}
}
}
throw ultimoError;
}
CORS: por qué el navegador bloquea algunas peticiones
Si intentas hacer una petición desde tu-sitio.com a otra-api.com y ves un error de CORS en la consola, no es un bug de tu código: es el navegador protegiéndote. CORS (Cross-Origin Resource Sharing) es una política de seguridad que restringe las peticiones entre orígenes distintos.
El servidor de destino debe incluir la cabecera Access-Control-Allow-Origin en su respuesta para autorizar las peticiones de tu origen. Como desarrollador frontend, no puedes cambiar esto: la solución está en el servidor o en un proxy.
// CORS solo afecta al NAVEGADOR, no a Node.js ni a herramientas como curl
// Si la petición funciona desde curl pero no desde el navegador, es CORS
// Solución 1: que el servidor añada las cabeceras correctas (backend)
// Access-Control-Allow-Origin: https://tu-sitio.com
// Access-Control-Allow-Methods: GET, POST, PUT, DELETE
// Access-Control-Allow-Headers: Content-Type, Authorization
// Solución 2: proxy en desarrollo (Vite, Create React App, etc.)
// En vite.config.js:
// server: {
// proxy: {
// '/api': 'http://localhost:3000' ← redirige /api/* al backend local
// }
// }
// Solución 3: en producción, servir el frontend y el backend desde el mismo origen
// o usar un reverse proxy (nginx, Cloudflare) que añada las cabeceras
// Para APIs públicas que sí permiten CORS, fetch funciona directamente:
const respuesta = await fetch('https://api.github.com/users/octocat');
const usuario = await respuesta.json();
console.log(usuario.name); // "The Octocat"
Resumen
fetch(url)devuelve una Promesa con un objetoResponse. Llama arespuesta.json()para parsear el cuerpo como JSON (también es asíncrono).- La Promesa de
fetchsolo se rechaza con errores de red. Los errores HTTP (404, 500) tienenrespuesta.ok === false: siempre verificaokantes de usar los datos. - Para enviar datos usa el segundo argumento con
method,headersybody. El body debe serJSON.stringify(datos)y la cabeceraContent-Type: application/json. - Para subir archivos usa
FormDatasin especificarContent-Type: el navegador lo establece automáticamente con el boundary correcto. - Usa
AbortControllerpara cancelar peticiones: imprescindible en buscadores en tiempo real y al navegar entre vistas antes de que llegue la respuesta. - Encapsula la lógica de fetch en un cliente HTTP reutilizable para no repetir cabeceras, verificación de errores y manejo de autenticación en cada petición.
- CORS es una política del navegador: si una petición falla por CORS, la solución está en el servidor (añadir cabeceras) o en un proxy, no en el código de fetch.
No hay comentarios todavía. Sé el primero en compartir tu opinión.