Should you use AsyncLocalStorage?

Exploring Different Approaches to Dependency Injection in Node.js

Eytan Manor

--

We’ve been assigned a task to implement a REST API for a todo list application that supports CRUD operations. The todo app is personal, meaning that a user can only manage their own todo items. Accordingly, we will have the following components in our system:

  • A managed Auth service to authenticate requests and yield their belonging user.
  • A Database to store and alter todo data.
  • An application router to handle HTTP requests.

The description above suggests that we should have an Auth client and a Database client, both of which can be consumed by the application router, as well as other potential components. We can achieve that by creating a dedicated module for each client implementation, so they can be imported and consumed wherever they’re needed:

In a singular environment the implementation can remain so, but more likely than not, we will need to run our application in different types of environments, which can make statically initialized assets irrelevant in some circumstances. In a testing environment, for example, we often want to use mock clients instead of real ones. But how exactly can we accomplish that? Let’s explore different approaches.

Dependency Injection via Function Parameters

This approach requires us to wrap components with classes or factory functions that accept dependencies as parameters, enabling us to provide mock implementations and write more isolated unit tests:

This is a solid approach that works perfectly fine for many applications. Nevertheless, as our application grows and becomes more complex, passing dependencies as parameters can become challenging, because parent components will carry an increasing number of parameters as we ascend the hierarchy tree:

Here are a few cases where this issue becomes especially noticeable:

  • There’s a dependency that’s used very frequently, like a logger or a telemetry client. Just like that, almost every function in our application will carry 2 more extra parameters.
  • Dependencies are used in library functions that weren’t designed to receive dependencies via parameters. You may find yourself monkey patching implementations just to make that happen.
  • Call stack is difficult to trace, and adding another layer of complexity isn’t exactly helpful and can slow down debugging.

Dependency Injection via a Static Store

To mitigate that, we can take an alternative approach that doesn’t require us to pass dependencies via parameters. Instead, we can use a static object that can store dependencies and can be imported by different components accross our application. It’s up to us to load the store with the dependencies that we want during the application’s bootstrap phase:

That’s an excellent approach for solving the bloated parameters problem, because we don’t need to constantly drill dependencies in our application. However, when we use a static store to inject dependencies, it needs to be done with care, as it doesn’t ensure type-safety, and we can easily omit a dependency without noticing. For that reason, I’d always prefer using parameters, unless it becomes “too complicated” (see the list of bullet points mentioned earlier).

But even then, we’re still not completely covered. What if we want to have dependencies that are context specific? And not static for the entire application? In our case, we want to have a dependency that’s specific to a request, particularly one that’s authenticated by the Auth service.

Dependency Injection via WeakMaps

As mentioned, the Auth service will yield a user for a given request, which can be useful information for some logic to come. Thus, in addition to having a store that can serve the app, we can also have a store that can serve a request. That can be acheived with a WeakMap, where the key is a request object, and the value is a store that can be populated with auth-related data:

This pretty much covers most use-cases, except for one; when we no longer have access to the request object.

Introducing: AsyncLocalStorage API

It appears that the maintainers of Node.js were already aware of this problem when they introduced the AsyncLocalStorage class — an API that was in fact considered stable long ago, dating back to Node.js version 16.4.0 (see changelog). Here’s a code snippet that demonstrates how the AsyncLocalStorage API works, directly from the Node.js docs:

import http from 'node:http';
import { AsyncLocalStorage } from 'node:async_hooks';

const asyncLocalStorage = new AsyncLocalStorage();

function logWithId(msg) {
const id = asyncLocalStorage.getStore();
console.log(`${id !== undefined ? id : '-'}:`, msg);
}

let idSeq = 0;
http.createServer((req, res) => {
asyncLocalStorage.run(idSeq++, () => {
logWithId('start');
// Imagine any chain of async operations here
setImmediate(() => {
logWithId('finish');
res.end();
});
});
}).listen(8080);

http.get('http://localhost:8080');
http.get('http://localhost:8080');
// Prints:
// 0: start
// 1: start
// 0: finish
// 1: finish

As demonstrated, we are able to log the request ID without keeping any reference to the request object. We can leverage this API to implement a dependency injection infrastructure:

import { AsyncLocalStorage } from 'node:async_hooks';

const asyncLocalStorage = new AsyncLocalStorage<AsyncScope>();

export interface AsyncScope {
[key: symbol]: unknown;
}

export class AsyncScope {
static get() {
const scope = asyncLocalStorage.getStore();
if (!scope) {
throw new Error('Scope not found');
}

return scope;
}

constructor(callback: () => void) {
const parentScope = asyncLocalStorage.getStore();
if (parentScope) {
Object.setPrototypeOf(this, parentScope);
}

asyncLocalStorage.run(this, callback);
}
}

export class AsyncVar<T> {
private readonly symbol = Symbol(this.name);

constructor(readonly name: string) {
}

set(value: T) {
const scope = AsyncScope.get();

scope[this.symbol] = value;
}

get() {
if (!this.exists()) {
throw new Error(`Varialble "${this.name}" not found`);
}

const scope = AsyncScope.get();

return scope[this.symbol] as T;
}

exists() {
const scope = AsyncScope.get();

return this.symbol in scope;
}
}

In this example, the AsyncVar class uses AsyncLocalStorage to get() and set() values for the active AsyncScope. If we try to get a value before it was set, an error will be thrown. It works very similar to a regular scope in JavaScript.

So given that our objective is to build an application that utilizes an Auth service and a Database, we should have 2 AsyncVars — one for the AuthClient, and one for the DatabaseClient:

const authVar = new AsyncVar<AuthClient>('Auth');
const dbVar = new AsyncVar<DatabaseClient>('Database');

The values of the AsyncVars can be initialized in advance, and set once there’s an incoming HTTP request. This will make them consumable accross different parts of our application:

export const bootstrapMiddleware = () => {
const auth = new AuthClient();
const db = new DatabaseClient();

return (req, res, next) => {
new AsyncScope(() => {
authVar.set(auth);
dbVar.set(db);
next();
});
};
};

Note that an AsyncScope cannot be created before a request callback was triggered, because it will be disposed by the time the event occurs. An AsyncLocalStorage can only maintain the value of a store if an async task was scheduled to run by the event loop, not by the system. If an event was triggered by an FS event, network event, process event, etc, we will no longer be able to use the values stored in AsyncVars, hence, we need to reset variables on each and every HTTP request. Here’s an example for a code that does NOT manage AsyncVars correctly:

const auth = new AuthClient();
const db = new DatabaseClient();

// ❌ This is NOT how to set async vars
new AsyncScope(() => {
authVar.set(auth);
dbVar.set(db);

app.use(router);
});

Once the application dependencies are ready and set, we can use them anywhere we want. For example, we can build an authentication middleware that will authenticate a request and will return its belonging user:

export const userVar = new AsyncVar('User');

export const authMiddleware = async (req, res, next) => {
const auth = authVar.get();
const user = await auth.cookie(req, res);
if (user) {
userVar.set(user);
next();
}
else {
res.sendStatus(401);
}
};

As you can see, AsyncVars are dynamic and can be set on the fly, which can be useful if we want to use a computation result, instead of running it all over again. Here’s an example of how we can use the result of the authentication middleware in the application router, to get resources that are specific to the user who initiated the request:

export const router = Router();

router.use(authMiddleware);

router.get('/todos', async (req, res) => {
const db = dbVar.get();
const user = userVar.get();
const todos = await db.select('*').from('todos').where('userId', db.eq(user.id));
res.json(todos);
});

Not so fast

There seems to be a performance degradation the more active AsyncLocalStorages we have, and there’s even a commit in the Node.js repo that adds micro benchmarks to prove this point. I’ve actually went ahead and ran a couple of these benchmarks; below are the results:

File: benchmark/async_hooks/async-local-storage-getstore-nested-run.js

Description:

This benchmark verifies the performance of
`AsyncLocalStorage.getStore()` on multiple `AsyncLocalStorage` instances
nested `AsyncLocalStorage.run()`s.

- AsyncLocalStorage1.run()
- AsyncLocalStorage2.run()
...
- AsyncLocalStorageN.run()
- AsyncLocalStorage1.getStore()

Results:

sotrageCount=1: 8,914,356.214108573 operations / s
sotrageCount=10: 8,370,904.744126554
sotrageCount=100: 8,845,322.72602232
File: benchmark/async_hooks/async-local-storage-propagate-promise.js

Description:

This benchmark verifies the performance degradation of
async resource propagation on the increasing number of
active `AsyncLocalStorage`s.

- AsyncLocalStorage.run()
- Promise
- Promise
...
- Promise

Results:

storageCount=0: 16,881,948.082269784 operations / s
storageCount=1: 5,898,533.311996372
storageCount=10: 1,917,448.180195951
storageCount=100: 137,230.01510918932

According to the results, there doesn’t seem to be any effect on synchronous code, but there does seem to be a correlation between the number of active stores and a Promise invocation time. This makes sense after all, because AynscLocalStorage basically wraps async operations inorder to provide us with the right store each time they’re executed.

See source code

The Node.js docs do seem to emphasize the fact that AsyncLocalStorage involves significant optimizations that are non-obvious to implement, but they don’t go into the extent of explaining what that means, which I think is key if we want to be able to use the API in the most optimal way.

So to answer the question of this article — should you use AsyncLocalStorage? I’d say it depends. It’s more performant not to, but then again, there’s a lot of APIs that we use on a regular basis that make things significantly less performant, like promise vs. callback, as this benchmark suggests. Take micro benchmarks with a grain of salt, and try it out on your project and see if that makes sense to you, it might save you a lot of time on the long run.

--

--

Responses (4)