Resource management
For automatic cleanup of resources
Resources like database connections, file handles, and locks need cleanup. Manual cleanup is easy to forget, and try/finally is verbose and does not compose. JavaScript now has explicit resource management: the using declaration disposes a resource when it goes out of scope, on every exit path.
This page introduces that feature and then shows how the Evolu Library builds on it: the disposable helper for objects, Task and Run for asynchronous ownership, and shared resources with leases. Safari and React Native still need Evolu's polyfills for the runtime globals used below.
Disposable resources
A resource is disposable when it has a [Symbol.dispose] method. Bind it with using, and it is disposed when the block exits, even when the block throws:
import { assertErr, assertTrue, trySync } from "@evolu/common";
let closed = false;
const openConnection = (): Disposable => ({
[Symbol.dispose]: () => {
closed = true;
},
});
const work = () => {
using _connection = openConnection();
// _connection is disposed when work exits, even though it throws.
throw new Error("doWork failed");
};
assertErr(trySync(work));
assertTrue(closed);
For asynchronous cleanup, implement [Symbol.asyncDispose] and bind the resource with await using:
import { assertTrue } from "@evolu/common";
let closed = false;
const openConnection = (): AsyncDisposable => ({
[Symbol.asyncDispose]: async () => {
await Promise.resolve();
closed = true;
},
});
{
await using _connection = openConnection();
}
assertTrue(closed);
Block scopes
Use block scopes to control exactly when resources are disposed:
import { assertEqual } from "@evolu/common";
const log: Array<string> = [];
const createLock = (name: string): Disposable => ({
[Symbol.dispose]: () => {
log.push(`unlock:${name}`);
},
});
log.push("start");
{
using _lock = createLock("a");
log.push("critical-section-a");
// lock "a" is released when this block exits
}
log.push("between");
{
using _lock = createLock("b");
log.push("critical-section-b");
// lock "b" is released when this block exits
}
log.push("end");
assertEqual(log, [
"start",
"critical-section-a",
"unlock:a",
"between",
"critical-section-b",
"unlock:b",
"end",
]);
DisposableStack
When a function acquires several resources, use DisposableStack to own all of them. It disposes its resources in reverse order and keeps going when one of them fails.
Result and Disposable are orthogonal: Result answers "Did the operation succeed?", Disposable answers "When do we clean up?". An early return on an Err is just another scope exit, so the stack disposes whatever has been acquired so far:
import {
assertEqual,
assertErr,
assertOk,
err,
ok,
type Result,
type Typed,
} from "@evolu/common";
const log: Array<string> = [];
const createResource = (
name: string,
shouldFail = false,
): Result<Disposable, CreateResourceError> => {
if (shouldFail) return err({ type: "CreateResourceError", name });
log.push(`create:${name}`);
return ok({
[Symbol.dispose]: () => {
log.push(`dispose:${name}`);
},
});
};
interface CreateResourceError extends Typed<"CreateResourceError"> {
readonly name: string;
}
const processResources = (
failFile: boolean,
): Result<string, CreateResourceError> => {
using disposer = new DisposableStack();
const db = createResource("db");
if (!db.ok) return db;
disposer.use(db.value);
const file = createResource("file", failFile);
// On error, disposer disposes db.
if (!file.ok) return file;
disposer.use(file.value);
// On success, disposer disposes file, then db.
return ok("processed");
};
assertOk(processResources(false), "processed");
assertEqual(log, ["create:db", "create:file", "dispose:file", "dispose:db"]);
log.length = 0;
assertErr(processResources(true), {
type: "CreateResourceError",
name: "file",
});
assertEqual(log, ["create:db", "dispose:db"]);
For asynchronous resources, use AsyncDisposableStack with await using.
API overview:
disposer.use(resource)adds a disposable resource and returns it.disposer.defer(fn)adds a cleanup function, like Go'sdefer.disposer.adopt(value, cleanup)wraps a non-disposable value with cleanup.disposer.move()transfers ownership to a new stack.
Disposal continues even when one dispose throws. The remaining resources are still disposed, and the failure is rethrown afterwards. A hand-written loop over resources would stop at the first throw:
import { assertEqual, assertThrowsInstanceOf } from "@evolu/common";
const log: Array<string> = [];
const createResource = (name: string, failDispose = false): Disposable => ({
[Symbol.dispose]: () => {
if (failDispose) throw new Error(`dispose:${name}`);
log.push(`dispose:${name}`);
},
});
// Disposes "c", fails on "b", still disposes "a", then rethrows.
const error = assertThrowsInstanceOf(() => {
using disposer = new DisposableStack();
disposer.use(createResource("a"));
disposer.use(createResource("b", true));
disposer.use(createResource("c"));
}, Error);
assertEqual(error.message, "dispose:b");
assertEqual(log, ["dispose:c", "dispose:a"]);
Ownership transfer
When a factory creates resources for use elsewhere, move() transfers ownership out of the factory. Without it, the stack would dispose the resources when the factory returns, even on success:
import {
assertEqual,
assertErr,
assertOk,
err,
ok,
type Result,
type Typed,
} from "@evolu/common";
interface FileHandle extends Disposable {
readonly path: string;
}
const log: Array<string> = [];
const open = (path: string): Result<FileHandle, OpenFileError> => {
if (path.startsWith("missing")) return err({ type: "OpenFileError", path });
log.push(`open:${path}`);
return ok({
path,
[Symbol.dispose]: () => {
log.push(`close:${path}`);
},
});
};
interface OpenFileError extends Typed<"OpenFileError"> {
readonly path: string;
}
interface OpenFiles extends Disposable {
readonly handles: ReadonlyArray<FileHandle>;
}
const openFiles = (
paths: ReadonlyArray<string>,
): Result<OpenFiles, OpenFileError> => {
using disposer = new DisposableStack();
const handles: Array<FileHandle> = [];
for (const path of paths) {
const file = open(path);
// On error, disposer closes the files opened so far.
if (!file.ok) return file;
handles.push(disposer.use(file.value));
}
// Success: transfer ownership to the caller
const disposables = disposer.move();
return ok({ handles, [Symbol.dispose]: () => disposables.dispose() });
};
assertErr(openFiles(["a.txt", "missing.txt"]), {
type: "OpenFileError",
path: "missing.txt",
});
assertEqual(log, ["open:a.txt", "close:a.txt"]);
log.length = 0;
{
const result = openFiles(["a.txt", "b.txt"]);
assertOk(result);
using files = result.value;
assertEqual(
files.handles.map((handle) => handle.path),
["a.txt", "b.txt"],
);
// files are closed when this block exits, in reverse order
}
assertEqual(log, ["open:a.txt", "open:b.txt", "close:b.txt", "close:a.txt"]);
The naming mirrors the two roles: disposer while the factory is still registering resources, disposables after ownership has moved to the returned object.
Disposable objects in Evolu
Evolu creates objects with createX factories instead of classes, and it creates disposable objects with disposable. The helper adds the disposal method and wraps the object's function-valued properties with a guard, so calling a disposed object throws immediately instead of operating on disposed state. This is the JavaScript equivalent of .NET's ObjectDisposedException: use after disposal is a programmer error, and it should fail fast.
Pass a DisposableStack or AsyncDisposableStack when the object owns cleanup resources. The helper moves the stack into the returned object, so the move() and [Symbol.dispose] boilerplate from the Ownership transfer section disappears:
import {
assertEqual,
assertThrowsInstanceOf,
assertTrue,
disposable,
} from "@evolu/common";
interface Counter extends Disposable {
readonly next: () => number;
}
let flushed = false;
const createCounter = (): Counter => {
let value = 0;
using disposer = new DisposableStack();
disposer.defer(() => {
flushed = true;
});
return disposable<Counter>(
{
next: () => {
value += 1;
return value;
},
},
disposer,
);
};
const counter = createCounter();
assertEqual(counter.next(), 1);
assertEqual(counter.next(), 2);
counter[Symbol.dispose]();
assertTrue(flushed);
const error = assertThrowsInstanceOf(() => counter.next(), Error);
assertEqual(error.message, "Cannot use a disposed object.");
Omit the stack when the object has no cleanup resources but must still become unusable after disposal. Evolu's RefCount does this: disposal enforces correct ownership tracking rather than releasing anything.
Do not hand-write isDisposed flags or assertNotDisposed guards in every method. That is the pattern disposable replaces, and it is easy to forget in one method. The assertNotDisposed assertion is what the guard uses internally. Call it directly only for what disposable cannot wrap, such as an accessor property.
Asynchronous ownership with Task and Run
The rest of this page builds on Task. If Task is new to you, skim these sections now and return after the Task step of the learning path.
using covers resources reachable from a stack frame. Asynchronous code has a second kind of resource: in-flight operations. Evolu models that with Task and Run. A composition root creates a root Run with createRun and binds it with await using. Async disposal of a Run aborts its child Tasks, waits for them to settle, and runs the finalizers registered with run.defer. Sync disposal starts the same shutdown without waiting.
Choose the ownership primitive by where the resource is reachable:
- Synchronous stack frame:
usingorDisposableStack. - Asynchronous Task stack frame:
await usingorAsyncDisposableStack. - Closure-held state bounded by a reusable Run:
run.defer.
An object that exposes several asynchronous operations creates one internal Run with run.create() and runs all of them through it. Disposing the object disposes that Run, which aborts in-flight operations, and the disposable guard makes later calls throw synchronously:
import {
AbortError,
assertEqual,
assertErr,
assertThrowsInstanceOf,
assertTrue,
createRun,
disposable,
ok,
sleep,
tryAsync,
type Task,
} from "@evolu/common";
interface Client extends AsyncDisposable {
readonly load: (id: string) => Promise<string>;
}
const loadUser =
(id: string): Task<string> =>
async (run) => {
await run.ok(sleep("1s"));
return ok(`user:${id}`);
};
const createClient: Task<Client> = async (run) => {
await using disposer = new AsyncDisposableStack();
// One internal Run owns every asynchronous operation of the object.
const clientRun = disposer.use(run.create());
return ok(
disposable<Client>({ load: (id) => clientRun.ok(loadUser(id)) }, disposer),
);
};
await using run = createRun();
const client = await run.ok(createClient);
const pending = client.load("ada");
await client[Symbol.asyncDispose]();
// Disposal aborted the in-flight operation...
const aborted = await tryAsync(() => pending);
assertErr(aborted);
assertTrue(AbortError.is(aborted.error));
// ...and later calls throw before starting anything.
const error = assertThrowsInstanceOf(() => client.load("grace"), Error);
assertEqual(error.message, "Cannot use a disposed object.");
A Task that returns a resource transfers ownership of a live resource to its caller, as createClient does above. Use run.ok with await using to bind such a value. Use acquireUseRelease when acquisition must be balanced with a separate release step that is not a disposable value, such as unlocking or logging out. See Resource management in the Task module for the full rules.
Shared resources
Some resources are expensive and used by many independent callers: a database connection, a WebSocket, a worker. The Resource module splits ownership in two. A SharedResource owns the resource. Callers hold a Lease, which they release with using. The resource is created lazily on the first acquire. Releasing the last lease starts its disposal, optionally after an idle delay. Release itself never waits for disposal to finish, so the example observes completion through the onDisposed callback:
import {
assertEqual,
assertFalse,
assertTrue,
createRun,
createSharedResource,
disposable,
ok,
type Task,
} from "@evolu/common";
interface Connection extends Disposable {
readonly send: (message: string) => void;
}
const log: Array<string> = [];
const createConnection: Task<Connection> = () => {
using disposer = new DisposableStack();
disposer.defer(() => {
log.push("close");
});
log.push("open");
return ok(
disposable<Connection>(
{
send: (message) => {
log.push(`send:${message}`);
},
},
disposer,
),
);
};
const disposed = Promise.withResolvers<void>();
await using run = createRun();
await using sharedConnection = await run.ok(
createSharedResource(createConnection, { onDisposed: disposed.resolve }),
);
// Nothing is open until the first acquire.
assertEqual(log, []);
{
using lease = await run.ok(sharedConnection.acquire);
assertTrue(lease.created);
lease.resource.send("first");
// A second lease shares the same connection.
using otherLease = await run.ok(sharedConnection.acquire);
assertFalse(otherLease.created);
otherLease.resource.send("second");
// Releasing the last lease starts disposal; onDisposed signals completion.
}
await disposed.promise;
assertEqual(log, ["open", "send:first", "send:second", "close"]);
A lease exposes the resource as a BorrowedResource, which removes the disposal methods from its type. The caller can use the connection but cannot dispose what it does not own. Nothing in JavaScript enforces that a lease is released, but a leaked lease never outlives its owner: disposing the SharedResource or its Run drains outstanding leases and disposes the resource.