A few months ago we released Encore.ts — an Open Source backend framework for TypeScript.
Since there are already a lot of frameworks out there, we wanted to share some of the uncommon design decisions we've made and how they lead to remarkable performance numbers.
Performance benchmarks
We've previously published benchmarks showing how Encore.ts is 9x faster than Express and 2x faster than Fastify.
This time we've benchmarked Encore.ts against Elysia and Hono, two modern high-performance TypeScript frameworks.
We benchmarked each framework both with and without schema validation, using TypeBox for validation with ElsyiaJS and Hono as it is a natively supported validation library for these frameworks. (Encore.ts has its own built-in type validation that works end-to-end.)
For each benchmark we took the best result of five runs. Each run was performed by making as many requests as possible with 150 concurrent workers, over 10s. The load generation was performed with oha, a Rust and Tokio-based HTTP load testing tool.
Enough talk, let's see the numbers!
Requests per second: Encore.ts is 3x faster than Elysia & Hono when using type validation
(Check out the benchmark code on GitHub.)
Aside from performance, Encore.ts achieves this while maintaining 100% compatibility with Node.js.
How it works: Outlier design decisions
How is this possible? From our testing we've identified three major sources of performance, all related to how Encore.ts works under the hood.
1. Encore.ts is multi-threaded and powered by a Rust runtime
Node.js runs JavaScript code using a single-threaded event loop. Despite its single-threaded nature this is quite scalable in practice, since it uses non-blocking I/O operations and the underlying V8 JavaScript engine (that also powers Chrome) is extremely optimized.
But you know what's faster than a single-threaded event loop? A multi-threaded one.
Encore.ts consists of two parts:
A TypeScript SDK that you use when writing backends using Encore.ts.
A high-performance runtime, with a multi-threaded, asynchronous event loop written in Rust (using Tokio and Hyper).
The Encore Runtime handles all I/O like accepting and processing incoming HTTP requests. This runs as a completely independent event loop that utilizes as many threads as the underlying hardware supports.
Once the request has been fully processed and decoded, it gets handed over to the Node.js event-loop, and then takes the response from the API handler and writes it back to the client.
(Before you say it: Yes, we put an event loop in your event loop, so you can event-loop while you event-loop.)
2. Encore.ts pre-computes request schemas
Encore.ts, as the name suggests, is designed from the ground up for TypeScript. But you can't actually run TypeScript: it first has to be compiled to JavaScript, by stripping all the type information. This means run-time type safety is much harder to achieve, which makes it difficult to do things like validating incoming requests, leading to solutions like Zod becoming popular for defining API schemas at runtime instead.
Encore.ts works differently. With Encore, you define type-safe APIs using native TypeScript types:
import { api } from "encore.dev/api";
interface BlogPost {
id: number;
title: string;
body: string;
likes: number;
}
export const getBlogPost = api(
{ method: "GET", path: "/blog/:id", expose: true },
async ({ id }: { id: number }) => Promise<BlogPost> {
// ...
},
);
Encore.ts then parses the source code to understand the request and response schema that each API endpoint expects, including things like HTTP headers, query parameters, and so on. The schemas are then processed, optimized, and stored as a Protobuf file.
When the Encore Runtime starts up, it reads this Protobuf file and pre-computes a request decoder and response encoder, optimized for each API endpoint, using the exact type definition each API endpoint expects. In fact, Encore.ts even handles request validation directly in Rust, ensuring invalid requests never have to even touch the JS layer, mitigating many denial of service attacks.
Encore’s understanding of the request schema also proves beneficial from a performance perspective. JavaScript runtimes like Deno and Bun use a similar architecture to that of Encore's Rust-based runtime (in fact, Deno also uses Rust+Tokio+Hyper), but lack Encore’s understanding of the request schema. As a result, they need to hand over the un-processed HTTP requests to the single-threaded JavaScript engine for execution.
Encore.ts, on the other hand, handles much more of the request processing inside Rust, and only hands over the decoded request objects. By handling much more of the request life-cycle in multi-threaded Rust, the JavaScript event-loop is freed up to focus on executing application business logic instead of parsing HTTP requests, yielding an even greater performance boost.
3. Encore.ts integrates with infrastructure
Careful readers might have noticed a trend: the key to performance is to off-load as much work from the single-threaded JavaScript event-loop as possible.
We've already looked at how Encore.ts off-loads most of the request/response lifecycle to Rust. So what more is there to do?
Well, backend applications are like sandwiches. You have the crusty top-layer, where you handle incoming requests. In the center you have your delicious toppings (that is, your business logic, of course). At the bottom you have your crusty data access layer, where you query databases, call other API endpoints, and so on.
We can't do much about the business logic — we want to write that in TypeScript, after all! — but there's not much point in having all the data access operations hogging our JS event-loop. If we moved those to Rust we'd further free up the event loop to be able to focus on executing our application code.
So that's what we did.
With Encore.ts, you can declare infrastructure resources directly in your source code.
For example, to define a Pub/Sub topic:
import { Topic } from "encore.dev/pubsub";
interface UserSignupEvent {
userID: string;
email: string;
}
export const UserSignups = new Topic<UserSignupEvent>("user-signups", {
deliveryGuarantee: "at-least-once",
});
// To publish:
await UserSignups.publish({ userID: "123", email: "hello@example.com" });
"So which Pub/Sub technology does it use?"
— All of them!
The Encore Rust runtime includes implementations for most common Pub/Sub technologies, including AWS SQS+SNS, GCP Pub/Sub, and NSQ, with more planned (Kafka, NATS, Azure Service Bus, etc.). You can specify the implementation on a per-resource basis in the runtime configuration when the application boots up, or let Encore's Cloud DevOps automation handle it for you.
Beyond Pub/Sub, Encore.ts includes infrastructure integrations for PostgreSQL databases, Secrets, Cron Jobs, and more.
All of these infrastructure integrations are implemented in the Encore.ts Rust Runtime.
This means that as soon as you call .publish()
, the payload is handed over to Rust which takes care to publish the message, retrying if necessary, and so on. Same thing goes with database queries, subscribing to Pub/Sub messages, and more.
The end result is that with Encore.ts, virtually all non-business-logic is off-loaded from the JS event loop.
In essence, with Encore.ts you get a truly multi-threaded backend "for free", while still being able to write all your business logic in TypeScript.
Conclusion
Whether or not this performance is important depends on your use case. If you're building a tiny hobby project, it's largely academic. But if you're shipping a production backend to the cloud, it can have a pretty large impact.
Lower latency has a direct impact on user experience. To state the obvious: A faster backend means a snappier frontend, which means happier users.
Higher throughput means you can serve the same number of users with fewer servers, which directly corresponds to lower cloud bills. Or, conversely, you can serve more users with the same number of servers, ensuring you can scale further without encountering performance bottlenecks.
While we're biased, we think Encore offers a pretty excellent, best-of-all-worlds solution for building high-performance backends in TypeScript. It's fast, it's type-safe, and it's compatible with the entire Node.js ecosystem.
And it's all Open Source, so you can check out the code and contribute on GitHub.
Or just give it a try and let us know what you think!
Top comments (7)
Wondering where the performance race is going to end 😶🌫️
It would be great to have some consolidation amongst all these server libraries.
Aside from performance, what do you think are the most important features/characteristics for a framework?
To me, the 3 most important things:
All the rest (TLS/SSL, HTTP2/3, data parsing and validation, data serialization, special headers, ...) should either be handled by the front server/load balancer (like nginx, traeffic, to name a few), or by existing libraries that are already doing a pretty amazing job at it (Ajv my love).
Sounds like you would like a lot about Encore.ts then. If you haven't check out the introduction on encore.dev/ to see how Encore helps with standardization, security, observability, and developer experience. :)
Also, I don't consider Elysia, Express, Fastify, Hono nor Encore to be real frameworks. They're simply HTTP server libs. NextJS, Perseid, Django, are actual frameworks: they enforce a global structure and way to develop applications or websites , with much more constraints that these server libs. But that's just a matter of terminology ;)
Hi @marcuskohlberg .
An issue I've found with the tutorial or educational content on getting started with Encore is the lack of structure, I don't know if this is a fault of the content creator or encore itself.
These example can't be used in a production application.
I'm not seeing a way to have a feature/modular application.
Where do I create my feature folders?
How do I create my controllers?
How do I create my service files for my controllers ?
How and where do I create my DTOs ? e.t.c
What's the Dev experience to start?
This is downside of Express.js , the initial configuration, installing of necessary packages and setup is just too much.
This is why Nest.js is my favourite Backend framework (although not so performant), with just CLI commands I already have a functioning API. It has less overhead to actually doing your work.
If Encore can have something like that, I'll actually seriously consider it.
Thank you and well done to the team on this innovative framework.
Hey @nathbabs, feel free to join encore.dev/discord for assistance.
We'll try to clarify these points in the tutorials, but it's pretty well documented how you created multiple services / sub-systems etc. in the normal docs: encore.dev/docs/ts/develop/app-str...
Encore isn't really opinionated about if you use feature folders or not, that very much depends on your context and needs, so we don't think the framework needs to be prescriptive.
For ease of getting started, you can have a rest API running in the cloud in 2 minutes by just doing
encore app create --example=ts/hello-world && git push encore
. Not sure it gets easier than that.