Fetch API: cómo consumir APIs REST desde el navegador

D
DanisCh
• 12 min de lectura
Fetch API: cómo consumir APIs REST desde el navegador
HTML JavaScript

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 objeto Response. Llama a respuesta.json() para parsear el cuerpo como JSON (también es asíncrono).
  • La Promesa de fetch solo se rechaza con errores de red. Los errores HTTP (404, 500) tienen respuesta.ok === false: siempre verifica ok antes de usar los datos.
  • Para enviar datos usa el segundo argumento con method, headers y body. El body debe ser JSON.stringify(datos) y la cabecera Content-Type: application/json.
  • Para subir archivos usa FormData sin especificar Content-Type: el navegador lo establece automáticamente con el boundary correcto.
  • Usa AbortController para 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.
Etiquetas: JavaScript HTML

¿Te ha gustado esta entrada?

Compártela con tus compañeros para que también sigan aprendiendo.

Comunidad y Comentarios

0 COMENTARIOS

No hay comentarios todavía. Sé el primero en compartir tu opinión.

Escribe tu opinión
Respondiendo a