Node.js web frameworks — where do we even begin? With so many options out there, choosing the right one for your project can feel overwhelming.
In this post, I’ll walk you through the hottest frameworks in the Node.js ecosystem, breaking down the strengths, weaknesses, and best use cases for each one.
Whether you’re looking for speed, scalability, or simplicity, hopefully we will cover it all—so by the end, you’ll know exactly which framework is right for you.
The frameworks we will be looking at are: Nest, Elysia, Encore.ts and Hono.
Video version:
Feature overview
On a scale from most lightweight to most feature rich I would position the frameworks like this:
This does not mean that lightweight is bad, it just depends on what the needs are for your project. The lightweight nature of Hono is actually one of the selling points, with just under 14KB its perfect for deploying to Cloudflare Workers.
Encore.ts on the other hand comes with a lot of built in features, like automatic tracing out of the box and local infrastructure.
Let’s take a look at each framework, and we are going to start with Encore.ts
Encore.ts
An Open Source framework that aims to make it easier to build robust and type-safe backends with TypeScript. The framework has a lot of build in tools to make your development experience smoother and performance wise, it’s the fasters of all frameworks in this comparison.
Encore has built-in request validation. The request and response types you define in regular TypeScript are used to validate the request, both during compile and runtime. And unlike the other frameworks the actual validation is done in Rust and not in JavaScript. This makes the validation really fast, but more on that later.
import {api, Header, Query} from "encore.dev/api";
enum EnumType {
FOO = "foo",
BAR = "bar",
}
// Encore.ts automatically validates the request schema
// and returns and error if the request does not match.
interface RequestSchema {
foo: Header<"x-foo">;
name?: Query<string>;
someKey?: string;
someOtherKey?: number;
requiredKey: number[];
nullableKey?: number | null;
multipleTypesKey?: boolean | number;
enumKey?: EnumType;
}
// Validate a request
export const schema = api(
{expose: true, method: "POST", path: "/validate"},
(data: RequestSchema): { message: string } => {
console.log(data);
return {message: "Validation succeeded"};
},
);
Encore makes it easy to create and call services. From a code perspective, a service just looks like another folder in your repo. When calling an endpoint in a service it’s just like calling a regular function. But the cool part is that under the hood, those function calls gets converted to actual HTTP calls. When deploying, you can even choose to deploy your services to separate instances, for example in a Kubernetes cluster, while still having all service code in the same repo.
Import a service and call its API endpoints like regular functions
import { api } from "encore.dev/api";
import { hello } from "~encore/clients"; // import 'hello' service
export const myOtherAPI = api({}, async (): Promise<void> => {
// all the ping endpoint using a function call
const resp = await hello.ping({ name: "World" });
console.log(resp.message); // "Hello World!"
});
With Encore.ts you integrate your infrastructure as type-safe objects in application code. Creating a database or a Pub/Sub topic just requires a few lines of application code. Encore.ts builds your application as a Docker image and you just supply the runtime configuration when deploying, that’s it.
Create a PostgreSQL database in one line of code
import { SQLDatabase } from "encore.dev/storage/sqldb";
const db = new SQLDatabase("userdb", {migrations: "./migrations"});
// ... use db.query to query the database.
Create Pub/Sub topics and subscriptions
import { Topic } from "encore.dev/pubsub";
export interface User { /* fields ... */ }
const signups = new Topic<User>("signup", {
deliveryGuarantee: "at-least-once",
});
await signups.publish({ ... });
Encore also comes with a built-in development dashboard. When you start your Encore app, the development dashboard is available on port localhost:9400. From here you can call your endpoints, a bit like Postman. Each call to your application results in a trace that you can inspect to see the API requests, database calls, and Pub/Sub messages. The local development dashboard also includes automatic API documentation and an always up-to-date architectural diagram of you system.
Local Development Dashboard
And its worth mentioning that even though Encore comes with a lot of features, it has 0 npm dependencies.
Hono
Hono is the creation of Yusuke Wada. He started the project 2021 because there were no good Node.js frameworks that worked well on Cloudflare Workers. Since then Hono as added support for a lot of other runtimes like Node.js, Bun and Deno.
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.text('Hono!'))
export default app
Hono is really small, the hono/tiny
preset is under 13kB which makes it really suitable for deploying to Cloudflare Workers. Hono also has 0 NPM dependencies which is really impressive!
The multi-runtime selling point is interesting, the idea that no matter the Javascript runtime you will be able to run Hono. In their repo they have this concept of adapters where you can see the adjustments they make for each runtime. I think this has been huge for adoption and growing the user base but realistically for an individual user you will probably not switch runtimes once you have your app deployed to the cloud.
And even though Hono is lightweight out of the box it has a bunch of middleware, both 1st and 3rd-party that you can install to enhance your app. It’s the “add it when you want to use it”-approach that got popular with Express. This can work great as long as you app is small but with a larger app, maintaining a huge list of dependencies can be frustrating.
Elysia
Elysia, like Encore, is built for TypeScript and also offers type-safety inside your API handlers. Knowing what you are working with inside your API handlers saves you a lot of time and it’s great to not having to litter your code with type checks.
You specify the request types with the t
module, which is an extension of the TypeBox validation library. Unlike Encore, the validation happens in the JavaScript layer which adds some performance overhead.
import { Elysia, t } from 'elysia'
new Elysia()
.patch("/profile", ({ body }) => body.profile, {
body: t.Object({
id: t.Number(),
profile: t.File({ type: "image" }),
}),
})
.listen(3000);
Adding Swagger documentation only requires one line of code and Elysia has 1st party support for OpenTelemetry. Which is really nice, so you can easily monitor your app regardless of the platform.
Elysia is fast! But not as fast as Encore as you will see in the next section.
Nest.js
Nest.js is a bit different then the other frameworks in this comparison. Encore, Elysia and Hono tries to offer minimalistic APIs for creating endpoints and middleware and you are free to structure your business logic however you wish. Nest.js is much more opinionated and forces you to structure your code in a certain way. It offers a modular architecture that organizes code into different abstractions, like Providers, Controllers, Modules and Middleware.
Nest is designed to make it easier to maintain and develop larger applications. But whether your a fan of the opinionated structure Nest offers is at the end of the day very subjective. I would say that it might be advantageous for large-scale projects where long-term maintainability are more important than speed and simplicity. For smaller project with just a few developers the added level of abstractions will probably be overkill for most use cases. The opinionated nature of Nest also brings with it a steeper learning curve compared to the Hono, Encore and Elysia.
When using Nest you can choose to either use Express or Fastify as the underlying HTTP server framework. All the Nest functionality is added on top of that.
Performance
Speed is perhaps not the most important thing when choosing a framework, but it’s not something you can ignore. It will impact your apps responsiveness AND ultimately your hosting bill.
We have benchmarked both without and with request schema validation, the measurement is requests per second. The names in parenthesis are the request validation libraries used. Encore.ts has request validation built in.
Encore.ts is the fastest for all frameworks in our benchmark followed by Elysia, Hono, Fastify and Express. Nest uses Fastify or Express under the hood so you can expect the performance to for a Nest app to be equivalent to that, maybe a but slower as Nest adds some overhead.
How can Encore.ts be so much faster? The secret is that Encore.ts has a Rust runtime. And Rust is fast!
Encore.ts actually consists of two parts:
A user facing TypeScript part for defining APIs and infrastructure.
And under the hood, it has a multi-threaded runtime written in Rust.
The key to the performance boost is to off-load as much work as possible from the single-threaded Node.js event-loop to the Rust runtime.
For example, the Rust runtime handles all input/output like accepting incoming HTTP requests or reading from the database. Once the request or database query has been fully processed, then it gets handed over to the Node.js event-loop.
Code structure
Hono, Elysia and Encore are unopinionated in how you structure your code. And the way you create APIs and middleware are fairly similar between the frameworks.
Here is a GET endpoint for each framework. There are of course some differences, but if we squint the APIs look fairly similar. At least similar enough for this not to be the deciding factor in my opinion:
Encore.ts
interface Response {
message: string;
}
export const get = api(
{ expose: true, method: "GET", path: "/hello/:name" },
async ({ name }: { name: string }): Promise<Response> => {
const msg = `Hello ${name}!`;
return { message: msg };
},
);
Elysia
import { Elysia, t } from "elysia";
new Elysia()
.get(
"/hello/:name",
({ params }) => {
const msg = `Hello ${params.name}!`;
return { message: msg };
},
{
response: t.Object({
message: t.String(),
}),
},
)
Hono
import { Hono } from "hono";
const app = new Hono();
app.get("/hello/:name", async (c) => {
const msg = `Hello ${c.req.param("name")}!`;
return c.json({ message: msg });
});
What really makes a difference is the being able to relay on type-safety when building a robust application. Encore and Elysia offer type-safe APIs but Encore also offers compile time type-safety when working with infrastructure like Pub/Sub. With Encore, you also get compile time type-safety when calling an endpoint in another service. And if you've ever worked with a microservice architecture before you know how big of a deal that is.
Nest.js is the one that really sticks out in terms of API design. There are a lot of concepts and abstractions that goes into a Nest app. This can be both a good and a bad thing, it really depends of your preference. One thing that will be immediately apparent when looking at a Nest app is the use of decorators. Nest relies heavily on decorators, for example when using dependency injection to inject a Services into Controllers.
Nest controller
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}
Personally, I am not a big fan and I know that I am not the only one out there.
Deployment & Infrastructure
All of these frameworks are similar in that they are deployable to to all mainstream cloud platforms (like Digital Ocean and Fly.io) or straight to a cloud provider like AWS or GCP.
Encore offers automatic local infrastructure. encore run
starts your app and spinns up all local infrastructure, like databases and Pub/Sub topic. Forget YAML, Docker Compose, and the usual headaches.
When building your Encore app you get a runtime config where you can supply the configuration needed for connecting to the infrastructure in your cloud.
If you want to get your Encore app into the cloud quickly and don’t feel like self-serving then you can use Encore Cloud. Encore Cloud offers CI/CD and preview environments for pull requests. And if you want to, Encore Cloud can provision all the needed infrastructure in your own cloud on AWS or GCP. This means your app is not dependent on any third-party services and you have full control over all your infrastructure.
Hono sticks out in that it supports a bunch of different runtimes, you therefore have a lot of options when it comes to deployment. Deploying to Cloudflare Workers, Netlify or AWS Lambda is easy and does not require a lot of configuration.
With Nest you run the nest build
command that compiles your TypeScript code into JavaScript. This process generates a dist
directory containing the compiled files. Thats pretty much it, you can then use Node.js to run your dist
folder.
The recommended way to deploy an Elysia application is to compile your app into a binary using the bun build
command. Once binary is compiled, you don't need Bun
installed on the machine to run the server.
Wrapping up
That’s it! Hopefully you know what framework to reach for in your next project.
If you want to learn more about Encore.ts, you can check out the Open Source project on GitHub: https://github.com/encoredev/encore
Recommendations:
Top comments (0)