12k
All articles

Cómo JavaScript Aprendió Finalmente a Limpiar lo que Ensucia

Gestión explícita de recursos en JavaScript con using, await using, DisposableStack y SuppressedError para limpiar archivos, bloqueos, sockets y conexiones.

OpenReplay Team
OpenReplay Team
Cómo JavaScript Aprendió Finalmente a Limpiar lo que Ensucia

La nueva funcionalidad de Gestión Explícita de Recursos de JavaScript — las declaraciones using y await using respaldadas por Symbol.dispose y Symbol.asyncDispose — otorga al lenguaje una limpieza determinista de recursos no relacionados con la memoria, basada en el ámbito de ejecución, para recursos como manejadores de archivos, sockets, bloqueos y conexiones a bases de datos. Esto no es recolección de basura. La recolección de basura recupera memoria según su propio calendario no determinista, y nunca cerró tus archivos, liberó tus bloqueos ni canceló la suscripción de tus event listeners. Esos son exactamente los recursos que using limpia, de forma predecible, en el momento en que un ámbito finaliza.

Si has estado escribiendo bloques try/finally manualmente para cerrar conexiones y liberar bloqueos, esta funcionalidad reemplaza ese código repetitivo con una sola palabra clave. Este artículo explica qué hace using, cómo funciona la división entre síncrono y asíncrono, cómo escribir tus propios objetos desechables, cómo coordinar varios de ellos con DisposableStack, cómo adaptar bibliotecas que aún no lo soportan, y cómo los errores de eliminación se presentan a través del nuevo SuppressedError. (WeakRef y FinalizationRegistry son las herramientas relacionadas con el GC para la memoria; este es un mecanismo completamente diferente.)

Puntos Clave

  • Una declaración using invoca el método [Symbol.dispose]() de un objeto cuando el bloque que lo contiene finaliza; await using invoca [Symbol.asyncDispose]() y lo espera, de modo que el desmontaje que devuelve una promesa se completa antes de que el ámbito se cierre.
  • Cuando varios recursos comparten un ámbito, se eliminan en orden inverso al de su declaración, de modo que un recurso dependiente se desmonta antes que la dependencia de la que depende.
  • La Gestión Explícita de Recursos es una propuesta TC39 finalizada y estandarizada en ES2026, disponible de forma nativa en Chrome 134 (V8 13.8), Firefox 141 y Node.js 24 (V8 13.6); Safari aún está al día — el símbolo Symbol.dispose llegó a Safari de escritorio 26.4, pero aún no a Safari en iOS.
  • DisposableStack.prototype.move() transfiere los recursos registrados a una nueva pila y marca la original como eliminada sin desechar nada — el patrón para entregar de forma segura recursos parcialmente construidos desde un constructor.
  • Si la eliminación lanza una excepción mientras tu código ya está lanzando otra, JavaScript envuelve ambas en un SuppressedError, de modo que ni el fallo original ni el fallo de limpieza se pierden.

El problema de limpieza que try/finally nunca resolvió bien

Cada recurso que abres — un manejador de archivo, un lector de stream, una conexión a base de datos, un bloqueo — tiene que cerrarse, y la única herramienta a nivel de lenguaje para garantizarlo era try/finally. Funciona, pero es verboso y escala mal. Considera un lector de Web Streams: si ocurre un error durante el proceso de lectura y olvidas llamar a releaseLock() antes de que el error se propague, el stream permanece bloqueado. La solución es envolver el bucle de lectura en un bloque try y colocar reader.releaseLock() en finally para que el bloqueo siempre se libere.

Ese patrón está bien para un solo recurso. Con dos o tres recursos interdependientes, se convierte en bloques finally anidados donde el orden importa y una excepción dentro de una limpieza puede saltarse las demás. La parte mecánica — “este objeto necesita desmontaje, ejecútalo al salir, en el orden correcto, incluso en la ruta de error” — es exactamente lo que el lenguaje ahora maneja por ti.

Cómo funciona la palabra clave using

La palabra clave using declara un enlace de ámbito de bloque no reasignable (como const) cuyo valor debe ser null, undefined, o un objeto que tenga un método [Symbol.dispose](); cuando la variable sale del ámbito, ese método se ejecuta automáticamente. Usa using para la limpieza síncrona. Usa await using cuando el desmontaje devuelva una promesa — declara variables de ámbito de bloque que se eliminan de forma asíncrona, el valor debe tener un método [Symbol.asyncDispose]() o [Symbol.dispose](), y ese método se invoca y se espera cuando la variable sale del ámbito. Como await using también acepta objetos desechables síncronos simples, puedes utilizarlo siempre que no sepas qué tipo tienes.

import fs from "node:fs/promises";

async function readConfig() {
  await using file = await fs.open("config.json", "r");
  const { buffer } = await file.read();
  return buffer.toString();
  // file[Symbol.asyncDispose]() se invoca y se espera aquí, en cada ruta de salida
}

Los objetos FileHandle de Node.js ya implementan el protocolo de desechable asíncrono, por lo que esto funciona sin un wrapper manual. Observa las dos operaciones await: await fs.open() espera la adquisición y desenvuelve la promesa en un FileHandle, mientras que await using espera la eliminación cuando la variable sale del ámbito.

La regla de ordenamiento importa más cuando los recursos dependen entre sí. Si un ámbito contiene múltiples declaraciones using o await using, todos los métodos de eliminación se ejecutan en secuencia en orden inverso al de declaración, independientemente del tipo de declaración, y se garantiza que todos se ejecuten, de forma similar a un bloque finally.

await using db = await openConnection();      // se elimina segundo
await using tx = await db.beginTransaction(); // se elimina primero
// tx se desmonta antes que db, por lo que la conexión sigue activa
// cuando la transacción se finaliza

Sobre el ámbito de aplicación: using es válido dentro de cualquier bloque, cuerpo de función, bloque de inicialización estática, encabezado for, y en el nivel superior de un módulo — pero no en el nivel superior de un script, porque los ámbitos de script persisten y el método de eliminación nunca se ejecutaría. await using en el nivel superior es exclusivo de módulos, ya que depende de await en el nivel superior. Las reglas alineadas con la especificación están documentadas en la referencia MDN de using.

Cómo escribir tu propio objeto desechable

Cualquier objeto se convierte en desechable implementando [Symbol.dispose]() para la limpieza síncrona o [Symbol.asyncDispose]() para la limpieza asíncrona — no existe clase base ni paso de registro. El símbolo bien conocido es el contrato: un objeto es desechable si tiene un método [Symbol.dispose](), y la declaración using busca ese símbolo en el inicializador para obtener el método que se invocará cuando la variable salga del ámbito.

A continuación se muestra un wrapper de conexión a base de datos que se utiliza a lo largo del resto de este artículo:

class DatabaseConnection {
  #client;
  constructor(client) {
    this.#client = client;
  }
  query(sql, params) {
    return this.#client.query(sql, params);
  }
  async [Symbol.asyncDispose]() {
    await this.#client.end();
  }
}

async function getUser(pool, id) {
  await using db = new DatabaseConnection(await pool.acquire());
  return await db.query("SELECT * FROM users WHERE id = $1", [id]);
  // db[Symbol.asyncDispose]() se ejecuta aquí — el cliente se libera en cada ruta
}

Un método de eliminación síncrono sigue la misma estructura con [Symbol.dispose]() en su lugar. Un método de eliminación síncrono no debería devolver una promesa, ya que las promesas devueltas por [Symbol.dispose]() no son esperadas por await using; para declarar objetos desechables asíncronos, usa Symbol.asyncDispose.

Coordinación de recursos con DisposableStack

Cuando un único objeto posee varios recursos — o cuando estás adquiriendo recursos en un bucle — DisposableStack y AsyncDisposableStack los recopilan y eliminan todo el grupo a la vez, en orden inverso. Ambas estructuras proporcionan métodos como use(), adopt() y defer() para agregar recursos o acciones de eliminación, y un método dispose() o asyncDispose() para activar la limpieza. Ellos mismos llevan [Symbol.dispose]() / [Symbol.asyncDispose](), por lo que funcionan con using y await using.

Los tres métodos de registro cubren diferentes formas:

MétodoRegistraÚsalo cuando
use(resource)Un recurso que ya tiene un símbolo de eliminaciónEl objeto es en sí mismo desechable
adopt(value, onDispose)Un valor no desechable más un callback de limpiezaEl recurso tiene un método estilo close/abort pero no tiene símbolo
defer(onDispose)Un callback de limpieza independiente, sin recursoNecesitas ejecutar un desmontaje arbitrario (p. ej., clearInterval)
function startWorker() {
  using stack = new DisposableStack();
  const handle = setInterval(poll, 5000);
  stack.defer(() => clearInterval(handle));
  stack.adopt(openSocket(), (s) => s.close());
  // ambas limpiezas se ejecutan, en orden inverso, cuando el bloque finaliza
}

El poder no obvio es move(), que resuelve un problema real de seguridad en la construcción. A veces el ámbito de función no es suficiente — una clase u objeto posee varios recursos que deberían agruparse como using, pero como campo de clase o clausura, que es para lo que sirven DisposableStack y AsyncDisposableStack. DisposableStack.prototype.move() transfiere todos los recursos registrados a una nueva pila y marca la original como eliminada sin desechar los recursos, lo que te permite construir recursos localmente y entregarlos solo si la construcción se completa con éxito:

function openResources() {
  using cleanup = new DisposableStack();
  const a = cleanup.use(openA());
  const b = cleanup.use(openB()); // si esto lanza, `a` se elimina automáticamente
  const moved = cleanup.move();   // éxito: la propiedad se transfiere, nada se elimina
  return moved;                   // el llamador ahora es responsable de la eliminación de ambos
}

Si openB() lanza una excepción, el bloque finaliza y cleanup elimina a — sin fugas. Si todo tiene éxito, move() entrega los recursos al llamador intactos. La semántica de move() está documentada en el artículo de V8 sobre Gestión Explícita de Recursos.

Adaptación de bibliotecas que aún no soportan la eliminación

No necesitas que una biblioteca adopte Symbol.dispose para usarla con using — adjunta el símbolo tú mismo con Object.assign. Un cliente de biblioteca que expone un método close() o end() pero no tiene símbolo de eliminación se convierte en desechable en una sola línea. Asignar un método Symbol.asyncDispose al cliente significa que puedes usarlo en declaraciones await using y con AsyncDisposableStack#use(), y si más adelante actualizas a una versión que implementa el protocolo de forma nativa, obtendrás un error que te recordará eliminar el shim.

const client = await MongoClient.connect(url);
Object.assign(client, {
  async [Symbol.asyncDispose]() {
    await client.close();
  },
});

async function run() {
  await using db = client; // ahora se elimina correctamente
  // ...
}

El shim con Object.assign es el puente para el ecosistema actual: la mayoría de los clientes de terceros aún no han incorporado símbolos de eliminación, y esto te permite integrarlos hoy mismo.

Dónde ayuda en el frontend y en Node

En el cliente, los recursos que generan fugas son los event listeners, las instancias de IntersectionObserver/ResizeObserver, los navigator.locks, los Web Streams bloqueados y las transacciones de IndexedDB — todos tienen un paso de liberación explícito que es fácil omitir en una ruta de error. Envolver estos recursos en un objeto desechable vincula su ciclo de vida a un ámbito. El ejemplo canónico del equipo de V8 es un lector de stream: una declaración using sobre un objeto cuyo [Symbol.dispose]() llama a reader.releaseLock() elimina la necesidad de recordar la liberación por completo.

Los observers huérfanos y los bloqueos sin liberar son precisamente las fugas que se ocultan en una prueba local rápida. Un observer huérfano o un bloqueo sin liberar no tiene costo en una página que recargas cada pocos segundos, pero a lo largo de una sesión larga de una aplicación de página única se acumulan en un crecimiento gradual de memoria y latencia en las interacciones — la degradación lenta que una reproducción completa de sesión revela cuando una recarga puntual nunca la reproduce. Delimitar esos recursos con using es la solución estructural para esa clase de errores.

En el servidor, los objetos FileHandle de Node.js provenientes de fs/promises y muchos tipos de conexiones ya participan, y los wrappers personalizados como el DatabaseConnection anterior cubren el resto.

Manejo de errores durante la eliminación con SuppressedError

La eliminación puede fallar, y puede fallar mientras tu código ya está lanzando una excepción — sin un comportamiento definido, un error silenciaría al otro. JavaScript resuelve esto con SuppressedError. Todos los errores lanzados durante la eliminación, incluido el error inicial que causó la salida del ámbito, se agregan dentro de un único SuppressedError — cada excepción anterior como la propiedad suppressed y la excepción posterior como la propiedad error — y se lanza después de que la eliminación se completa.

Por lo tanto, si tu bloque lanza una excepción y luego un método de eliminación también lanza, capturas un único SuppressedError cuyo .error es el fallo de eliminación y cuyo .suppressed es el original. Cuando múltiples métodos de eliminación lanzan excepciones, se anidan, de modo que todos los fallos son accesibles:

try {
  using a = makeDisposableThatThrowsOnDispose("a");
  using b = makeDisposableThatThrowsOnDispose("b");
  throw new Error("body failed");
} catch (e) {
  // e es un SuppressedError; b se elimina primero y a se elimina al final, por lo que
  // e.error es el error de eliminación de a, y e.suppressed anida el resto
  // (el error de eliminación de b, luego el "body failed" original)
}

SuppressedError es la parte que hace que using sea seguro de utilizar en código propenso a errores.

Soporte actual: disponible, no “próximamente”

La Gestión Explícita de Recursos es una propuesta TC39 finalizada y estandarizada como parte de ES2026 — no un experimento en Stage 3 que requiera transpilación en todas partes. A mediados de 2026 el panorama es el siguiente:

Entorno de ejecuciónEstadoNotas
Chrome / Edge / OperaDisponibleusing/await using desde Chromium 134 (V8 13.8); el símbolo Symbol.dispose solo ha estado presente desde Chrome 125
Node.jsDisponibleusing/await using nativo en Node.js 24 (V8 13.6); los símbolos desde 18.18.0
FirefoxDisponibleDesde Firefox 141 (estable actual 152)
SafariParcialEl símbolo Symbol.dispose llegó a Safari de escritorio 26.4; Safari en iOS aún no lo soporta
TypeScriptDisponibleSintaxis using desde la versión 5.2; estable actual 6.0

Vale la pena señalar una trampa: que Symbol.dispose esté definido no significa que el motor pueda analizar using. El símbolo suele estar disponible antes que la declaración — Node expuso los símbolos bien conocidos desde la línea v18 (18.18.0) pero solo hizo nativa la sintaxis de declaración en Node 24, y Chrome incorporó el símbolo alrededor de la v125 pero no analizó using hasta la 134. Por lo tanto, en un motor antiguo puedes referenciar Symbol.dispose y aun así obtener un error de sintaxis o un TypeError de “not disposable” — y una tabla de soporte de Symbol.dispose (como la enlazada anteriormente) va por delante del soporte real de using. El soporte completo de using es una cuestión de versión del motor, no de polyfill.

Para Safari y objetivos más antiguos, transpila. TypeScript requiere configurar el objetivo de compilación en ES2022 o inferior y configurar lib para incluir "esnext" o "esnext.disposable" (TypeScript soporta la sintaxis desde la versión 5.2), y puedes hacer polyfill de los globales con core-js o el paquete disposablestack.

El código de limpieza mecánico y propenso a errores que has estado escribiendo manualmente ahora tiene una primitiva del lenguaje: agrega [Symbol.dispose] o [Symbol.asyncDispose] a cualquier cosa que necesite desmontaje, declárala con using o await using, y el entorno de ejecución garantiza la eliminación en el orden correcto en cada ruta de salida. Comienza envolviendo el recurso que más frecuentemente olvidas cerrar — una conexión, un bloqueo, un lector — y deja que el ámbito haga el resto.

Preguntas Frecuentes

¿Cuál es la diferencia entre using y await using?

Una declaración using invoca el método síncrono Symbol.dispose del objeto cuando el bloque finaliza, mientras que await using invoca Symbol.asyncDispose y lo espera, de modo que el desmontaje que devuelve una promesa se completa antes de que el ámbito se cierre. Usa using cuando la limpieza es síncrona y await using cuando el desmontaje devuelve una promesa. Como await using también acepta objetos desechables síncronos simples, puedes usarlo siempre que no estés seguro de qué tipo de método de eliminación lleva un objeto.

¿Por qué using lanza un error 'not disposable' en Node 18 aunque Symbol.dispose existe?

Que el símbolo Symbol.dispose esté definido no significa que el motor pueda analizar la declaración using. Node expuso los símbolos bien conocidos Symbol.dispose y Symbol.asyncDispose desde la versión 18.18.0, pero la sintaxis completa de declaración using y await using solo se hizo nativa en Node 24, que incorpora V8 13.6. En versiones anteriores de Node puedes referenciar el símbolo y aun así obtener un error de sintaxis o un TypeError en los manejadores integrados. El soporte completo de using es una cuestión de versión del motor, no de polyfill.

¿La Gestión Explícita de Recursos reemplaza a la recolección de basura?

No. La recolección de basura recupera memoria según su propio calendario no determinista y nunca cerró archivos, liberó bloqueos ni canceló la suscripción de event listeners. La Gestión Explícita de Recursos maneja esos recursos no relacionados con la memoria de forma determinista, ejecutando su limpieza en el momento en que un ámbito finaliza. Los dos mecanismos abordan problemas diferentes. WeakRef y FinalizationRegistry son las herramientas relacionadas con el GC para la memoria, mientras que using y await using proporcionan el desmontaje basado en ámbito de manejadores de archivos, sockets, bloqueos y conexiones.

¿Cómo uso la palabra clave using con una biblioteca que no tiene método dispose?

Adjunta el símbolo tú mismo con Object.assign. Un cliente que expone un método close o end pero no tiene símbolo de eliminación se convierte en desechable en una sola línea asignando un método Symbol.asyncDispose asíncrono que llama al método close existente. Después de eso puedes declarar el objeto con await using o registrarlo con una llamada a AsyncDisposableStack use. Si más adelante actualizas a una versión de la biblioteca que implementa el protocolo de forma nativa, la reasignación genera un error que te recuerda eliminar el shim.

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.