Using AsyncLocalStorage in Next.js 🧰 ✚ ⚛️
Rexford Essilfie
Posted on June 18, 2023
Introduction
In this article, we showcase how to introduce AsyncLocalStorage into your Next.js application!
This post focuses on the new Next.js App Router. If you are wanting to use the Pages Router (pages/api directory), you would need a slightly modified approach, which will be available in the linked repository at the end soon! The concept and general idea remains the same, only a slight difference in API in the API route handlers.
Why AsyncLocalStorage?
AsyncLocalStorage, introduced in Node.js (v13.10.0, v12.17.0), brings storage to asynchronous operations in JavaScript. It allows you to run a function or callback within a special environment with access to its own storage. This storage is accessible anywhere within the callback.
Why is this significant? Well, in multithreaded languages like Java or PHP, processes are able to create threads, each of which has its own thread local storage in memory. Each of these threads can execute the same logic and interact with memory without having to worry about overriding each other’s storage! This is perfect for use cases such as request tracing in a backend web server since each thread can create and save a request ID, that can be referenced and logged by any functions the thread executes. Looking at such logs, we can trace a request from beginning to end using such a trace ID.
In JavaScript, however, there is only a single thread. All memory is accessible and modifiable by callbacks and methods. In a web server, when a single thread receives a request, it fires an event that is handled asynchronously by callbacks. Callbacks do not have their own memory to store something such as a request ID. To achieve something such as a unique ID for each request, we would have to pass an ID down through all function calls, which is not always doable. Even where possible, we would be lugging around extra data in memory for each function call.
Instead, AsyncLocalStorage allows asynchronous callbacks to behave like threads - having their own local private store which can be accessed anywhere within the execution of the callback!
This opens the doors to several features such as request tracing and more. This example from the Node.js documentation drives it home! Further, if you have had a bit of time to play with Next.js server actions, you will come by the headers() and cookies(), helpers which give access to the current request’s headers and cookies. Under the hood these magic function calls are made possible via AsyncLocalStorage.
Using AsyncLocalStorage in Next.js Routes
So, how can we introduce ALS to Next.js Route Handlers? We can do so in 3 quick steps with the @nextwrappers/async-local-storage library! In the next section, we will also discuss a full breakdown of how the library works to try on your own as well.
Here’s what to do:
Step 1: Install the @nextwrappers/async-local-storage package
npm install @nextwrappers/async-local-storage
Import the package and call it with an initializer to set the data for the async local storage. The initializer provides access to the same request object supplied to the request.
import{asyncLocalStorageWrapper}from"@nextwrappers/async-local-storage"exportconst{wrapper:asyncLocalStorageWrapped,getStore}=asyncLocalStorageWrapper({initialize:(req)=>"12345"// Any data you want})
Wrap the route handler with the wrapper as such, and that’s it!
Now we have AsyncLocalStorage setup for requests made to our API route.
When we call getStore() anywhere in the code that is executed by the route handler, we can access the store we initialized with the wrapper.
Tip: A useful tip is using a non-primitive value in your store, such as an object or map, on which you can store multiple properties. The return type of getStore is strongly typed from the inferred return of initialize.
Breakdown (Implementation)
So how does this work? The implementation of @nextwrappers/async-local-storage is very straightforward. Let’s take a look at some of the helper functions that make this possible.
1. The AsyncLocalStorage runner: AsyncLocalStorage.run
This function takes the store or data to be “attached” to a callback, the callback function to run with the store, and finally, the arguments (args) to be passed to the callback.
What this does is simply run your callback or function on your behalf, but first “attach” the store to the callback so it can be accessed ANYWHERE within the callback: as deep as possible. If A calls B, then B calls C, C will still have access to the store.
I have defined a small type-safe wrapper function for this, though this is not absolutely necessary!
/**
* Runs a callback within the async local storage context
* @param storage The async local storage instance
* @param store The data to store
* @param callback The callback to run within the async local storage context
* @param args The arguments to pass to the callback
* @returns The return value of the callback
*/functionrunWithAsyncLocalStorage<Store=unknown,ReturnType=unknown,Argsextendsany[]=any[]>(storage:AsyncLocalStorage<Store>,store:Store,callback:(...args:Args)=>ReturnType,args:Args){returnstorage.run(store,callback,...args);}
Now that we have this piece, the next piece is to create the actual wrapper around the route handler.
This wrapper will handle initializing the async local store, before executing the route handler itself.
For creating this wrapper, we use a helper from @nextwrappers/core to make defining the wrapper simple. For a breakdown of how that works, you can see the source code or check out this article.
2. The wrapper: asyncLocalStorageWrapper
This takes in some options such as initialize to initialize the store for the AsyncLocalStorage, and an optional storage parameter that allows providing your own AsyncLocalStorage instance.
It returns an object with three things:
the AsyncLocalStorage storage instance,
a convenience getStore function which we can use to get the store and,
the wrapper we can use in our route handlers as we did already in the quick example above.
/**
* Creates an async local storage wrapper for a route handler
* @param options The options include an optional async local `storage`
* instance and an `initialize` function which receives the request
* and returns the `store`
* @returns
*/exportfunctionasyncLocalStorage<Store>(options:AsyncLocalStorageWrapperOptions<Store>){const{initialize,storage=newAsyncLocalStorage<Store>()}=options;return{storage,getStore:()=>storage.getStore(),wrapper:wrapper((next,req,ext)=>{conststore=initialize?.(req,ext);returnrunWithAsyncLocalStorage(storage,store,next,[req,ext]);})};}
In the above the wrapper helper from @nextwrappers/core gives us a middleware-like next() function which is really the route handler itself. Calling next essentially continues the request. Another nice thing about it is that when the wrapper is called, it returns back a plain route handler! This means you can wrap a route handler with multiple wrappers. See more in its documentation.
Performance Discussion
AsyncLocalStorage is not without its performance hits. It has improved significantly with newer versions of Node.js, but there is still a minor performance hit.
Following this issue on GitHub, I copied and adapted the benchmarking script to make fetch requests to a locally running Next.js application in production mode. The application has two GET routes, one running with AsyncLocalStorage, and the other without.
Here are the results from separate runs. The test comprises of firing as many requests as possible over a 10-second period first to a route with ALS and then to a route without ALS.
ALS
No ALS
ALS Penalty (%)
112
113
0.88
117
114
-2.63
111
114
2.63
113
114
0.88
113
114
0.88
In general, the no-ALS route consistently performs slightly better than the ALS route as expected. The penalty as a percentage however is very little, and I wouldn’t sweat it. This is just a basic benchmark and may not be reflective of real-world cases.
The specific version of the script used to perform the tests above can be found here.
Conclusion
This concludes the discussion of setting up request tracing with AsyncLocalStorage in Next.js! Hope you found it useful! If you did, kindly leave a star on GitHub!
Next, create a route handler wrapper function with wrapper, as follows:
App Router
// lib/wrappers/wrapped.tsimport{wrapper}from"@nextwrappers/core";// OR from "@nextwrappers/core/pagesapi" for pages/api directoryimport{NextRequest}from"next/server";exportconstwrapped=wrapper(async(next,request: NextRequest&{isWrapped: boolean})=>{request.isWrapped=true;constresponse=awaitnext();response.headers.set