Async/await en Node.js: de callbacks a promesas

Cómo usar async/await en Node.js desde cero: qué es una promesa, cómo pasar de callbacks a promesas y cómo escribir código asíncrono que se lea de arriba a abajo.

Casi todo lo que hace Node.js tarda: leer un archivo, consultar la base de datos, llamar a una API. Mientras espera, Node sigue atendiendo otras cosas, y por eso el código asíncrono se escribe distinto al que ves en otros lenguajes. Antes eso se resolvía con callbacks anidados, y el resultado era código que nadie quería leer.

Hoy se resuelve con promesas y con async/await, y es la forma que vas a usar todos los días. Aquí vemos cómo usar async/await en Node.js desde lo más básico: qué es una promesa, cómo crear una, cómo convertir un callback viejo y qué hacen realmente async y await. Con esta base, el siguiente paso es ejecutar tareas en paralelo con Promise.all y aprender a manejar los errores.

Probé todo el código con Node.js 22. Los ejemplos usan módulos ES, así que pon "type": "module" en tu package.json (o usa archivos .mjs).

¿Qué es una promesa?

Una promesa es un objeto que representa un valor que todavía no tienes, pero que va a llegar (o a fallar). Tiene tres estados:

  • Pendiente: sigue esperando.
  • Cumplida: salió bien y ya tiene su valor.
  • Rechazada: falló y tiene un error.

Eso es todo. async/await es una forma más cómoda de escribir código con promesas, y por dentro sigue siendo lo mismo.

Crear tu propia promesa

Casi nunca vas a crear promesas a mano, porque Node ya trae versiones con promesas de casi todo. Pero verlo una vez ayuda a entender cómo funcionan:

function dividir(a, b) {
  return new Promise((resolve, reject) => {
    if (b === 0) {
      reject(new Error('No se puede dividir entre cero'));
    } else {
      resolve(a / b);
    }
  });
}

// Con .then / .catch / .finally
dividir(10, 2)
  .then((resultado) => console.log('10 / 2 =', resultado))
  .catch((err) => console.log('error:', err.message))
  .finally(() => console.log('terminó el primer cálculo'));

dividir(10, 0)
  .then((resultado) => console.log('10 / 0 =', resultado))
  .catch((err) => console.log('error:', err.message))
  .finally(() => console.log('terminó el segundo cálculo'));

La función que le pasas a new Promise recibe dos funciones: resolve para decir “salió bien, este es el valor” y reject para decir “falló, este es el error”. Después, con la promesa en la mano:

  • .then() se ejecuta si se cumplió.
  • .catch() se ejecuta si se rechazó.
  • .finally() se ejecuta en cualquier caso.

La salida es esta:

10 / 2 = 5
error: No se puede dividir entre cero
terminó el primer cálculo
terminó el segundo cálculo

Fíjate que las líneas van mezcladas: las dos operaciones corren a la vez y cada una avanza cuando le toca. Esa es la idea del código asíncrono.

De callbacks a promesas

Leamos el mismo archivo de tres maneras. Crea un datos.txt con cualquier texto y este código:

import { readFile } from 'node:fs';
import { promisify } from 'node:util';
import { readFile as readFilePromesa } from 'node:fs/promises';

// 1) Estilo viejo: callback
readFile('datos.txt', 'utf8', (err, texto) => {
  if (err) {
    console.log('callback -> error:', err.code);
    return;
  }
  console.log('callback ->', texto.trim());
});

// 2) Convertir un callback en promesa con promisify
const readFileAsync = promisify(readFile);
readFileAsync('datos.txt', 'utf8')
  .then((texto) => console.log('promisify ->', texto.trim()))
  .catch((err) => console.log('promisify -> error:', err.code));

// 3) Hoy lo normal: fs/promises + async/await
async function leer() {
  try {
    const texto = await readFilePromesa('datos.txt', 'utf8');
    console.log('async/await ->', texto.trim());
  } catch (err) {
    console.log('async/await -> error:', err.code);
  }
}
leer();

Las tres hacen lo mismo. Lo que cambia es cómo se lee:

  • El callback recibe el error como primer parámetro y tienes que acordarte de revisarlo siempre.
  • promisify convierte una función con callback en una que devuelve una promesa. Sirve para librerías viejas que no tienen versión con promesas.
  • async/await se lee de arriba a abajo, como código normal, y los errores se atrapan con try/catch.

Para casi todo lo que viene en Node hay una versión /promises (fs/promises, timers/promises, dns/promises), así que casi nunca vas a necesitar promisify.

Qué hacen async y await

Son dos palabras con trabajos distintos:

  • async va antes de una función y hace que siempre devuelva una promesa, aunque dentro devuelvas un valor normal.
  • await va antes de una promesa y pausa esa función hasta que se resuelva. El resto de tu programa sigue funcionando mientras tanto.

Se ve claro con un ejemplo mínimo:

javascript

async function saludar() {
  return 'hola';
}

const resultado = saludar();
console.log(resultado);          // una promesa, no el texto
console.log(await resultado);    // ahora sí el texto
Promise { 'hola' }
hola

Aunque saludar devuelve un texto, desde fuera recibes una promesa. Para sacar el valor necesitas await. Y await solo funciona dentro de una función async o en el nivel principal de un módulo ES, que es lo que estamos usando en estos ejemplos.

Resultado: async/await en Node.js leyendo un archivo

Corre el ejemplo de callbacks, promisify y async/await con el archivo datos.txt en su lugar. Esto muestra la consola:

callback -> hola desde datos.txt
promisify -> hola desde datos.txt
async/await -> hola desde datos.txt

Ahora renombra o borra datos.txt y corre el mismo código:

callback -> error: ENOENT
promisify -> error: ENOENT
async/await -> error: ENOENT

Las tres formas dan el mismo resultado, tanto cuando el archivo existe como cuando no. ENOENT es el código de Node para “archivo o carpeta no encontrado”. La diferencia entre ellas es solo cómo se escribe y qué tan fácil es de leer cuando el código crece: con callbacks, cada paso extra mete otro nivel de anidación; con async/await, solo agregas otra línea.

Errores comunes

1. Imprimir una promesa en lugar del valor

Si te sale Promise { <pending> } o Promise { 'algo' } en la consola, casi seguro olvidaste el await. Estás viendo la promesa y no lo que contiene. Pasa más en cadenas largas, cuando llamas a una función async y usas el resultado como si ya estuviera listo.

2. await fuera de una función async

Si tu proyecto usa require (CommonJS) y pones await en el nivel principal, Node te dice:

SyntaxError: await is only valid in async functions and the top level bodies of modules

Tienes dos salidas: poner el await dentro de una función async, o pasar tu proyecto a módulos ES con "type": "module" en el package.json.

3. Mezclar demasiado .then() con await

No es un error, pero se vuelve difícil de leer. Elige uno por función. Para la mayoría de los casos, async/await es más claro y deja los errores en un solo try/catch.

(Si tienes alguna anécdota real con callbacks anidados de algún proyecto viejo, este es el lugar para contarla en uno o dos párrafos.)

Siguientes pasos

Ya sabes qué es una promesa y cómo escribir código con async/await. Lo que sigue es lo que más se usa en la práctica:

Y si quieres ver async en acción con archivos grandes, mira cómo leer CSV en Node.js con streams.

Recomendaciones

  • Usa async/await para casi todo y deja .then() para cadenas cortas en las que de verdad te sirva.
  • Cuando uses async/await en Node.js, busca primero si lo que necesitas ya trae versión con promesas (fs/promises, timers/promises). Es más limpio que convertirlo tú.
  • Si tienes que convertir un callback viejo, usa promisify antes que escribir tu propia promesa a mano.
  • Una función async siempre devuelve una promesa. Acuérdate de esperarla con await donde la llames.

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *