EdgeCases Logo
Mar 2026
JavaScript
Expert
8 min read

Explicit Resource Management: using and Symbol.dispose

JavaScript's answer to RAII — automatic cleanup for file handles, locks, and event listeners that actually runs.

javascript
using
symbol-dispose
resource-management
raii
cleanup

JavaScript has always struggled with cleanup. We've used try/finally, manual cleanup in useEffect returns, and hoped we didn't forget. Now ECMAScript's Explicit Resource Management brings the using keyword and Symbol.dispose — automatic, guaranteed cleanup when a resource goes out of scope. Think C++ RAII or C# using, but for JavaScript.

The using Declaration

// ✅ Resource is ALWAYS disposed when block exits
{
  using file = await openFile('data.txt');
  const content = file.read();
  processContent(content);
  // file is disposed here — even if processContent throws
}

// ✅ Works with functions
function processData() {
  using lock = acquireLock();
  // ... do work ...
  return result;
  // lock released after return value is captured, before function exits
}

The using keyword declares a variable whose value will be disposed when the enclosing scope exits — whether normally, via return, or via exception. The value must have a [Symbol.dispose]() method or be null/undefined.

Making Objects Disposable

class DatabaseConnection {
  #connection;
  #disposed = false;

  constructor(connectionString) {
    this.#connection = connect(connectionString);
  }

  query(sql) {
    if (this.#disposed) {
      throw new Error('Connection already disposed');
    }
    return this.#connection.query(sql);
  }

  // The disposal method
  [Symbol.dispose]() {
    if (!this.#disposed) {
      this.#connection.close();
      this.#disposed = true;
    }
  }
}

// Usage
{
  using db = new DatabaseConnection('postgres://...');
  const users = db.query('SELECT * FROM users');
  // db.close() called automatically here
}

Implement [Symbol.dispose]() to define cleanup logic. It should be idempotent — safe to call multiple times. Don't throw unless cleanup truly failed.

Async Disposal: await using

class AsyncResource {
  async [Symbol.asyncDispose]() {
    await this.flushBuffers();
    await this.closeConnection();
  }
}

// Use await using for async disposal
async function process() {
  await using resource = new AsyncResource();
  await resource.doWork();
  // asyncDispose awaited here
}

Use Symbol.asyncDispose when cleanup is async. The await using declaration awaits the disposal promise before the scope exits.

Practical Patterns

Event Listener Cleanup

function createEventDisposable(target, event, handler) {
  target.addEventListener(event, handler);
  return {
    [Symbol.dispose]() {
      target.removeEventListener(event, handler);
    }
  };
}

function trackMousePosition() {
  using listener = createEventDisposable(
    document,
    'mousemove',
    e => console.log(e.clientX, e.clientY)
  );
  
  // ... do something ...
  // Listener removed automatically when function exits
}

AbortController Cleanup

function createAbortDisposable() {
  const controller = new AbortController();
  return {
    signal: controller.signal,
    [Symbol.dispose]() {
      controller.abort('Scope exited');
    }
  };
}

async function fetchWithAutoCancel(url) {
  using abortable = createAbortDisposable();
  
  try {
    return await fetch(url, { signal: abortable.signal });
  } catch (e) {
    if (e.name === 'AbortError') return null;
    throw e;
  }
  // Request cancelled if we exit early
}

Mutex/Lock Pattern

class Lock {
  #locked = false;
  #queue = [];

  async acquire() {
    if (this.#locked) {
      await new Promise(resolve => this.#queue.push(resolve));
    }
    this.#locked = true;
    return {
      [Symbol.dispose]: () => {
        this.#locked = false;
        this.#queue.shift()?.();
      }
    };
  }
}

const lock = new Lock();

async function criticalSection() {
  using _guard = await lock.acquire();
  // Only one caller at a time reaches here
  await doExclusiveWork();
  // Lock released when block exits
}

Timer Cleanup

function createTimeout(ms) {
  let id;
  const promise = new Promise(resolve => {
    id = setTimeout(resolve, ms);
  });
  
  return {
    promise,
    [Symbol.dispose]() {
      clearTimeout(id);
    }
  };
}

async function withTimeout() {
  using timer = createTimeout(5000);
  
  // If we return early, the timeout is cleared
  if (cachedResult) return cachedResult;
  
  await timer.promise;
  return computeExpensiveResult();
}

DisposableStack: Managing Multiple Resources

async function complexOperation() {
  using stack = new DisposableStack();
  
  // Add resources in order; disposed in reverse order
  const db = stack.use(new DatabaseConnection());
  const file = stack.use(await openFile('log.txt'));
  const lock = stack.use(await mutex.acquire());
  
  // All three disposed when function exits,
  // in order: lock, file, db
  
  await db.query('INSERT...');
  await file.write('Success');
}

// For async disposal
async function asyncComplex() {
  await using stack = new AsyncDisposableStack();
  
  stack.use(await asyncResource1());
  stack.defer(async () => await cleanup());
  
  // All disposals awaited in reverse order
}

DisposableStack collects disposables and disposes them in reverse order. Use defer() to add arbitrary cleanup callbacks.

Error Handling: SuppressedError

When disposal throws, JavaScript doesn't lose the original error:

class FailingResource {
  [Symbol.dispose]() {
    throw new Error('Disposal failed');
  }
}

try {
  using resource = new FailingResource();
  throw new Error('Main error');
} catch (e) {
  // e is a SuppressedError
  console.log(e.error);      // Error: Disposal failed
  console.log(e.suppressed); // Error: Main error
}

SuppressedError chains all errors — the disposal error becomes error, the original exception becomes suppressed. Multiple disposal errors nest further.

Edge Cases and Gotchas

Closure Escape

function danger() {
  using resource = new Resource();
  
  // ⚠️ Returning a closure that captures the resource
  return () => resource.doWork();
  // Resource disposed BEFORE function returns!
}

const fn = danger();
fn(); // Error: Resource already disposed

The resource is disposed when the function scope exits, but the closure still holds a reference. Avoid returning closures that capture disposed resources.

No Top-Level Script

// ❌ Not allowed in scripts (scope never exits)
using resource = new Resource(); // SyntaxError

// ✅ Allowed in modules (disposed when module finishes loading)
using resource = new Resource(); // OK in .mjs

Reverse Order Matters

using a = new ResourceA();
using b = new ResourceB(a); // b depends on a

// When scope exits: b disposed first, then a
// This is correct — dependent resources cleaned up first

Browser & TypeScript Support

As of 2026: Chrome 127+, Safari 18+, Firefox 132+. TypeScript 5.2+ supports using natively with the lib: ["esnext.disposable"] config. For older environments, use the core-js polyfill:

import "core-js/proposals/explicit-resource-management";

// Feature detection
if (Symbol.dispose) {
  // Native support
}

Advertisement

Related Insights

Explore related edge cases and patterns

Browser APIs
Deep
AbortSignal.any(): Composing Cancellation Signals
7 min
Browser APIs
Surface
Web Workers Structured Clone: What Can't Cross the Boundary
6 min
JavaScript
Deep
structuredClone(): The Deep Copy That Isn't Always Deep
7 min

Advertisement