Skip to content
ElysiaJS
← Blog

Release

Elysia 1.3 and Scientific Witchery

saltyaom

@saltyaom

pink-violet tint mesh gradient background with the words 'Elysia 1.3' and the words 'Scientific Witchery' below the title

Named after the song Ga1ahad and Scientific Witchery by Mili.

This release doesn’t come with shiny new features.

It’s about refinement to make things better to the point that we consider it as “magic”.

Elysia 1.3 brings near 0 overhead, refinement, technical debt fixes, and internal code refactoring, featuring:

We introduced normalize in Elysia 1.1 to ensure that data matches our desired shape, and it works nicely.

It helps reduce potential data leaks and unexpected properties, and our users love it. However, it comes with a performance cost.

Under the hood, it uses TypeBox's Value.Clean to coerce data into specified schema dynamically.

It works great but not as fast as we want it to be.

TypeBox doesn’t offer a compiled version of Value.Clean, unlike TypeCompiler.Check, which takes advantage of knowing the shape ahead-of-time.

That’s why we introduced a replacement with Exact Mirror.

Exact Mirror is a drop-in replacement for TypeBox’s Value.Clean with significant performance improvements by leveraging ahead-of-time compilation.

For small objects without arrays, we measured up to ~500x faster for the same object. Exact Mirror run on small data resulting in 582.52x faster than TypeBox Value.Clean

Exact Mirror run on small data

And for medium and large-size objects, we measured up to ~30x faster. Exact Mirror run on medium and large data resulting in 29.46x and 31.6x in order

Exact Mirror run on medium and large data

Starting from Elysia 1.3, Exact Mirror is a default strategy for normalization replacing TypeBox.

By upgrading to Elysia 1.3, you can expect a significant performance improvement without any code changes.

Here’s the throughput on Elysia 1.2. Elysia with normalization turned off resulting in 49k req/sec

Elysia with normalization turned off

And here’s the same code on Elysia 1.3. Elysia with normalization turned on resulting in 77k req/sec

Elysia with normalization turned on

We measured up to ~1.5x throughput when using a single schema with normalization.

This means if you use more than a single schema, you should see even more performance improvement.

When comparing to the same code without schema, we see < 2% performance differences.

Elysia runs with no validation results in 79k req/sec

Elysia runs with no validation

This is huge.

Previously, you had to choose between safety and performance as we close the performance gap between using and not using validation. But now you don’t have to worry about it.

But now, we drop validation overhead from a significant amount to almost near zero without requiring any changes on your side.

It just works, like magic.

However, if you would like to use TypeBox or disable normalization entirely, you can set it with the constructor like any other configuration:

import { Elysia } from 'elysia'
new Elysia({
normalize: 'typebox' // Using TypeBox
})

You can try the benchmark out yourself by visiting Exact Mirror on GitHub.

We have never had performance problems with the router in Elysia.

It has excellent performance, and we have hyper-optimized it as much as we possibly can.

We pushed it to the near limit of what JavaScript can offer in a practical sense.

However, Bun 1.2.3 offers a built-in solution to routing (possibly) in native code.

Although for static routes we didn’t see much performance improvement, we found that dynamic routes perform 2-5% faster without any code changes.

Starting from Elysia 1.3, we offer a dual router strategy by using both Bun’s native router and Elysia’s router.

Elysia will try to use a Bun router if possible and fall back to Elysia’s router.

To make this possible, we have to rewrite our internal compilation code to support a custom router from adapter.

This means that it’s now possible to use a custom router alongside Elysia’s own router.

This opens up an opportunity for performance improvement in some environments, for example: using built-in uWebSocket.js router which has a native implementation for routing.

In Elysia, we can define a schema and apply it to multiple routes with guard.

We can then override a public schema by providing a schema in a route handler which sometimes looks like this:

Elysia running with default override guard showing a schema getting overridden

Elysia running with default override guard

But sometimes we don’t want to override a schema.

Instead we want both to work, allowing us to combine schemas instead of overriding them.

Starting from Elysia 1.3, we can do just that.

We can now tell Elysia not to override it and instead treat it as its own by providing a schema as standalone.

import { Elysia } from 'elysia'
new Elysia()
.guard({
schema: 'standalone',
response: t.Object({
title: t.String()
})
})

As a result, we have results that are like merging a local and global schema together.

Elysia running with standalone merging multiple guards together

Elysia running with standalone merging multiple guards together

Elysia’s type inference is already extremely fast.

We are really confident in our optimization of type inference and it’s faster than most frameworks that use an express-like syntax.

However, some of our users have really really large scale applications with multiple routes and complex type inference.

We managed to reduce type instantiation by half in most cases, and measured up to 60% improvement in inference speed.

type instantiation reduced from 109k to 52k

type instantiation reduced from 109k to 52k

We also changed the default behavior of decorate to use intersections instead of looping through every object and property recursively.

This should solve the problem with users who use heavy objects/classes, for example, PrismaClient.

As a result, we should end up with faster IDE auto-completion, suggestion, type checking and Eden Treaty.

We have refactored and optimized a lot of internal code which accumulates up to significant improvements.

We have refactored how we store route information and reuse an object reference instead of cloning/creating a new one.

We saw the following improvements:

  • Up to ~5.6x reduced memory usage
  • Up to ~2.7x faster route registration time

Route registration comparison between Elysia 1.2 (left), and 1.3 (right)

Route registration comparison between Elysia 1.2 (left), and 1.3 (right)

These optimizations should show real results for medium to large scale apps as they scale with how many routes the server has.

We have implemented Sucrose cache to reduce unnecessary re-computation and reused compiled routes when compiling each route for non-inline events.

Sucrose performance comparison between Elysia 1.2 (left), and 1.3 (right)

Sucrose performance comparison between Elysia 1.2 (left), and 1.3 (right)

Sucrose converts each event into a checksum number and stores it as a cache. It uses little memory and will be cleaned up once the server has started.

This improvement should help with the startup time of each route that reuses global/scoped events.

We saw a significant improvement when creating multiple instances and applying them as plugins.

  • Up to ~10x reduced memory usage
  • Up to ~3x faster plugin creation

Elysia instance comparison between Elysia 1.2 (left), and 1.3 (right)

Elysia instance comparison between Elysia 1.2 (left), and 1.3 (right)

These optimizations will be applied automatically by upgrading to Elysia 1.3. However, these performance optimizations might not be significantly noticeable for small apps.

Serving a simple Bun server has a fixed cost of around 10-15MB. These optimizations focus more on reducing existing overhead and help improve startup time.

Through various micro-optimizations, fixing technical debt, and eliminating unused compiled instructions, we saw some general improvements in Elysia request processing speed. In some cases up to 40%.

Elysia.handle comparison between Elysia 1.2 and 1.3

Elysia.handle comparison between Elysia 1.2 and 1.3

We want Elysia validation to just work.

You can just tell it what you want, then you get it. It’s one of the most valuable aspects of Elysia.

In this update, we have improved some areas that we have been lacking.

We have moved encodeSchema out of experimental, and enabled it by default.

This allows us to use t.Transform to apply custom response mapping to return to the end user.

Using t.Transform to intercept a value into a new one

Using t.Transform to intercept a value into a new one

This example code will intercept a response, replacing “hi” with “intercepted” instead.

To prevent SQL injection and XSS, and to ensure string input/output is safe, we introduced the sanitize option.

It accepts a function or an array of functions that intercepts every t.String, and transforms it into a new value.

Using sanitize with Bun.escapeHTML

Using sanitize with Bun.escapeHTML

In this example, we are using Bun.escapeHTML and replacing every “dorothy” with “doro” instead.

As sanitize will apply to every schema globally, it must be applied on a root instance.

This should greatly reduce the boilerplate to safely validate and transform each string field manually.

In previous versions of Elysia, it was not possible to type-check a FormData response with form and t.Object at compile time.

We have now introduced a new t.Form type to fix that.

Using t.Form to validate FormData

Using t.Form to validate FormData

To migrate to type-check form, simply replace t.Object with t.Form in the response schema.

Elysia now uses file-type to validate file type.

Defining file type using t.File

Defining file type using t.File

Once type is specified, Elysia will automatically detect file type by checking the magic number.

However, it’s also listed as peerDependencies and not installed with Elysia by default to reduce bundle size for users who don’t need it.

It’s recommended to update to Elysia 1.3 if you rely on file type validation for better security.

We can create a reference model by using Elysia.model and reference it by name.

However, sometimes we need to reference it inside a schema.

We can do just that by using Elysia.Ref to reference the model with auto-completion.

Using Elysia.Ref to reference model

Using Elysia.Ref to reference model

You can also use t.Ref to reference a model, but it wouldn’t provide auto-completion.

We received some feedback that some users want to quickly prototype their API or sometimes have problems trying to enforce validation.

In Elysia 1.3, we introduced t.NoValidate to skip validation.

Using t.NoValidate to tell Elysia to skip validation

Using t.NoValidate to tell Elysia to skip validation

This will tell Elysia to skip runtime validation, but still provide TypeScript type checking and OpenAPI schema for API documentation.

We have received a lot of responses about the naming of error.

Starting with Elysia 1.3, we decided to deprecate error, and recommend the use of status instead.

IDE showing that error is deprecated and renamed to status

IDE showing that error is deprecated and renamed to status

The error function will work as it is in the previous version, with no immediate changes required.

However, we recommend refactoring to status instead as we will be supporting error function for at least the next 6 months or until around Elysia 1.4 or 1.5.

To migrate, simply rename error to status.

Previously, you had to add (treaty).index to handle paths that end with /.

Starting with Elysia 1.3, we decided to drop the use of .index and can simply bypass it to call the method directly.

Eden Treaty showing no-use of .index

Eden Treaty showing no-use of .index

This is a breaking change but should require minimal effort to migrate.

To migrate, simply remove .index from your codebase. This should be a simple change by using IDE search to bulk change-and-replace by matching .index to remove it.

Here are some notable changes from the changelog.

  • encodeSchema now stable and enabled by default
  • optimize types
  • reduce redundant type check when using Encode
  • optimize isAsync
  • unwrap Definition[‘typebox’] by default to prevent unnecessary UnwrapTypeModule call
  • Elysia.form can now be type-checked
  • refactor type-system
  • refactor _types into ~Types
  • using aot compilation to check for custom Elysia type, eg. Numeric
  • refactor app.router.static, and move static router code generation to compile phase
  • optimize memory usage on add, _use, and some utility functions
  • improve startup time on multiple routes
  • dynamically create cookie validator as needed in compilation process
  • reduce object cloning
  • optimize start index for finding delimiter of a content type header
  • Promise can now be a static response
  • ParseError now keeps stack trace
  • refactor parseQuery and parseQueryFromURL
  • add config options to mount
  • recompile automatically after async modules are mounted
  • support macro on when hook has function
  • support resolve macro on ws
  • #1146 add support to return web API’s File from handler
  • #1165 skip non-numeric status codes in response schema validation
  • #1177 cookie does not sign when an error is thrown
  • Response returned from onError is using octet stream
  • unintentional memory allocation when using mergeObjectArray
  • handle empty space on Date query
  • only provide c.request to mapResponse when maybeStream is true
  • use plain object for routeTree instead of Map
  • remove compressHistoryHook and decompressHistoryHook
  • webstandard handler now returns text/plain if not on Bun
  • use non const value for decorate unless explicitly specified
  • Elysia.mount now sets detail.hide = true by default
  • remove as('plugin') in favor of as('scoped')
  • remove root index for Eden Treaty
  • remove websocket from ElysiaAdapter
  • remove inference.request

Hi? It’s been a while.

Life can be confusing, can’t it?

One day you’re chasing your dream, working hard toward it.

Before you know it, you look back and realize that you are far ahead of your goal.

Someone looks up to you, and you become their inspiration. A role model for someone.

It sounds amazing, right?

But I don’t think I would be a good role model for others.

Sometimes, things just get exaggerated.

I may appear to be a genius who can create anything but I’m not. I just try my best.

I hang out playing video games with friends, listening to weird songs, and watching movies. I even meet my friends at cosplay conventions.

Just like a normal person.

All this time, I’ve just been hugging tightly to your arm.

I’m just like you, nothing special.

I try my best but I also act like a fool from time to time.

Even if I don’t think I have anything that makes me a role model, I want you to let me say that I’m grateful.

My boring and slightly lonely life, please don’t beautify it too much.

~ I’m glad you’re evil too.