Saltar al contenido principal
55 min de lectura3 de agosto de 2026
JavaScriptAsyncFetchDOMMódulosAPIs

Requisitos previos

  • javascript

Lo que aprenderás

  • Manejar promesas y async/await en profundidad
  • Consumir APIs con fetch y cancelar peticiones con AbortController
  • Usar módulos ES y import dinámico
  • Aplicar event delegation e IntersectionObserver
  • Trabajar con localStorage, URL, Clipboard e Intl
Tutorial55 min

JavaScript moderno: asíncrono y APIs del navegador

El corazón de cualquier aplicación web moderna es el código asíncrono: peticiones a APIs, eventos del DOM, timers y almacenamiento. Este tutorial profundiza en promesas, async/await, fetch con cancelación, observadores del DOM, módulos ES y las APIs del navegador que usas a diario sin pensar. Prerequisito conceptual: JavaScript básico (funciones, arrays y objetos).


Promesas en profundidad

Una promesa representa un valor que puede estar disponible ahora, en el futuro o nunca. Tiene tres estados: pending (pendiente), fulfilled (resuelta) y rejected (rechazada). Una vez que pasa de pending, su estado ya no cambia:

const esperar = (ms: number) =>
  new Promise((resolve) => setTimeout(resolve, ms));

const peticion = fetch("https://api.example.com/datos");

// Estados
console.log(peticion);            // Promise { <pending> }
peticion.then((res) => {
  console.log("Resuelta:", res);  // fulfilled
});

El chaining encadena .then() y devuelve una nueva promesa, permitiendo transformar el valor y propagar errores por la cadena:

fetch("https://api.example.com/usuarios/1")
  .then((res) => res.json())
  .then((usuario) => {
    // Si esto lanza un error, la cadena salta al .catch
    console.log(usuario.nombre);
  })
  .catch((error) => {
    console.error("Algo falló:", error);
  })
  .finally(() => {
    console.log("La petición terminó (sí o sí)");
  });
Un error que ocurre dentro de un .then() no se propaga a la promesa original: se propaga a la promesa devuelta por ese .then(). Siempre encadena el .catch() al final de la cadena.

Combinadores: all, allSettled, race, any

Cuando necesitas varias promesas a la vez, JavaScript ofrece cuatro combinadores con semánticas distintas:

// Promise.all: espera TODAS; si una falla, falla todo.
const [usuarios, productos] = await Promise.all([
  fetch("/api/usuarios").then((r) => r.json()),
  fetch("/api/productos").then((r) => r.json()),
]);

// Promise.allSettled: espera TODAS; devuelve el resultado de cada una.
const resultados = await Promise.allSettled([
  fetch("/a"),
  fetch("/b"),
  fetch("/c"),
]);
resultados.forEach((r) => {
  if (r.status === "fulfilled") {
    console.log("OK:", r.value);
  } else {
    console.log("FALLÓ:", r.reason);
  }
});

// Promise.race: gana la primera en terminar (resuelta o rechazada).
const masRapida = await Promise.race([cargarLento(), cargarRapido()]);

// Promise.any: la primera en RESOLVERSE; solo falla si TODAS fallan.
const primeraOk = await Promise.any([replicaA(), replicaB(), replicaC()]);
Promise.all con un array vacío se resuelve con un array vacío (útil como "gate"). Promise.any con todas las promesas rechazadas lanza un AggregateError que contiene todas las razones.

Promise.all falla rápido: si una promesa se rechaza, las demás se siguen ejecutando en segundo plano pero su resultado se descarta. Para "parar" realmente las demás necesitas AbortController (lo vemos en fetch).

async/await

async/await es azúcar sintáctico sobre promesas: permite escribir código asíncrono con la estructura visual de código síncrono. Una función async siempre devuelve una promesa:

async function obtenerUsuario(id: number) {
  const res = await fetch("/api/usuarios/" + id);
  if (!res.ok) throw new Error("Usuario no encontrado");
  return res.json();
}

// Lo anterior equivale a:
function obtenerUsuario(id: number) {
  return fetch("/api/usuarios/" + id).then((res) => {
    if (!res.ok) throw new Error("Usuario no encontrado");
    return res.json();
  });
}

El manejo de errores con try/catch captura tanto los rechazos de await como los errores síncronos de la misma función:

async function cargarPerfil(id: number) {
  try {
    const usuario = await obtenerUsuario(id);
    const posts = await obtenerPosts(usuario.id);
    return { usuario, posts };
  } catch (error) {
    console.error("No se pudo cargar el perfil:", error);
    return null; // degradación elegante
  }
}
Los await en serie son lentos: await a(); await b() ejecuta b solo cuando a termina. Si no hay dependencia entre ellas, lánzalas en paralelo. En el ejemplo anterior, obtenerUsuario debe terminar antes de pedir sus posts — pero dos posts independientes deberían ir con Promise.all.

El top-level await permite usar await fuera de una función async, en el cuerpo de un módulo ES. Es la base de cargar dependencias que necesitan otras de forma natural:

// config.ts
const config = await fetch("/api/config").then((r) => r.json());

// Otro módulo puede importarla sabiendo que ya está lista
export const apiUrl = config.apiUrl;
Top-level await bloquea la evaluación del módulo hasta resolver, y por lo tanto también a quien lo importa. Úsalo para inicialización imprescindible y evítalo en bibliotecas públicas: hace el arranque de la app más lento.

Fetch y consumo de APIs

fetch es la API nativa para peticiones HTTP. Devuelve una promesa que se resuelve cuando llegan los headers, no cuando llega el cuerpo. Por eso hay que llamar a res.json() (o res.text()) para leer el contenido:

async function enviarDatos(datos: object) {
  const res = await fetch("/api/contacto", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-API-Key": "pk_123", // cabecera custom de ejemplo
    },
    body: JSON.stringify(datos),
  });

  // fetch NO lanza error en 4xx/5xx: hay que comprobarlo a mano
  if (!res.ok) {
    const detalle = await res.text();
    throw new Error("HTTP " + res.status + ": " + detalle);
  }

  return res.json();
}
Comprobar res.ok (o el status) es obligatorio: fetch solo rechaza la promesa por errores de red (DNS, conexión cortada, CORS), nunca por un 404 o 500. Una API que responde 500 sin comprobarlo "parece que funciona" y te devuelve un HTML de error como si fuera JSON.

AbortController y cancelación

AbortController permite cancelar una petición en curso. Es imprescindible en componentes que se desmontan, en búsquedas que escriben más rápido de lo que responden y en timeouts de red:

function buscarConTiempo(consulta: string) {
  const controlador = new AbortController();

  const timeout = setTimeout(() => controlador.abort(), 5000);

  return fetch("/api/buscar?q=" + encodeURIComponent(consulta), {
    signal: controlador.signal,
  })
    .then((res) => {
      clearTimeout(timeout);
      return res.json();
    })
    .catch((error) => {
      if (error.name === "AbortError") {
        throw new Error("La búsqueda tardó demasiado");
      }
      throw error;
    });
}

Una forma más limpia para timeouts es AbortSignal.timeout(), que aborta automáticamente al cabo de los milisegundos indicados:

const res = await fetch("/api/lento", {
  signal: AbortSignal.timeout(5000),
}).catch((error) => {
  if (error.name === "AbortError") {
    console.warn("Timeout: la API no respondió a tiempo");
  }
});

DOM avanzado

data-* y classList

Los atributos data-* guardan datos arbitrarios en un elemento sin interferir con la semántica HTML. Se leen con dataset (camelCase) y se actualizan en caliente:

<button data-accion="eliminar" data-id="42">Eliminar</button>

<script>
  const boton = document.querySelector("button[data-accion='eliminar']");
  console.log(boton.dataset.accion); // "eliminar"
  console.log(boton.dataset.id);     // "42"
  boton.dataset.estado = "confirmando";
</script>

classList es la forma segura de manipular clases: add, remove, toggle y contains:

const panel = document.getElementById("panel");

panel.classList.add("abierto");              // añade una clase
panel.classList.remove("abierto");           // la quita
panel.classList.toggle("abierto");           // alterna
const estaAbierto = panel.classList.contains("abierto");

// Con segundo argumento (force)
panel.classList.toggle("abierto", hayNoticias);

Event delegation y closest()

Los eventos del DOM burbujean: un clic en un hijo sube hasta sus ancestros. La delegación de eventos aprovecha esto para escuchar una vez en un contenedor en vez de poner un listener en cada hijo — imprescindible en listas dinámicas:

const lista = document.getElementById("lista");

lista.addEventListener("click", (evento) => {
  const objetivo = evento.target as HTMLElement;

  const fila = objetivo.closest("tr[data-id]");
  if (!fila) return; // el clic no fue en una fila válida

  const id = fila.dataset.id;
  if (objetivo.matches("button.eliminar")) {
    borrarFila(id);
  } else if (objetivo.matches("button.editar")) {
    abrirEditor(id);
  }
});
closest() recorre hacia arriba buscando el ancestro que cumple el selector; matches() comprueba si un elemento concreto lo cumple. La combinación de ambos con delegación es el patrón de tablas y listas de toda la web.

IntersectionObserver y MutationObserver

IntersectionObserver notifica cuando un elemento entra o sale del viewport (o se cruza con otro). Es la base del lazy loading y de los efectos de scroll sin tocar el evento scroll:

const observador = new IntersectionObserver(
  (entradas) => {
    entradas.forEach((entrada) => {
      if (entrada.isIntersecting) {
        cargarImagen(entrada.target as HTMLImageElement);
        observador.unobserve(entrada.target); // solo una vez
      }
    });
  },
  { rootMargin: "200px" } // precarga 200px antes de entrar
);

document.querySelectorAll("img[data-lazy]").forEach((img) => {
  observador.observe(img);
});

MutationObserver observa cambios en el DOM: nodos añadidos, atributos modificados o texto alterado. Es la forma de reaccionar a cambios que no controlas (código de terceros, extensiones, SPAs):

const observador = new MutationObserver((mutaciones) => {
  mutaciones.forEach((mutacion) => {
    if (mutacion.type === "childList") {
      mutacion.addedNodes.forEach((nodo) => {
        if (nodo instanceof HTMLElement && nodo.matches(".ad")) {
          nodo.remove(); // bloquea anuncios inyectados
        }
      });
    }
  });
});

observador.observe(document.body, { childList: true, subtree: true });
Los observadores no deben sustituir a la arquitectura de tu framework (React/Vue gestionan su propio DOM). Úsalos para integraciones con librerías externas y lazy loading. Y recuerda llamar siempre a disconnect() o unobserve() para no filtrar listeners.

Módulos ES

Los módulos ES organizan el código en unidades con dependencias explícitas. Cada módulo tiene su propio scope: lo que no se exporta, no existe fuera:

// utils.ts
export function formatearMoneda(cantidad: number) {
  return new Intl.NumberFormat("es-ES", {
    style: "currency",
    currency: "EUR",
  }).format(cantidad);
}

export const VERSION = "1.0.0";

// default: un único export principal por módulo
export default function saludar(nombre: string) {
  return "Hola, " + nombre;
}

// app.ts
import saludar, { formatearMoneda, VERSION } from "./utils.js";

console.log(saludar("Ana"));
console.log(formatearMoneda(19.99));

El dynamic import() carga un módulo bajo demanda, devolviendo una promesa. Es la base del code splitting y del lazy loading de rutas en frameworks como Next.js:

// Carga solo cuando el usuario la necesita
async function abrirEditor() {
  const modulo = await import("./editor.js");
  modulo.iniciarEditor();
}

// Carga condicional según el navegador
if (navigator.clipboard) {
  const { copiar } = await import("./clipboard.js");
  copiar(texto);
}
El tree-shaking elimina del bundle final los exports que nunca se importan. Para que funcione, escribe exports con nombre (no efectos secundarios en el módulo) y evita importar bibliotecas enteras cuando solo usas una función.

APIs del navegador

localStorage y sessionStorage

localStorage persiste entre sesiones y sessionStorage se borra al cerrar la pestaña. Ambos guardan solo strings, así que los objetos se serializan con JSON:

// Guardar
const prefs = { tema: "oscuro", idioma: "es" };
localStorage.setItem("prefs", JSON.stringify(prefs));

// Leer (con valor por defecto y try/catch)
function leerPrefs() {
  try {
    const crudo = localStorage.getItem("prefs");
    return crudo ? JSON.parse(crudo) : { tema: "claro" };
  } catch {
    return { tema: "claro" }; // JSON corrupto
  }
}

// Eliminar
localStorage.removeItem("prefs");
sessionStorage.setItem("token", "abc123");
localStorage es síncrono y bloquea el hilo principal, y no es seguro para datos sensibles (cualquier script de la página puede leerlo). Para sesiones usa cookies httpOnly o almacenamiento en memoria; para datos grandes, IndexedDB.

URL y URLSearchParams

URL parsea y construye URLs de forma segura, y URLSearchParams maneja la query string sin concatenar strings a mano:

const url = new URL("https://api.example.com/buscar");
url.searchParams.set("q", "javascript avanzado");
url.searchParams.set("pagina", "2");
url.searchParams.delete("filtro");

console.log(url.toString());
// https://api.example.com/buscar?q=javascript+avanzado&pagina=2

// Leer la URL actual
const actual = new URL(window.location.href);
const termino = actual.searchParams.get("q");
const pagina = Number(actual.searchParams.get("pagina") ?? "1");

Clipboard API

La Clipboard API escribe y lee del portapapeles de forma asíncrona y segura (requiere permiso o gesto del usuario):

async function copiarTexto(texto: string) {
  try {
    await navigator.clipboard.writeText(texto);
    console.log("Copiado");
  } catch (error) {
    console.error("No se pudo copiar:", error);
  }
}

// Fallback para contextos sin permiso
async function copiarConRespaldo(texto: string) {
  if (navigator.clipboard) {
    await navigator.clipboard.writeText(texto);
    return;
  }
  const area = document.createElement("textarea");
  area.value = texto;
  document.body.appendChild(area);
  area.select();
  document.execCommand("copy");
  area.remove();
}

Intl para fechas y números

Intl formatea fechas, números y monedas según el locale, sin librerías externas:

const fecha = new Date("2026-08-03T10:00:00Z");

new Intl.DateTimeFormat("es-ES", {
  dateStyle: "full",
  timeStyle: "short",
}).format(fecha);
// "lunes, 3 de agosto de 2026, 12:00"

new Intl.NumberFormat("es-ES", {
  style: "currency",
  currency: "EUR",
}).format(1234.5);
// "1.234,50 €"

new Intl.RelativeTimeFormat("es-ES", { numeric: "auto" })
  .format(-2, "day");
// "hace 2 días"

structuredClone

structuredClone() hace una copia profunda de un objeto, incluyendo fechas, Map, Set y arrays anidados — algo que JSON.parse(JSON.stringify()) no puede:

const original = {
  nombre: "Ana",
  fecha: new Date(),
  tags: new Set(["js", "async"]),
};

const copia = structuredClone(original);
copia.tags.add("dom");

console.log(original.tags.has("dom")); // false: copia independiente
structuredClone no clona funciones ni símbolos, y lanza un error si encuentra algo no clonable (como un elemento del DOM). Para esos casos, sigue siendo necesario un clon manual o una librería.

Buenas prácticas de asincronía

Evitar race conditions

Una race condition ocurre cuando dos respuestas asíncronas compiten y la más antigua llega después de la más nueva, sobrescribiendo el estado con datos obsoletos. El patrón clásico es la búsqueda con autocompletado:

let peticionActual = 0;

async function buscar(consulta: string) {
  const id = ++peticionActual; // marca esta petición

  const res = await fetch("/api/buscar?q=" + consulta);
  const datos = await res.json();

  // Si llegó otra petición más nueva, descarta esta
  if (id !== peticionActual) return;
  renderizarResultados(datos);
}
La alternativa moderna es AbortController: aborta la petición anterior antes de lanzar la nueva. Así no solo ignoras el resultado, sino que liberas la conexión de red.

Throttling y debouncing

Los eventos de alta frecuencia (scroll, resize, input) disparan demasiadas veces por segundo. Dos técnicas las limitan:

  • Debounce: ejecuta la función solo después de que pase un tiempo sin nuevos eventos. Ideal para búsquedas mientras se escribe.
  • Throttle: ejecuta como máximo una vez cada X milisegundos. Ideal para scroll y resize.
function debounce<T extends (...args: never[]) => void>(
  fn: T,
  espera: number
) {
  let timer: ReturnType<typeof setTimeout> | undefined;
  return (...args: Parameters<T>) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), espera);
  };
}

const buscarDebounced = debounce((q: string) => {
  buscar(q);
}, 300);

input.addEventListener("input", (e) => {
  buscarDebounced((e.target as HTMLInputElement).value);
});
No reinventes la rueda en producción: React Query, SWR y librerías de estado ya gestionan race conditions, caché y cancelación. Estas técnicas manuales son para entender el mecanismo y para código vanilla o integraciones puntuales.

Ejercicios


El asincronismo es lo que hace que la web se sienta viva: promesas para coordinar, async/await para leerlo con claridad, fetch para hablar con el servidor y las APIs del navegador para interactuar con el entorno. Dominar los combinadores, la cancelación y los observadores te separa de quien solo encadena .then(). Con estas piezas ya puedes construir interfaces rápidas, robustas y sin condiciones de carrera.

Siguiente lección

Introducción a TypeScript

Aprende TypeScript desde tipos básicos hasta genéricos y Utility Types. Con 5 ejercicios prácticos.