Home/JavaScript/Errors and resource lifetimes

Errors and resource lifetimes

Error contracts, synchronous and asynchronous cleanup, and preserving multiple failures.

Errors carry a failure across call boundaries so each layer can handle the part it owns. Resource cleanup must follow every exit: successful return, exception, cancellation, and partial initialization. JavaScript's garbage collector reclaims unreachable memory; it does not provide a timely close protocol for files, sockets, locks, or subscriptions.

Throw meaningful failures at the right boundary

Throw Error objects rather than strings so callers get a stack and a recognizable type. Use TypeError for an incompatible value kind, RangeError for an unsupported numeric/domain range when appropriate, and a domain-specific class when callers need a stable business distinction. Preserve the cause when wrapping a lower-level failure.

JavaScript
try {
  JSON.parse("broken");
} catch (cause) {
  const failure = new Error("Settings could not be decoded", {cause});
  console.log(failure.cause instanceof SyntaxError); // true
}

Do not catch an error merely to return undefined unless that is the documented result. Distinguish a missing record, a rejected operation, and an unavailable dependency. A retryable transport failure also does not establish that a remote mutation did not happen.

Finally follows control flow

A finally block runs when execution exits its try block. Returning or throwing from finally can replace an earlier result or exception, which is rarely the intended error policy. Put cleanup there, but decide what happens if cleanup itself fails.

JavaScript
const events = [];
try { events.push("work"); }
finally { events.push("close"); }
console.log(events); // ["work", "close"]

Acquisition can fail before a resource exists, so cleanup should apply only to successfully acquired resources. If a wrapper owns the resource, close it exactly once. If ownership remains with the caller, closing it inside a helper may be a bug. Establish that contract before choosing a convenient syntax.

Await the lifetime you claim to manage

A try/finally around a returned promise can close the resource before that promise finishes unless the relevant work is awaited within the lifetime. The same issue occurs when starting detached work from inside a request scope.

JavaScript
async function inspect(open) {
  const resource = await open();
  try { return await resource.read(); }
  finally { await resource.close(); }
}

This integration fragment assumes read and close methods with asynchronous contracts. It demonstrates ordering, not a complete policy for simultaneous body and close failure. Disposal syntax may be available in particular runtimes, but explicit try/finally remains useful for understanding the ownership mechanism.

Preserve evidence when cleanup also fails

If both the main operation and cleanup fail, blindly throwing the cleanup error hides the original failure. AggregateError can retain both in a defined order. If only one fails, preserve that exact error object so callers can correlate its type, cause, and identity.

Cancellation is a request to stop useful work, not automatic resource cleanup in every API. Pass an AbortSignal to operations that support it, then wait for their cleanup according to the application's deadline. Releasing local resources cannot roll back a remote effect that already committed. Test failure interleavings with controlled promises instead of hoping a sleep happens to expose the race.

Put it into practice

Implement the exercise in a full-screen workspace, then run its tests.

Open practice
Next lessonModules and runtime data boundaries