DI Benchmark: Vanilla, RegistryComposer, typed-inject, tsyringe, inversify, nest.js
Pure DI in TypeScript Node.js

Search for a command to run...
Pure DI in TypeScript Node.js

In this series, I'm covering the topic of performant framework-less Pure DI in Node.Js. Use cases and examples included.
Introduction In this set of articles, I'm going to share my own experience with implementing and using Typesafe Dependency Injection with Registry in TypeScript Node.js backend applications. So, let's get started with it. P.S. you can read more abou...
In the previous tutorial, I've implemented GraphQL Server using GraphQL Yoga. Today, I'm going to extend our service by using DataLoader. I will use the repo as starting point. DataLoader GraphQL gives us the ability to combine multiple queries int...
In the tutorial preceding this one, I demonstrated how to create a logging service with metadata. In this article, I will be implementing a new GraphQL endpoint for our application. I will use the repo as starting point. Data Store Before we start ...
In the previous tutorial, I've shown how to implement a RequestId feature using AsyncLocalStorage. Here, we will extend the functionality of our app by adding Logger with metadata. I will use the repo as starting point. Logger Why not use console.*...
In the previous article, I wrote about implementing Dependency Injection using the Registry approach. Today, I'm going to focus on the problem of how to deal with scoped-like dependencies within a static application state. The Problem Let's say we wa...
In the previous articles, I've introduced a framework-less and clean way to perform Dependency Injection in TypeScript.
The approach has multiple pros such as type-safety, zero-cost overhead, and others.
However, the one missing piece I had was - benchmarking.
In this article I'm going to compare the performance of the following DI approaches/libs:
Vanilla TypeScript
For the test I'm defining the following application dependency tree:

The implementation of the functions and services does not really matter for our tests, since we only test the dependency resolution performance. So the most important thing here is the depth of the graph.
In the application to resolve findUserReviewedProducts the server needs to resolve all findUserByUsername, findUserReviews, findProductById, and also their dependencies: UserStore, ProductStore, and UserReviewStore, etc.
So, to resolve findUserReviewedProducts - 9 other dependencies need to be resolved.
NOTE: For fare comparison, all dependencies' lifetimes are
Singleton, so once one is resolved, it can be cached for the whole application lifetime.
Typesafe implicit allocation of the dependency graph.
So the code looks like this:
// ...
const productStore = new MysqlProductStore(getMysqlClient);
const userStore = new RedisUserStore(getRedisClient);
const userReviewStore = new DynamodbUserReviewStore(getDynamodbClient);
const { findProductById } = new ImplFindProductById(productStore);
const { findUserByUsername } = new ImplFindUserByUsername(userStore);
const { findUserReviews } = new ImplFindUserReviews(userReviewStore);
const { findUserReviewedProducts } = new ImplFindUserReviewedProducts(
findUserByUsername,
findUserReviews,
findProductById
);
return Object.freeze({
getMysqlClient,
getRedisClient,
getDynamodbClient,
findProductById,
productStore,
userStore,
userReviewStore,
findUserByUsername,
findUserReviews,
findUserReviewedProducts,
});
NOTE: I'm using
Vanilla TypeScriptas a baseline for all the benchmarking since it's the most performant solution.
Typesafe dependency injection approach, that does not require to use of any libraries. Very similar to vanilla TS, but the main benefit - it is helping to organize the registration complexity.
new RegistryComposer()
.add(implGetMysqlClient())
.add(implGetRedisClient())
.add(implGetDynamodbClient())
.add(mysqlProductStore())
.add(redisUserStore())
.add(dynamodbUserReviewStore())
.add(implFindProductById())
.add(implFindUserByUsername())
.add(implFindUserReviews())
.add(implFindUserReviewedProducts())
.compose();
Typesafe dependency injection framework for TypeScript. Conceptually it's very similar to Registry Composer except, it requires adding specific public static inject field to every class. Registration of functions is also not very pretty.
createInjector()
// ...
.provideClass("ProductStore", DecoratedMysqlProductStore)
.provideClass("UserReviewStore", DecoratedDynamodbUserReviewStore)
.provideClass("UserStore", DecoratedRedisUserStore)
.provideClass("DecoratedImplFindProductById", DecoratedImplFindProductById)
.provideFactory(
"FindProductById",
provideFn(["DecoratedImplFindProductById"], "findProductById")
)
.provideClass(
"DecoratedImplFindUserByUsername",
DecoratedImplFindUserByUsername
);
// ...
Lightweight dependency injection container for TypeScript. The container is decorator-driven and not typesafe.
// ...
container.register("ProductStore", {
useClass: DecoratedMysqlProductStore,
});
container.register("UserReviewStore", {
useClass: DecoratedDynamodbUserReviewStore,
});
container.register("UserStore", {
useClass: DecoratedRedisUserStore,
});
// ...
Another popular decorator-driven and not typesafe dependency injection container for TypeScript.
const container = new Container({
defaultScope: "Singleton",
});
// ...
container.bind("ProductStore").to(DecoratedMysqlProductStore);
container.bind("UserReviewStore").to(DecoratedDynamodbUserReviewStore);
container.bind("UserStore").to(DecoratedRedisUserStore);
// ...
Unlike others, nest.js is not just a DI library, it's a complete framework for building backend services. The DI system is very similar to Angular Modules.
@Module({
providers: [
DecoratedImplFindProductById,
DecoratedImplFindUserByUsername,
DecoratedImplFindUserReviewedProducts,
DecoratedImplFindUserReviews,
// ...
{
provide: "FindUserReviews",
useFactory: ({ findUserReviews }: DecoratedImplFindUserReviews) => {
return findUserReviews;
},
inject: [DecoratedImplFindUserReviews],
},
{
provide: "ProductStore",
useClass: DecoratedMysqlProductStore,
},
{
provide: "UserReviewStore",
useClass: DecoratedDynamodbUserReviewStore,
},
{
provide: "UserStore",
useClass: DecoratedRedisUserStore,
},
// ...
],
})
export class RootModule {}
NOTE: nest.js is a little out of scope since it doesn't expose it's DI container explicitly. But I've decided to test it as well since it's a very popular solution.
For the benchmarking, I'm using the benchmark.js library with the default config.
There are 3 different benchmarks I'm performing:
The Cold Start suite covers the case when you just start an application. To do so, we need to skip caching when we start each test. It's achieved by using a helper function requireUncached.
function requireUncached(module: string) {
delete require.cache[require.resolve(module)];
return require(module);
}
For example for inversify for every test, I do: Similarly, for nestjs:
requireUncached("reflect-metadata");
requireUncached("@nestjs/core");
requireUncached("@nestjs/common");
This suite verifies the performance of service resolution only. Meaning, the cold start happens on the test startup and does not affect the metrics.
For example, the code for the tsyringe looks like this:
container.resolve("FindUserReviewedProducts");
The last suite is a combination of both: the cold start and resolution.
requireUncached("reflect-metadata");
requireUncached("inversify");
...
createContainer().get("FindUserReviewedProducts");
Each scenario contains the following test cases:
vanilla - a test case for a state object created by implicit allocation
registry-composer - a test case for a state object created by using RegistryComposer
typed-inject - a test case for a DI container created by using typed-inject library
tsyringe - a test case for a DI container created by using tsyringe library
inversify - a test case for a DI container created by using inversify library
nest - a test case for a DI container created by using nest.js library
tsyringe#frozen - a test case for a state object created from a DI container created by using tsyringe library
inversify#frozen - a test case for a state object created from a DI container created by using inversify library

What we can see from the chart: the transpile-time solutions are 3x faster than the runtime containers.
NOTE: even transpile-time solutions are 3x times faster, it does not mean your app will start 3x times faster, since we only compare DI framework timing, excluding any extra dependency your app might have.

Since benchmarking all transpile-time and frozen solutions is accessing a field by name, it's very fast. We can see that all the solutions can perform ~950M op/sec. While runtime container solutions perform in the range of 1M-6M op/sec, which is 150x times slower.

Similarly to just the Cold Start test, the transpile-time solutions are 3x faster than the runtime containers.
The results I got are pretty obvious. The runtime container adds overhead, compared to transpile-time solutions. It's especially clear for Resolution tests since the runtime is 150x times different. The runtime containers are less performant and not typesafe, but provide an easier way to manage services lifetimes. While the transpile-time solutions are typesafe, very fast, and make the application code framework agnostic, but require more care at the code design stage (especially for the scope lifetime dependencies). At the end of the day, it's up to you what solution to use, since each has its pros and cons.
A full example source code can be found here