DEV Community

Rexford Essilfie
Rexford Essilfie

Posted on

Using AsyncLocalStorage in Next.js 🧰 ✚ ⚛️

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:

  1. Step 1: Install the @nextwrappers/async-local-storage package

    npm install @nextwrappers/async-local-storage
    
  2. 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"
    
    export const {
        wrapper: asyncLocalStorageWrapped,
        getStore
    } = asyncLocalStorageWrapper({
        initialize: (req) => "12345" // Any data you want
    })
    
    
  3. Wrap the route handler with the wrapper as such, and that’s it!

    // app/api/.../route.ts
    import { asyncLocalStorageWrapped, getStore } from "lib/wrappers"
    
    export const GET = asyncLocalStorageWrapped((request: NextRequest) => {
        const asyncLocalStorageValue = getStore()
    
        console.log(asyncLocalStorageValue) // => "12345"
    
        return (...)
    })
    
    

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
 */
function runWithAsyncLocalStorage<
  Store = unknown,
  ReturnType = unknown,
  Args extends any[] = any[]
>(
  storage: AsyncLocalStorage<Store>,
  store: Store,
  callback: (...args: Args) => ReturnType,
  args: Args
) {
  return storage.run(store, callback, ...args);
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. the AsyncLocalStorage storage instance,
  2. a convenience getStore function which we can use to get the store and,
  3. 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
 */
export function asyncLocalStorage<Store>(
  options: AsyncLocalStorageWrapperOptions<Store>
) {
  const { initialize, storage = new AsyncLocalStorage<Store>() } = options;
  return {
    storage,
    getStore: () => storage.getStore(),
    wrapper: wrapper((next, req, ext) => {
      const store = initialize?.(req, ext);
      return runWithAsyncLocalStorage(storage, store, next, [req, ext]);
    })
  };
}
Enter fullscreen mode Exit fullscreen mode

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!

GitHub logo rexfordessilfie / nextwrappers

Reusable, composable middleware-like wrappers for Next.js App Router Route Handlers and Middleware.

Next.js Route Handler Wrappers

Reusable, composable middleware-like wrappers for Next.js API route and middleware.ts handlers.

Get Started 🚀

  1. First install the core library using your favorite package manager:

    npm install @nextwrappers/core # npm
    yarn add @nextwrappers/core # yarn
    pnpm add @nextwrappers/core # pnpm
    Enter fullscreen mode Exit fullscreen mode

    OR Install and use pre-made (utility) wrappers in the packages directory:

    npm install @nextwrappers/async-local-storage @nextwrappers/matching-paths # npm
    yarn add @nextwrappers/async-local-storage @nextwrappers/matching-paths # yarn
    pnpm add @nextwrappers/async-local-storage @nextwrappers/matching-paths # pnpm
    Enter fullscreen mode Exit fullscreen mode
  2. Next, create a route handler wrapper function with wrapper, as follows:

    App Router
    // lib/wrappers/wrapped.ts
    import { wrapper } from "@nextwrappers/core"; // OR from "@nextwrappers/core/pagesapi" for pages/api directory
    import { NextRequest } from "next/server";
    export const wrapped = wrapper(
      async (next, request: NextRequest & { isWrapped: boolean }) => {
        request.isWrapped = true;
        const response = await next();
        response.headers.set
    Enter fullscreen mode Exit fullscreen mode

Top comments (0)