How to Assign Unique IDs to Express API Requests for Tracing


The ability to track what happens with API requests is an important aspect of monitoring, tracing and debugging back-end applications. But how do you differentiate between the reports of two consecutive API requests to the same API endpoint?
This article aims to show you how to:
Properly assign a unique ID to API requests in your Express applications,
Store and access the ID using the AsyncLocalStorage API in Node.js, and
Use it in request logging.
Experience in creating API endpoints and using middleware in Express will be helpful as you follow along with this guide. You can also apply ideas from this article to frameworks like NestJS and Koa.
Table of Contents
Getting Started with the Starter Repository
To make it easier to follow along, I’ve created a starter project and hosted it on GitHub. You can clone it from here. To get it up and running on your local computer, install its dependencies using your preferred JavaScript package manager (npm, yarn, pnpm, bun). Then start the application by running the npm start
command in the terminal of the project.
If the application starts successfully, it should log the snippet below on the terminal:
Listening on port 3333
The application has only one API endpoint currently – a GET /
. When you make an API request to the endpoint using curl
or a browser by visiting http://localhost:3333, you will get an “OK” string as the payload response:
$ curl -i http://localhost:3333
OK%
If the snippet above is what you see, then congratulations! You have set the project up correctly.
Set Up Logger Utilities
The first step is to set up custom loggers for logging messages to the terminal. The loggers will log the events that occur during the process of handling an API request and log the summary of the request.
To achieve this, you’ll need to install two Express middlewares – morgan and winston – using your preferred package manager. If you use npm
, you can run the command below in the folder terminal of the project:
$ npm install morgan winston
If the above command is successful, morgan and winston will be added to the dependencies
object in package.json
. Create a file named logger.js
in the root folder of the project. logger.js
will contain the code for the custom logger utilities.
The first logger utility you will create is logger
, created from the winston package you installed earlier. logger
is an object with two methods:
info
for logging non-error messages to the terminalerror
for logging error messages to the terminal
// logger.js
const winston = require("winston");
const { combine, errors, json, timestamp, colorize } = winston.format;
const logHandler = winston.createLogger({
level: "debug",
levels: winston.config.npm.levels,
format: combine(
timestamp({ format: "YYYY-MM-DD hh:mm:ss.SSS A" }), // set the format of logged timestamps
errors({ stack: true }),
json({ space: 2 }),
colorize({
all: true,
colors: {
info: "gray", // all info logs should be gray in colour
error: "red", // all error logs should be red in colour
},
})
),
transports: [new winston.transports.Console()],
});
exports.logger = {
info: function (message) {
logHandler.child({}).info(message);
},
error: function (message) {
logHandler.child({}).error(message);
},
};
In the code snippet above, winston.createLogger
is used to create logHandler
. logger
is exported out of the logger.js
module and logger.info
and logger.error
are functions that use logHandler
to log messages to the terminal.
The second logger utility will be a middleware that will log information about the request just before the request response is sent to the client. It will log information such as how long it took to run the request and the status code of the request. It will be called logRequestSummary
and will use the morgan package and the http
method of logHandler
.
// logger.js
const winston = require("winston");
const morgan = require("morgan");
const { combine, errors, json, timestamp, colorize } = winston.format;
const logHandler = winston.createLogger({
// ...
format: combine(
// ...
colorize({
all: true,
colors: {
// ...
http: "blue", // 👈🏾 (new line) logs from logRequestSummary will be blue in colour
},
})
),
// ...
});
exports.logger = {
// ...
};
exports.logRequestSummary = morgan(
function (tokens, req, res) {
return JSON.stringify({
url: tokens.url(req, res),
method: tokens.method(req, res),
status_code: Number(tokens.status(req, res) || "500"),
content_length: tokens.res(req, res, "content-length") + " bytes",
response_time: Number(tokens["response-time"](req, res) || "0") + " ms",
});
},
{
stream: {
// use logHandler with the http severity
write: (message) => {
const httpLog = JSON.parse(message);
logHandler.http(httpLog);
},
},
}
);
The JSON string returned by the first function when the morgan
function is executed is received by the write
function of the stream
object in the second argument passed to the organ function. It’s then parsed to JSON and passed to logHandler.http
to be logged with the winston.npm
http
severity level.
At this point, two objects are exported from logger.js
: logger
and logRequestSummary
.
In index.js
, create a new controller to handle GET
requests to the /hello
path. Also import and use the exported objects from logger.js
. Use logger
to log information when events occur in controllers and include logRequestSummary
as a middleware for the application.
// index.js
const express = require("express");
const { logRequestSummary, logger } = require("./logger");
const app = express();
app.use(
express.json(),
express.urlencoded({ extended: true }),
logRequestSummary // logger middleware utility
);
app.get("/", function (req, res) {
logger.info(`${req.method} request to ${req.url}`); // logger utility for events
return res.json({ method: req.method, url: req.url });
});
app.get("/hello", function (req, res) {
logger.info(`${req.method} request to ${req.url}`); // logger utility for events
return res.json({ method: req.method, url: req.url });
});
// ...
Stop the application (with CTRL
+ C
or OPTION
+ C
), and start it again with npm start
. Make API requests to both API endpoints, you’ll see output similar to the snippet below in the terminal – an event log first and a log of the summary of the request after.
{
"level": "info",
"message": "GET request to /",
"timestamp": "2025-08-16 10:35:06.831 PM"
}
{
"level": "http",
"message": {
"content_length": "26 bytes",
"method": "GET",
"response_time": "9.034 ms",
"status_code": 200,
"url": "/"
},
"timestamp": "2025-08-16 10:35:06.844 PM"
}
You can view the latest state of the code by switching to the 2-custom-logger-middleware
branch using git checkout 2-custom-logger-middleware
or by visiting branch 2-custom-logger-middleware of the repository.
Now that you’re able to log and view events that occur for each API request, how do you differentiate between two consecutive requests to the same endpoint? How do you figure out which API request logged a specific message? How do you specify the API request to trace when communicating with your teammates? By attaching a unique ID to each request, you’ll be able to answer all these questions.
What is AsyncLocalStorage and Why is it Important?
Before AsyncLocalStorage, users of Express stored request context information in the res.locals
object. With AsyncLocalStorage, Node.js provides a native way to store information that’s necessary for executing asynchronous functions. According to its documentation, it’s a performant and memory-safe implementation that involves significant optimizations that would be difficult for you to implement by yourself.
When you use AsyncLocalStorage, you can store and access information in a similar manner to localStorage in the browser. You pass the store value (usually an object, but it can be a primitive value, too) as the first argument and the asynchronous function that should access the store value as the second argument when you execute the run
method.
James Snell, one of the leading contributors of Node.js explains it further in this video Async Context Tracking in Node with Async Local Storage API.
Store the Request ID in AsyncLocalStorage
In the project, create a file with the name context-storage.js
. In this file, you’ll create an instance of AsyncLocalStorage (if it hasn’t been created yet) and export it. This instance of AsyncLocalStorage will be used in storing and retrieving the request IDs for the logger and any other context that needs the request ID.
// context-storage.js
const { AsyncLocalStorage } = require("node:async_hooks");
let store;
module.exports.contextStorage = function () {
if (!store) {
store = new AsyncLocalStorage();
}
return store;
};
You’ll create another file called set-request-id.js
which will create and export a middleware. The middleware will intercept API requests, generate a request ID, and store it in the instance of AsyncLocalStorage from context-storage.js
if it doesn’t exist in it already.
You can use any ID-generating library you want, but here we’ll use randomUUID
from the Node.js crypto
package.
// set-request-id.js
const { randomUUID } = require("node:crypto");
const { contextStorage } = require("./context-storage");
/**
* Preferably your first middleware.
*
* It generates a unique ID and stores it in the AsyncLocalStorage
* instance for the request context.
*/
module.exports.setRequestId = function () {
return function (_req, _res, next) {
requestId = randomUUID();
const store = contextStorage().getStore();
if (!store) {
return contextStorage().run({ requestId }, next);
}
if (store && !store.requestId) {
store.requestId = requestId;
return next();
}
return next();
};
};
In the setRequestId
function in the snippet above, the instance of AsyncLocalStorage created in context-storage.js
is retrieved from the return value of executing contextStorage
as store
. If store
is falsy, the run
method executes the next
Express callback, providing requestId
in an object for access anywhere within next
from contextStorage
.
If store
has a value but doesn’t have the requestId
property, set the requestId
property and its value on it and return the executed next
function.
Lastly, place setRequestId
as the first middleware of the Express application in index.js
so that every request can have an ID before carrying out other operations.
// index.js
const express = require("express");
const { logRequestSummary, logger } = require("./logger");
const { setRequestId } = require("./set-request-id");
const app = express();
app.use(
setRequestId(), // 👈🏾 set as first middleware
express.json(),
express.urlencoded({ extended: true }),
logRequestSummary
);
// ...
You can check the current state of this project if you run the git checkout 3-async-local-storage-req-id
command on your terminal or by visiting 3-async-local-storage-req-id of the GitHub repository.
Use the Request ID in the Logger Utilities
Now that the requestId
property has been set in the store, you can access it from anywhere within next
using contextStorage
. You’ll access it within the functions in logger.js
and attach it to the logs so that when messages are logged to the terminal for a request, the request ID will appear with the logged message.
// logger.js
const winston = require("winston");
const morgan = require("morgan");
const { contextStorage } = require("./context-storage");
const { combine, errors, json, timestamp, colorize } = winston.format;
const logHandler = winston.createLogger({
level: "debug",
levels: winston.config.npm.levels,
format: combine(
// 👇🏽 retrieve requestId from contextStorage and attach it to the logged message
winston.format((info) => {
info.request_id = contextStorage().getStore()?.requestId;
return info;
})(),
// 👆🏽 retrieve requestId from contextStorage and attach it to the logged message
timestamp({ format: "YYYY-MM-DD hh:mm:ss.SSS A" }),
errors({ stack: true }),
// ...
),
transports: [new winston.transports.Console()],
});
// ...
In the combine
function from winston, you’ll include a function argument that accepts the message to be logged – info
– as an argument and attach the request_id
property to it. Its value is the value of requestId
retrieved from contextStorage
. With this modification, any message logged for a request will have the request ID for that request attached to it.
With this complete, stop the project from running if it’s running already and run it again with the npm start
command. Make API requests to the two endpoints and you’ll see output similar to the snippet below on the terminal:
{
"level": "info",
"message": "GET request to /hello",
"request_id": "c80e92d0-eafe-42c7-b093-e5ffce014819",
"timestamp": "2025-08-17 07:58:13.571 PM"
}
{
"level": "http",
"message": {
"content_length": "31 bytes",
"method": "GET",
"response_time": "9.397 ms",
"status_code": 200,
"url": "/hello"
},
"request_id": "c80e92d0-eafe-42c7-b093-e5ffce014819",
"timestamp": "2025-08-17 07:58:13.584 PM"
}
Unlike the previous log output, this one contains the request ID of each request. By using AsyncLocalStorage
to efficiently store the value of the request ID and access it for use in the loggers, you can accurately trace logged messages to their API requests.
You can access the current state of the project if you run the git checkout 4-use-context-in-logger
command on the terminal or by visiting 4-use-context-in-logger of the GitHub repository.
Set Header to have X-Request-Id (Optional Challenge)
You have been able to store, access, and attach a request’s ID to its logged message. Can you set the request ID as a header on the response? The challenge is to set a header, X-Request-Id
, on the response so that every request response has the value of the request ID as the value of the X-Request-Id
response header.
This is useful for communicating with the frontend when trying to debug requests.
Conclusion
When API requests can be monitored, you can track performance metrics to discover areas that need improvement and attention, identify issues like failed requests and server errors and why they happened, and study patterns in request volume metrics for planning and scalability.
When you attach a unique identifier to an API request, you can use it to trace the events that occurred within the lifetime of the request and differentiate it from other requests of the same type.
Aside from using AsyncLocalStorage to store request IDs, you can also use it to store other request context information such as the authenticated user details. Using AsyncLocalStorage to store request context information is considered a best practice.
Subscribe to my newsletter
Read articles from Orim Dominic Adah directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
