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í)");
});.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
}
}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;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();
}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 });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);
}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 independientestructuredClone 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);
}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);
});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.