Thread-local storage (TLS) in Node.js


1. Introduction

Thread-local storage (TLS) is a widely used concept, which allows sharing a variable within a single thread.

In this article we are going to look at what is a TLS, why should we use it. Last but not least, we are going to see how can we implement it in Node.js.

2. Thread-local storage (TLS)

In a programming language, the most well-known variable scopes are either block scope or a global variable.

A block scope variable is accessible within a single block. For example, a block could be a function, if-else statement or a for-loop.

A global variable is accessible throughout the lifecycle of a program. There are no restrictions on who and when can access the variable. The critical point is that in a multithreaded environment, all threads will have the same reference to the global variable.

TLS is almost like a global variable with a slight difference. The scope of the variable is a single thread. If we take a traditional multithreaded environment, such as Java, every thread will have its’ own reference to a variable. For instance, Java provides ThreadLocal API to support TLS.

3. Use cases of TLS

Why do we need TLS? One of the most common scenarios is logging. Imagine we have a web server with authentication. We extract the user information from the request and do some actions with the user information passed to our server.

When we are logging about the actions the user is performing in our server, we would always like to have user information in the resulting log output. How can we achieve this? Seemingly the most straightforward option is to pass down the user information to where we are invoking the log statements.

However, this gets very quickly very complicated. Especially if we want to log something down in a function deep down in the call stack.

An alternative to passing down the user information to every method would be to use TLS. Before starting to process the request, we set the value of a thread-local variable and later on use it in the log statement.

4. Thread-local storage in Node.js

As we might know, Node.js is not a multithreaded environment. When we write a web server in Node.js, only a single thread is responsible for processing all the requests.

When several requests arrive at the server, a single thread handles all of them at the same time. The control from one request to another is passed on using callbacks or async-await.

Considering that, it’s hard to talk about thread-local variables in Node.js. Or is it? The good news is that starting from Node version 12, it’s possible to use TLS-like concept using AsyncLocalStorage.

Let’s imagine we have a code which is responsible for fetching all the orders of a user. Below you can see a sample controller which processes requests from the users.

const DUMMY_ORDERS = {
    "1": [
        { id:1, sum: 20 },
        { id:2, sum: 40 }
    ]
}

const logDoSomethingElse = (user) => {    
    console.log(`Doing something else as an user ${user}`);
}

const doSomethingElse = (user) => {
    // Doing something else
    logDoSomethingElse(user);
}

const retrieveAllOrdersForUser = (user) => {
    doSomethingElse(user);
    
    return DUMMY_ORDERS[user];
}

const ordersRoute = (request, response) => {
    const orders = retrieveAllOrdersForUser(request.headers.user);

    response.send(orders);
}

module.exports = ordersRoute

The controller invokes function retrieveAllOrdersForUser which is responsible for fetching all the orders for a single user. We also have a function doSomethingElse which is not specific to any single user. At the end of doSomethingElse, we would like to log down the line for the access logs with the user context - we need to invoke logDoSomethingElse.

To get the user information for the log record, we need to pass user as an argument to logDoSomethingElse. We need to modify the signatures of several functions just to enrich a log statement with the user information. That’s bad and increases the chance of an accident.

This is the place where AsyncLocalStorage comes to the rescue. AsyncLocalStorage is the way to store values over the lifetime of an asynchronous call. Let’s see how to take advantage of it.

The core idea of AsyncLocalStorage is that the values of the variables persist within a single callback or promise chain. For example, we could introduce middleware which initializes the context for every request with the user ID. We can see a traditional Express middleware:

const { AsyncLocalStorage } = require('async_hooks');
const asyncLocalStorage = new AsyncLocalStorage();

const loggerMiddleware = (req, res, next) => {
    const { user } = req.headers;

    const map = new Map();
    map.set('user', user);

    asyncLocalStorage.run(map, () => next());
}

module.exports = { loggerMiddleware, asyncLocalStorage };

We initialize a global variable asyncLocalStorage, and for every incoming request, we invoke asyncLocalStorage.run with a Map. The Map itself contains a key user with the value extracted from the request headers.

We need to invoke app.use to install the middleware.

const { loggerMiddleware } = require('./logger-middleware');

const PORT = 3000;

const app = express();
app.use(loggerMiddleware);

Looking into the controller, we can see that we don’t need the argument user for doSomething and logDoSomethingElse. That’s way better. We can get the ID of the user using asyncLocalStorage.getStore() which returns the Map unique to a specific request. We’re able to get the ID of the user using Map.get and log it down.

const {asyncLocalStorage} = require('./logger-middleware');

const DUMMY_ORDERS = {
    "1": [
        { id:1, sum: 20 },
        { id:2, sum: 40 }
    ]
}

const logDoSomethingElse = () => {    
    const contextStore = asyncLocalStorage.getStore();
    const user = contextStore.get('user');

    console.log(`Doing something else as an user ${user}`);
}

const doSomething = () => {
    // Doing something else
    logDoSomethingElse();
}

const retrieveAllOrdersForUser = (user) => {
    doSomething();
    
    return DUMMY_ORDERS[user];
}

const ordersRoute = (request, response) => {
    const orders = retrieveAllOrdersForUser(request.headers.user);

    response.send(orders);
}

module.exports = ordersRoute

5. Conclusion

All in all, thread-local storage (TLS) comes in handy when we want to pass down context deep down in the call stack.