Elysia
1,837,294 reqs/sHono
740,451
Measured in requests/second. Result from TechEmpower Benchmark Round 23 (2025-02-24) in JSON serialization
This guide is for Hono users who want to see a differences from Elysia including syntax, and how to migrate your application from Hono to Elysia by example.
Hono is a fast and lightweight built on Web Standard. It has broad compatibility with multiple runtime like Deno, Bun, Cloudflare Workers, and Node.js.
Elysia is an ergonomic web framework. Designed to be ergonomic and developer-friendly with a focus on sounds type safety and performance.
Both frameworks are built on top of Web Standard API, and has slight different syntax. Hono offers more compatability with multiple runtimes while Elysia focus on specific set of runtimes.
Elysia has significant performance improvements over Hono thanks to static code analysis.
740,451
Measured in requests/second. Result from TechEmpower Benchmark Round 23 (2025-02-24) in JSON serialization
Hono and Elysia has similar routing syntax, using app.get()
and app.post()
methods to define routes and similar path parameters syntax.
Both use a single Context
parameters to handle request and response, and return a response directly.
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => {
return c.text('Hello World')
})
app.post('/id/:id', (c) => {
c.status(201)
return c.text(req.params.id)
})
export default app
Hono use helper
c.text
,c.json
to return a response
import { Elysia } from 'elysia'
const app = new Elysia()
.get('/', 'Hello World')
.post(
'/id/:id',
({ status, params: { id } }) => {
return status(201, id)
}
)
.listen(3000)
Elysia use a single
context
and returns the response directly
While Hono use a c.text
, and c.json
to warp a response, Elysia map a value to a response automatically.
There is a slight different in style guide, Elysia recommends usage of method chaining and object destructuring.
Hono port allocation is depends on runtime, and adapter while Elysia use a single listen
method to start the server.
Hono use a function to parse query, header, and body manually while Elysia automatically parse properties.
import { Hono } from 'hono'
const app = new Hono()
app.post('/user', async (c) => {
const limit = c.req.query('limit')
const { name } = await c.body()
const auth = c.req.header('authorization')
return c.json({ limit, name, auth })
})
Hono parse body automatically but it doesn't apply to query and headers
import { Elysia } from 'elysia'
const app = new Elysia()
.post('/user', (ctx) => {
const limit = ctx.query.limit
const name = ctx.body.name
const auth = ctx.headers.authorization
return { limit, name, auth }
})
Elysia use static code analysis to analyze what to parse
Elysia use static code analysis to determine what to parse, and only parse the required properties.
This is useful for performance and type safety.
Both can inherits another instance as a router, but Elysia treat every instances as a component which can be used as a subrouter.
import { Hono } from 'hono'
const subRouter = new Hono()
subRouter.get('/user', (c) => {
return c.text('Hello User')
})
const app = new Hono()
app.route('/api', subRouter)
Hono require a prefix to separate the subrouter
import { Elysia } from 'elysia'
const subRouter = new Elysia({ prefix: '/api' })
.get('/user', 'Hello User')
const app = new Elysia()
.use(subRouter)
Elysia use optional prefix constructor to define one
While Hono requires a prefix to separate the subrouter, Elysia doesn't require a prefix to separate the subrouter.
While Hono supports for zod, Elysia focus on deep integration with TypeBox to offers seamless integration with OpenAPI, validation, and advanced feature behind the scene.
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const app = new Hono()
app.patch(
'/user/:id',
zValidator(
'param',
z.object({
id: z.coerce.number()
})
),
zValidator(
'json',
z.object({
name: z.string()
})
),
(c) => {
return c.json({
params: c.req.param(),
body: c.req.json()
})
}
)
Hono use pipe based
import { Elysia, t } from 'elysia'
const app = new Elysia()
.patch('/user/:id', ({ params, body }) => ({
params,
body
}),
{
params: t.Object({
id: t.Number()
}),
body: t.Object({
name: t.String()
})
})
Elysia use TypeBox for validation, and coerce type automatically
Both offers type inference from schema to context automatically.
Both Hono, and Elysia use Web Standard API to handle file upload, but Elysia has a built-in declarative support for file validation using file-type to validate mimetype.
import { Hono } from 'hono'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
import { fileTypeFromBlob } from 'file-type'
const app = new Hono()
app.post(
'/upload',
zValidator(
'form',
z.object({
file: z.instanceof(File)
})
),
async (c) => {
const body = await c.req.parseBody()
const type = await fileTypeFromBlob(body.image as File)
if (!type || !type.mime.startsWith('image/')) {
c.status(422)
return c.text('File is not a valid image')
}
return new Response(body.image)
}
)
Hono needs a separate
file-type
library to validate mimetype
import { Elysia, t } from 'elysia'
const app = new Elysia()
.post('/upload', ({ body }) => body.file, {
body: t.Object({
file: t.File({
type: 'image'
})
})
})
Elysia handle file, and mimetype validation declaratively
As Web Standard API doesn't validate mimetype, it is a security risk to trust content-type
provided by the client so external library is required for Hono, while Elysia use file-type
to validate mimetype automatically.
Hono middleware use a single queue-based order similar to Express while Elysia give you a more granular control using an event-based lifecycle.
Elysia's Life Cycle event can be illustrated as the following.
Click on image to enlarge
While Hono has a single flow for request pipeline in order, Elysia can intercept each event in a request pipeline.
import { Hono } from 'hono'
const app = new Hono()
// Global middleware
app.use(async (c, next) => {
console.log(`${c.method} ${c.url}`)
await next()
})
app.get(
'/protected',
// Route-specific middleware
async (c, next) => {
const token = c.headers.authorization
if (!token) {
c.status(401)
return c.text('Unauthorized')
}
await next()
},
(req, res) => {
res.send('Protected route')
}
)
Hono use a single queue-based order for middleware which execute in order
import { Elysia } from 'elysia'
const app = new Elysia()
// Global middleware
.onRequest('/user', ({ method, path }) => {
console.log(`${method} ${path}`)
})
// Route-specific middleware
.get('/protected', () => 'protected', {
beforeHandle({ status, headers }) {
if (!headers.authorizaton)
return status(401)
}
})
Elysia use a specific event interceptor for each point in the request pipeline
While Hono has a next
function to call the next middleware, Elysia does not has one.
Elysia is designed to be sounds type safety.
For example, you can customize context in a type safe manner using derive and resolve while Hono doesn't not.
import { Hono } from 'hono'
import { createMiddleware } from 'hono/factory'
const app = new Hono()
const getVersion = createMiddleware(async (c, next) => {
c.set('version', 2)
await next()
})
app.use(getVersion)
app.get('/version', getVersion, (c) => {
return c.text(c.get('version') + '')No overload matches this call.
Overload 1 of 2, '(key: never): unknown', gave the following error.
Argument of type '"version"' is not assignable to parameter of type 'never'.
Overload 2 of 2, '(key: never): never', gave the following error.
Argument of type '"version"' is not assignable to parameter of type 'never'.})
const authenticate = createMiddleware(async (c, next) => {
const token = c.req.header('authorization')
if (!token) {
c.status(401)
return c.text('Unauthorized')
}
c.set('token', token.split(' ')[1])
await next()
})
app.post('/user', authenticate, async (c) => {
c.get('version')No overload matches this call.
Overload 1 of 2, '(key: never): unknown', gave the following error.
Argument of type '"version"' is not assignable to parameter of type 'never'.
Overload 2 of 2, '(key: never): never', gave the following error.
Argument of type '"version"' is not assignable to parameter of type 'never'.
return c.text(c.get('token'))No overload matches this call.
Overload 1 of 2, '(key: never): unknown', gave the following error.
Argument of type '"token"' is not assignable to parameter of type 'never'.
Overload 2 of 2, '(key: never): never', gave the following error.
Argument of type '"token"' is not assignable to parameter of type 'never'.No overload matches this call.
Overload 1 of 2, '(text: string, status?: ContentfulStatusCode | undefined, headers?: HeaderRecord | undefined): Response & TypedResponse<string, ContentfulStatusCode, "text">', gave the following error.
Argument of type 'unknown' is not assignable to parameter of type 'string'.
Overload 2 of 2, '(text: string, init?: ResponseOrInit<ContentfulStatusCode> | undefined): Response & TypedResponse<string, ContentfulStatusCode, "text">', gave the following error.
Argument of type 'unknown' is not assignable to parameter of type 'string'.})
Hono use a middleware to extend the context, but is not type safe
import { Elysia } from 'elysia'
const app = new Elysia()
.decorate('version', 2)
.get('/version', ({ version }) => version)
.resolve(({ status, headers: { authorization } }) => {
if(!authorization?.startsWith('Bearer '))
return status(401)
return {
token: authorization.split(' ')[1]
}
})
.get('/token', ({ token, version }) => {
version
return token
})
Elysia use a specific event interceptor for each point in the request pipeline
While Hono can, use declare module
to extend the ContextVariableMap
interface, it is globally available and doesn't have sounds type safety, and doesn't garantee that the property is available in all request handlers.
declare module 'hono' {
interface ContextVariableMap {
version: number
token: string
}
}
This is required for the above Hono example to work, which doesn't offers sounds type safety
Hono use a callback function to define a reusable route-specific middleware, while Elysia use macro to define a custom hook.
import { Hono } from 'hono'
import { createMiddleware } from 'hono/factory'
const app = new Hono()
const role = (role: 'user' | 'admin') => createMiddleware(async (c, next) => {
const user = findUser(c.req.header('Authorization'))
if(user.role !== role) {
c.status(401)
return c.text('Unauthorized')
}
c.set('user', user)
await next()
})
app.get('/user/:id', role('admin'), (c) => {
return c.json(c.get('user'))No overload matches this call.
Overload 1 of 2, '(key: never): unknown', gave the following error.
Argument of type '"user"' is not assignable to parameter of type 'never'.
Overload 2 of 2, '(key: never): never', gave the following error.
Argument of type '"user"' is not assignable to parameter of type 'never'.No overload matches this call.
Overload 1 of 2, '(object: JSONValue | InvalidJSONValue | {}, status?: ContentfulStatusCode | undefined, headers?: HeaderRecord | undefined): JSONRespondReturn<...>', gave the following error.
Argument of type 'unknown' is not assignable to parameter of type 'JSONValue | InvalidJSONValue | {}'.
Overload 2 of 2, '(object: JSONValue | InvalidJSONValue | {}, init?: ResponseOrInit<ContentfulStatusCode> | undefined): JSONRespondReturn<...>', gave the following error.
Argument of type 'unknown' is not assignable to parameter of type 'JSONValue | InvalidJSONValue | {}'.Type instantiation is excessively deep and possibly infinite.})
Hono use callback to return
createMiddleware
to create a reusable middleware, but is not type safe
import { Elysia } from 'elysia'
const app = new Elysia()
.macro({
role: (role: 'user' | 'admin') => ({
resolve({ status, headers: { authorization } }) {
const user = findUser(authorization)
if(user.role !== role)
return status(401)
return {
user
}
}
})
})
.get('/token', ({ user }) => user, {
role: 'admin'
})
Elysia use macro to pass custom argument to custom middleware
Hono provide a onError
function which apply to all routes while Elysia provides a more granular control over error handling.
import { Hono } from 'hono'
const app = new Hono()
class CustomError extends Error {
constructor(message: string) {
super(message)
this.name = 'CustomError'
}
}
// global error handler
app.onError((error, c) => {
if(error instanceof CustomError) {
c.status(500)
return c.json({
message: 'Something went wrong!',
error
})
}
})
// route-specific error handler
app.get('/error', (req, res) => {
throw new CustomError('oh uh')
})
Hono use
onError
funcition to handle error, a single error handler for all routes
import { Elysia } from 'elysia'
class CustomError extends Error {
// Optional: custom HTTP status code
status = 500
constructor(message: string) {
super(message)
this.name = 'CustomError'
}
// Optional: what should be sent to the client
toResponse() {
return {
message: "If you're seeing this, our dev forgot to handle this error",
error: this
}
}
}
const app = new Elysia()
// Optional: register custom error class
.error({
CUSTOM: CustomError,
})
// Global error handler
.onError(({ error, code }) => {
if(code === 'CUSTOM')
return {
message: 'Something went wrong!',
error
}
})
.get('/error', () => {
throw new CustomError('oh uh')
}, {
// Optional: route specific error handler
error({ error }) {
return {
message: 'Only for this route!',
error
}
}
})
Elysia provide more granular control over error handling, and scoping mechanism
While Hono offers error handling using middleware-like, Elysia provide:
toResponse
for mapping error to a responseThe error code is useful for logging and debugging, and is important when differentiating between different error types extending the same class.
Hono encapsulate plugin side-effect, while Elysia give you a control over side-effect of a plugin via explicit scoping mechanism, and order-of-code.
import { Hono } from 'hono'
const subRouter = new Hono()
subRouter.get('/user', (c) => {
return c.text('Hello User')
})
const app = new Hono()
app.route('/api', subRouter)
Hono encapsulate side-effect of a plugin
import { Elysia } from 'elysia'
const subRouter = new Elysia()
.onBeforeHandle(({ status, headers: { authorization } }) => {
if(!authorization?.startsWith('Bearer '))
return status(401)
})
const app = new Elysia()
.get('/', 'Hello World')
.use(subRouter)
// doesn't have side-effect from subRouter
.get('/side-effect', () => 'hi')
Elysia encapsulate side-effect of a plugin unless explicitly stated
Both has a encapsulate mechanism of a plugin to prevent side-effect.
However, Elysia can explicitly stated which plugin should have side-effect by declaring a scoped while Fastify always encapsulate it.
import { Elysia } from 'elysia'
const subRouter = new Elysia()
.onBeforeHandle(({ status, headers: { authorization } }) => {
if(!authorization?.startsWith('Bearer '))
return status(401)
})
// Scoped to parent instance but not beyond
.as('scoped')
const app = new Elysia()
.get('/', 'Hello World')
.use(subRouter)
// now have side-effect from subRouter
.get('/side-effect', () => 'hi')
Elysia offers 3 type of scoping mechanism:
As Hono doesn't offers a scoping mechanism, we need to either:
However, this can caused a duplicated side-effect if not handled carefully.
import { Hono } from 'hono'
import { createMiddleware } from 'hono/factory'
const middleware = createMiddleware(async (c, next) => {
console.log('called')
await next()
})
const app = new Hono()
const subRouter = new Hono()
app.use(middleware)
app.get('/main', (c) => c.text('Hello from main!'))
subRouter.use(middleware)
// This would log twice
subRouter.get('/sub', (c) => c.text('Hello from sub router!'))
app.route('/sub', subRouter)
export default app
In this scenario, Elysia offers a plugin deduplication mechanism to prevent duplicated side-effect.
import { Elysia } from 'elysia'
const subRouter = new Elysia({ name: 'subRouter' })
.onBeforeHandle(({ status, headers: { authorization } }) => {
if(!authorization?.startsWith('Bearer '))
return status(401)
})
.as('scoped')
const app = new Elysia()
.get('/', 'Hello World')
.use(subRouter)
.use(subRouter)
.use(subRouter)
.use(subRouter)
// side-effect only called once
.get('/side-effect', () => 'hi')
By using a unique name
, Elysia will apply the plugin only once, and will not cause duplicated side-effect.
Hono has a built-in cookie utility functions under hono/cookie
, while Elysia use a signal-based approach to handle cookies.
import { Hono } from 'hono'
import { getSignedCookie, setSignedCookie } from 'hono/cookie'
const app = new Hono()
app.get('/', async (c) => {
const name = await getSignedCookie(c, 'secret', 'name')
await setSignedCookie(
c,
'name',
'value',
'secret',
{
maxAge: 1000,
}
)
})
Hono use utility functions to handle cookies
import { Elysia } from 'elysia'
const app = new Elysia({
cookie: {
secret: 'secret'
}
})
.get('/', ({ cookie: { name } }) => {
// signature verification is handle automatically
name.value
// cookie signature is signed automatically
name.value = 'value'
name.maxAge = 1000 * 60 * 60 * 24
})
Elysia use signal-based approach to handle cookies
Hono require additional effort to describe the specification, while Elysia seamless integrate the specification into the schema.
import { Hono } from 'hono'
import { describeRoute, openAPISpecs } from 'hono-openapi'
import { resolver, validator as zodValidator } from 'hono-openapi/zod'
import { swaggerUI } from '@hono/swagger-ui'
import { z } from '@hono/zod-openapi'
const app = new Hono()
const model = z.array(
z.object({
name: z.string().openapi({
description: 'first name only'
}),
age: z.number()
})
)
const detail = await resolver(model).builder()
console.log(detail)
app.post(
'/',
zodValidator('json', model),
describeRoute({
validateResponse: true,
summary: 'Create user',
requestBody: {
content: {
'application/json': { schema: detail.schema }
}
},
responses: {
201: {
description: 'User created',
content: {
'application/json': { schema: resolver(model) }
}
}
}
}),
(c) => {
c.status(201)
return c.json(c.req.valid('json'))
}
)
app.get('/ui', swaggerUI({ url: '/doc' }))
app.get(
'/doc',
openAPISpecs(app, {
documentation: {
info: {
title: 'Hono API',
version: '1.0.0',
description: 'Greeting API'
},
components: {
...detail.components
}
}
})
)
export default app
Hono require additional effort to describe the specification
import { Elysia, t } from 'elysia'
import { swagger } from '@elysiajs/swagger'
const app = new Elysia()
.use(swagger())
.model({
user: t.Object({
name: t.String(),
age: t.Number()
})
})
.post('/users', ({ body }) => body, {
body: 'user[]',
response: {
201: 'user[]'
},
detail: {
summary: 'Create user'
}
})
Elysia seamlessly integrate the specification into the schema
Hono has separate function to describe route specification, validation, and require some effort to setup properly.
Elysia use schema you provide to generate the OpenAPI specification, and validate the request/response, and infer type automatically all from a single source of truth.
Elysia also appends the schema registered in model
to the OpenAPI spec, allowing you to reference the model in a dedicated section in Swagger or Scalar UI while Hono inline the schema to the route.
Both is built on top of Web Standard API allowing it be used with any testing library.
import { Hono } from 'hono'
import { describe, it, expect } from 'vitest'
const app = new Hono()
.get('/', (c) => c.text('Hello World'))
describe('GET /', () => {
it('should return Hello World', async () => {
const res = await app.request('/')
expect(res.status).toBe(200)
expect(await res.text()).toBe('Hello World')
})
})
Hono has a built-in
request
method to run the request
import { Elysia } from 'elysia'
import { describe, it, expect } from 'vitest'
const app = new Elysia()
.get('/', 'Hello World')
describe('GET /', () => {
it('should return Hello World', async () => {
const res = await app.handle(
new Request('http://localhost')
)
expect(res.status).toBe(200)
expect(await res.text()).toBe('Hello World')
})
})
Elysia use Web Standard API to handle request and response
Alternatively, Elysia also offers a helper library called Eden for End-to-end type safety, allowing us to test with auto-completion, and full type safety.
import { Elysia } from 'elysia'
import { treaty } from '@elysiajs/eden'
import { describe, expect, it } from 'bun:test'
const app = new Elysia().get('/hello', 'Hello World')
const api = treaty(app)
describe('GET /', () => {
it('should return Hello World', async () => {
const { data, error, status } = await api.hello.get()
expect(status).toBe(200)
expect(data).toBe('Hello World')
})
})
Both offers end-to-end type safety, however Hono doesn't seems to offers type-safe error handling based on status code.
import { Hono } from 'hono'
import { hc } from 'hono/client'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
const app = new Hono()
.post(
'/mirror',
zValidator(
'json',
z.object({
message: z.string()
})
),
(c) => c.json(c.req.valid('json'))
)
const client = hc<typeof app>('/')
const response = await client.mirror.$post({
json: {
message: 'Hello, world!'
}
})
const data = await response.json()
console.log(data)
Hono use
hc
to run the request, and offers end-to-end type safety
import { Elysia, t } from 'elysia'
import { treaty } from '@elysiajs/eden'
const app = new Elysia()
.post('/mirror', ({ body }) => body, {
body: t.Object({
message: t.String()
})
})
const api = treaty(app)
const { data, error } = await api.mirror.post({
message: 'Hello World'
})
if(error)
throw error
console.log(data)
Elysia use
treaty
to run the request, and offers end-to-end type safety
While both offers end-to-end type safety, Elysia offers a more type-safe error handling based on status code while Hono doesn't.
Using the same purpose code for each framework to measure type inference speed, Elysia is 2.3x faster than Hono for type checking.
Elysia take 536ms to infer both Elysia, and Eden (click to enlarge)
Hono take 1.27s to infer both Hono, and HC with error (aborted) (click to enlarge)
The 1.27 seconds doesn't reflect the entire duration of the inference, but a duration from start to aborted by error "Type instantiation is excessively deep and possibly infinite." which happens when there are too large schema.
Hono HC showing excessively deep error
This is caused by the large schema, and Hono doesn't support over a 100 routes with complex body, and response validation while Elysia doesn't have this issue.
Elysia Eden code showing type inference without error
Elysia has a faster type inference performance, and doesn't have "Type instantiation is excessively deep and possibly infinite." at least up to 2,000 routes with complex body, and response validation.
If end-to-end type safety is important for you then Elysia is the right choice.
Both are the next generation web framework built on top of Web Standard API with slight differences.
Elysia is designed to be ergonomic and developer-friendly with a focus on sounds type safety, and has beter performance than Hono.
While Hono offers a broad compatibility with multiple runtimes, especially with Cloudflare Workers, and a larger user base.
Alternatively, if you are coming from a different framework, you can check out: