Post

Thread-local storage (TLS) in Node.js

Thread-local storage (TLS) in Node.js

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 TLS is and why we should use it. Last but not least, we are going to see how we can implement it in Node.js.

2. Thread-local storage (TLS)

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

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

A global variable is accessible throughout the lifecycle of a program. There are no restrictions on who can access the variable and when. 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 the variable. For instance, Java provides the 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 perform some actions on behalf of the user.

When we log the actions the user performs on our server, we would always like to have the 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 complicated very quickly, especially if we want to log something in a function deep down in the call stack.

An alternative to passing down the user information to every function 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, interleaved. Control passes from one request to another via callbacks, promises 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 12.17, it’s possible to use a TLS-like concept via AsyncLocalStorage.

Let’s imagine we have 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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const DUMMY_ORDERS = {
    "1": [
        { id: 1, sum: 20 },
        { id: 2, sum: 40 }
    ]
};

const logDoSomethingElse = (user) => {
    console.log(`Doing something else as 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 the function retrieveAllOrdersForUser, which is responsible for fetching all the orders of 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 write a line to the access logs with the user context, so we need to invoke logDoSomethingElse.

To get the user information into 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 cumbersome and increases the chance of a mistake.

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

The core idea of AsyncLocalStorage is that a stored value persists throughout a single chain of callbacks or promises. For example, we could introduce middleware which initializes the context for every request with the user ID. Here’s a traditional Express middleware:

1
2
3
4
5
6
7
8
9
10
11
12
13
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.

1
2
3
4
5
6
7
8
9
10
const express = require('express');
const { loggerMiddleware } = require('./logger-middleware');
const ordersRoute = require('./orders-route');

const PORT = 3000;

const app = express();
app.use(loggerMiddleware);
app.get('/orders', ordersRoute);
app.listen(PORT);

Looking at the controller again, we can see that we no longer need the argument user for doSomethingElse and logDoSomethingElse. That’s way better. We can get the ID of the user using asyncLocalStorage.getStore(), which returns the Map unique to the current request. From there, we read the ID of the user with Map.get and log it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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 user ${user}`);
};

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

const retrieveAllOrdersForUser = (user) => {
    doSomethingElse();

    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 context deep down the call stack without threading it through every function signature. Node.js has no threads to speak of, but AsyncLocalStorage gives us the same capability for asynchronous call chains.

© Kert Pjatkin. All rights reserved.