> ## Content Index
> Fetch the complete content index at: https://blog.devutilites.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Hono, Fastify, Express o NestJS 12: comparativa a fondo con pruebas reales
- URL: https://blog.devutilites.com/hono-fastify-express-nestjs-comparativa/
- Published: 2026-09-29T18:33:15.000Z
- Updated: 2026-09-29T18:33:15.000Z
- Description: La misma API escrita en los cuatro, pruebas de rendimiento reales en un servidor de producción, tabla de características, ventajas y desventajas de cada uno y cuándo elegir cuál.
- Author: Luis Collantes
- Tags: Backend

Si hoy empiezas un backend en JavaScript o TypeScript, la pregunta aparece en el minuto uno: ¿**Express**, que lo usa todo el mundo? ¿**Fastify**, que presume de ser el más rápido? ¿**Hono**, que funciona en cualquier sitio y está de moda? ¿O **NestJS**, que te da una arquitectura completa y acaba de sacar su versión 12?

Internet está lleno de comparativas que se quedan en un "hola mundo" y una gráfica copiada de otro sitio. Aquí he hecho lo contrario: he escrito **la misma API completa en los cuatro**, con validación, errores y un CRUD, he comprobado que responden exactamente igual y los he puesto a prueba **en este mismo servidor** (el que sirve este blog), uno detrás de otro, con las mismas condiciones. Luego he medido lo que casi nadie mide: cuánto tardan en arrancar, cuánta memoria gastan y cuánto pesan instalados.

Si quieres profundizar en cada uno por separado, tienes un artículo completo de cada framework: [Hono a fondo](https://blog.devutilites.com/hono-a-fondo/), [Fastify a fondo](https://blog.devutilites.com/fastify-a-fondo/), [Express a fondo](https://blog.devutilites.com/express-a-fondo/) y [NestJS 12 a fondo](https://blog.devutilites.com/nestjs-12-a-fondo/). Este es el que los pone cara a cara.

🧭

****Resumen para quien tenga prisa:** en Node, Fastify y Hono empatan en las rutas sencillas y Fastify gana cuando hay que validar; Hono es además el más ligero y el único que funciona igual en Node, Bun, Deno y Cloudflare Workers; Express es el más lento (aunque con Node 24 duplica su velocidad) pero el más conocido, y en su versión 5 por fin es moderno; NestJS no compite en velocidad sino en organización: es la opción para equipos y proyectos grandes, y sobre Fastify pierde poco rendimiento.

## Los cuatro en una frase

- **Express** (2010): el veterano. Un router y una cadena de middleware sobre el servidor HTTP de Node. No impone nada, y por eso casi todo el ecosistema de Node sabe hablar con él.
- **Fastify** (2016): nacido para ser rápido sin renunciar a la comodidad. Cada ruta lleva su esquema JSON, que sirve a la vez para validar, para serializar más deprisa y para documentar. Todo se extiende con plugins encapsulados.
- **Hono** (2021): "llama" en japonés. Pequeño, sin dependencias y construido sobre los estándares de la web (`Request` y `Response`, los mismos de `fetch`). Por eso el mismo código corre en Cloudflare Workers, Bun, Deno, Lambda o Node.
- **NestJS** (2017): no es exactamente un servidor HTTP, sino un **framework de aplicación** que se monta encima de Express o de Fastify. Aporta módulos, inyección de dependencias, controladores con decoradores y un montón de piezas para aplicaciones grandes.

![Esquema de capas: NestJS encima de Express o Fastify, estos sobre node:http, y Hono sobre las APIs web estándar, todo sobre Node u otros runtimes](https://blog.devutilites.com/content/images/2026/09/comparativa-capas.png)

NestJS no compite con Express y Fastify: se apoya en ellos. Hono se apoya en las APIs estándar de la web. Diagrama: devutilites.

Este esquema explica la mitad de las diferencias que verás después. Express y Fastify trabajan con los objetos del módulo `node:http` de Node. Hono trabaja con `Request` y `Response` estándar: en Node necesita un pequeño adaptador (`@hono/node-server`) que traduce entre ambos mundos, pero en Workers, Bun o Deno no necesita nada. Y NestJS añade una capa más arriba: tú escribes clases con decoradores y Nest las traduce a rutas de Express o de Fastify.

## Descarga e instalación

Estas son las versiones que he probado (las últimas publicadas a finales de septiembre de 2026) y dónde conseguir cada uno:

| Framework   | Versión | Web oficial                                                                                                            | npm                                                                                                   | Código fuente                                                                                 |
| ----------- | ------- | ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| **Hono**    | 4.13.11 | [hono.dev](https://hono.dev/?ref=blog.devutilites.com)                                                                 | [npmjs.com/package/hono](https://www.npmjs.com/package/hono?ref=blog.devutilites.com)                 | [github.com/honojs/hono](https://github.com/honojs/hono?ref=blog.devutilites.com)             |
| **Fastify** | 5.12.5  | [fastify.dev](https://fastify.dev/?ref=blog.devutilites.com)                                                           | [npmjs.com/package/fastify](https://www.npmjs.com/package/fastify?ref=blog.devutilites.com)           | [github.com/fastify/fastify](https://github.com/fastify/fastify?ref=blog.devutilites.com)     |
| **Express** | 5.2.1   | [expressjs.com](https://expressjs.com/?ref=blog.devutilites.com)                                                       | [npmjs.com/package/express](https://www.npmjs.com/package/express?ref=blog.devutilites.com)           | [github.com/expressjs/express](https://github.com/expressjs/express?ref=blog.devutilites.com) |
| **NestJS**  | 12.1.1  | [nestjs.com](https://nestjs.com/?ref=blog.devutilites.com) · [docs](https://docs.nestjs.com/?ref=blog.devutilites.com) | [npmjs.com/package/@nestjs/core](https://www.npmjs.com/package/@nestjs/core?ref=blog.devutilites.com) | [github.com/nestjs/nest](https://github.com/nestjs/nest?ref=blog.devutilites.com)             |

Y los comandos para empezar un proyecto con cada uno:

```bash
# Hono: asistente que pregunta el runtime (Node, Bun, Cloudflare Workers…)
npm create hono@latest mi-api
# o, en un proyecto existente de Node:
npm install hono @hono/node-server

# Fastify
npm install fastify
# (existe también un generador: npx create-fastify mi-api)

# Express 5
npm install express

# NestJS 12: la CLI crea el proyecto completo (pregunta CommonJS o ESM)
npm install -g @nestjs/cli
nest new mi-api
# para usar Fastify en lugar de Express:
npm install @nestjs/platform-fastify
```

📦

****Versión de Node:** Fastify 5 pide Node 20 o superior y NestJS 12 exige ****Node 20.19+ o 22.12+** (sus paquetes ya son ESM y dependen de `require(esm)`). Hono y Express funcionan con versiones más antiguas, pero no hay razón para usar un Node sin soporte: instala la LTS activa.

## La misma API en los cuatro

Para comparar de verdad hay que escribir lo mismo en los cuatro. La API tiene dos partes:

- **Las rutas del benchmark:** `GET /` devuelve texto, `GET /json` un objeto, `GET /users/:id` lee un parámetro de la URL y `POST /echo` valida un cuerpo JSON (`nombre` no vacío y `edad` entera mayor o igual que cero) y lo devuelve.
- **Un CRUD de tareas** (`/tareas`): listar, obtener, crear, reemplazar y borrar, con validación del cuerpo, 404 cuando la tarea no existe, 400 cuando los datos no son válidos o el JSON está mal formado, y un manejador de errores centralizado.

Guardo las tareas en un `Map` en memoria para no medir la base de datos, que no es lo que nos interesa aquí. Para validar he usado lo **idiomático de cada uno**: Zod en Hono y en Express, JSON Schema en Fastify (que es lo suyo) y el nuevo soporte de Standard Schema con Zod en NestJS 12\. Todo el código que ves aquí es exactamente el que se ejecutó en las pruebas.

### Hono

```javascript
import { Hono } from 'hono';
import { serve } from '@hono/node-server';
import { HTTPException } from 'hono/http-exception';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';

const Tarea = z.object({
  titulo: z.string().min(1).max(200),
  hecha: z.boolean().default(false),
});
const Eco = z.object({ nombre: z.string().min(1), edad: z.number().int().min(0) });

// Si la validación falla, respondemos siempre con el mismo formato
const validar = (donde, esquema) =>
  zValidator(donde, esquema, (r, c) => {
    if (!r.success) return c.json({ error: 'Datos no válidos', detalles: r.error.issues }, 400);
  });

const tareas = new Map();
let siguienteId = 1;

const app = new Hono();

// Rutas del benchmark
app.get('/', (c) => c.text('Hola, mundo'));
app.get('/json', (c) => c.json({ hola: 'mundo' }));
app.get('/users/:id', (c) => c.json({ id: c.req.param('id'), nombre: 'Ada' }));
app.post('/echo', validar('json', Eco), (c) => c.json(c.req.valid('json')));

// CRUD de tareas
app.get('/tareas', (c) => c.json([...tareas.values()]));

app.get('/tareas/:id', (c) => {
  const tarea = tareas.get(Number(c.req.param('id')));
  if (!tarea) throw new HTTPException(404, { message: 'Tarea no encontrada' });
  return c.json(tarea);
});

app.post('/tareas', validar('json', Tarea), (c) => {
  const tarea = { id: siguienteId++, ...c.req.valid('json') };
  tareas.set(tarea.id, tarea);
  return c.json(tarea, 201);
});

app.put('/tareas/:id', validar('json', Tarea), (c) => {
  const id = Number(c.req.param('id'));
  if (!tareas.has(id)) throw new HTTPException(404, { message: 'Tarea no encontrada' });
  const tarea = { id, ...c.req.valid('json') };
  tareas.set(id, tarea);
  return c.json(tarea);
});

app.delete('/tareas/:id', (c) => {
  if (!tareas.delete(Number(c.req.param('id'))))
    throw new HTTPException(404, { message: 'Tarea no encontrada' });
  return c.body(null, 204);
});

// Manejo de errores centralizado
app.notFound((c) => c.json({ error: 'Ruta no encontrada' }, 404));
app.onError((err, c) => {
  if (err instanceof HTTPException) return c.json({ error: err.message }, err.status);
  console.error(err);
  return c.json({ error: 'Error interno' }, 500);
});

serve({ fetch: app.fetch, port: Number(process.env.PORT) || 3000 }, (info) =>
  console.log(`Hono escuchando en http://localhost:${info.port}`),
);
```

Lo que salta a la vista: **todo pasa por el contexto `c`**. `c.req.param()` lee la URL, `c.req.valid('json')` devuelve el cuerpo ya validado y tipado, y `c.json()` construye la respuesta. No hay `res` que mutar: cada manejador **devuelve** una `Response`. Los errores se lanzan con `HTTPException` y se recogen en `app.onError`. El servidor de Node es una línea al final (`serve`); en Cloudflare Workers bastaría con `export default app`.

### Fastify

```javascript
import Fastify from 'fastify';

const tareaSchema = {
  type: 'object',
  required: ['titulo'],
  additionalProperties: false,
  properties: {
    titulo: { type: 'string', minLength: 1, maxLength: 200 },
    hecha: { type: 'boolean', default: false },
  },
};
const tareaRespuesta = {
  type: 'object',
  properties: { id: { type: 'integer' }, titulo: { type: 'string' }, hecha: { type: 'boolean' } },
};
const idParam = {
  type: 'object',
  properties: { id: { type: 'integer' } }, // Fastify convierte "7" en 7
};

const tareas = new Map();
let siguienteId = 1;

const app = Fastify({ logger: false });

// Rutas del benchmark
app.get('/', async () => 'Hola, mundo');
app.get('/json', async () => ({ hola: 'mundo' }));
app.get('/users/:id', async (req) => ({ id: req.params.id, nombre: 'Ada' }));
app.post('/echo', {
  schema: {
    body: {
      type: 'object',
      required: ['nombre', 'edad'],
      properties: { nombre: { type: 'string', minLength: 1 }, edad: { type: 'integer', minimum: 0 } },
    },
  },
}, async (req) => req.body);

// CRUD de tareas, como plugin con su prefijo
app.register(async (rutas) => {
  rutas.get('/', {
    schema: { response: { 200: { type: 'array', items: tareaRespuesta } } },
  }, async () => [...tareas.values()]);

  rutas.get('/:id', {
    schema: { params: idParam, response: { 200: tareaRespuesta } },
  }, async (req, reply) => {
    const tarea = tareas.get(req.params.id);
    if (!tarea) return reply.code(404).send({ error: 'Tarea no encontrada' });
    return tarea;
  });

  rutas.post('/', {
    schema: { body: tareaSchema, response: { 201: tareaRespuesta } },
  }, async (req, reply) => {
    const tarea = { id: siguienteId++, ...req.body };
    tareas.set(tarea.id, tarea);
    return reply.code(201).send(tarea);
  });

  rutas.put('/:id', {
    schema: { params: idParam, body: tareaSchema, response: { 200: tareaRespuesta } },
  }, async (req, reply) => {
    if (!tareas.has(req.params.id)) return reply.code(404).send({ error: 'Tarea no encontrada' });
    const tarea = { id: req.params.id, ...req.body };
    tareas.set(tarea.id, tarea);
    return tarea;
  });

  rutas.delete('/:id', { schema: { params: idParam } }, async (req, reply) => {
    if (!tareas.delete(req.params.id)) return reply.code(404).send({ error: 'Tarea no encontrada' });
    return reply.code(204).send();
  });
}, { prefix: '/tareas' });

// Manejo de errores centralizado
app.setNotFoundHandler((req, reply) => reply.code(404).send({ error: 'Ruta no encontrada' }));
app.setErrorHandler((err, req, reply) => {
  if (err.validation) {
    return reply.code(400).send({ error: 'Datos no válidos', detalles: err.validation });
  }
  req.log.error(err);
  return reply.code(err.statusCode ?? 500).send({ error: err.statusCode ? err.message : 'Error interno' });
});

await app.listen({ port: Number(process.env.PORT) || 3000, host: '0.0.0.0' });
console.log(`Fastify escuchando en http://localhost:${app.server.address().port}`);
```

Aquí la protagonista es la **opción `schema`** de cada ruta. El esquema del cuerpo valida la entrada (con Ajv, compilado a una función al arrancar), el de `params` convierte `"7"` en el número 7 sin que hagas nada, y el de `response` hace dos cosas: acelera la serialización con fast-json-stringify y **filtra los campos** que no estén declarados, lo que evita filtrar datos por accidente. El CRUD vive en un plugin registrado con prefijo: en una aplicación real, cada plugin sería un fichero con sus rutas y sus dependencias encapsuladas.

### Express 5

```javascript
import express from 'express';
import { z } from 'zod';

const Tarea = z.object({
  titulo: z.string().min(1).max(200),
  hecha: z.boolean().default(false),
});
const Eco = z.object({ nombre: z.string().min(1), edad: z.number().int().min(0) });

// Error con código HTTP, para lanzarlo desde cualquier ruta
class HttpError extends Error {
  constructor(status, message) { super(message); this.status = status; }
}

// Middleware de validación: si todo va bien, deja el body ya limpio
const validar = (esquema) => (req, res, next) => {
  const r = esquema.safeParse(req.body);
  if (!r.success) return res.status(400).json({ error: 'Datos no válidos', detalles: r.error.issues });
  req.body = r.data;
  next();
};

const tareas = new Map();
let siguienteId = 1;

const app = express();
app.use(express.json());

// Rutas del benchmark
app.get('/', (req, res) => res.send('Hola, mundo'));
app.get('/json', (req, res) => res.json({ hola: 'mundo' }));
app.get('/users/:id', (req, res) => res.json({ id: req.params.id, nombre: 'Ada' }));
app.post('/echo', validar(Eco), (req, res) => res.json(req.body));

// CRUD de tareas en su propio router
const rutas = express.Router();

rutas.get('/', (req, res) => res.json([...tareas.values()]));

rutas.get('/:id', (req, res) => {
  const tarea = tareas.get(Number(req.params.id));
  if (!tarea) throw new HttpError(404, 'Tarea no encontrada');
  res.json(tarea);
});

rutas.post('/', validar(Tarea), (req, res) => {
  const tarea = { id: siguienteId++, ...req.body };
  tareas.set(tarea.id, tarea);
  res.status(201).json(tarea);
});

rutas.put('/:id', validar(Tarea), (req, res) => {
  const id = Number(req.params.id);
  if (!tareas.has(id)) throw new HttpError(404, 'Tarea no encontrada');
  const tarea = { id, ...req.body };
  tareas.set(id, tarea);
  res.json(tarea);
});

rutas.delete('/:id', (req, res) => {
  if (!tareas.delete(Number(req.params.id))) throw new HttpError(404, 'Tarea no encontrada');
  res.status(204).end();
});

app.use('/tareas', rutas);

// Manejo de errores centralizado (en Express 5 también recoge los errores async)
app.use((req, res) => res.status(404).json({ error: 'Ruta no encontrada' }));
app.use((err, req, res, next) => {
  if (err.type === 'entity.parse.failed') return res.status(400).json({ error: 'JSON mal formado' });
  if (err instanceof HttpError) return res.status(err.status).json({ error: err.message });
  console.error(err);
  res.status(500).json({ error: 'Error interno' });
});

const port = Number(process.env.PORT) || 3000;
app.listen(port, () => console.log(`Express escuchando en http://localhost:${port}`));
```

Express es el más explícito: la validación es un middleware que escribes tú, los 404 son el penúltimo middleware y los errores el último (el que tiene **cuatro** argumentos). La gran novedad de Express 5 se nota aunque no se vea: si un manejador `async` lanza una excepción o su promesa se rechaza, Express 5 la manda al manejador de errores. En Express 4 esa excepción se perdía y la petición se quedaba colgada, y todo el mundo acababa usando `express-async-errors` o envolviendo cada ruta en un `try/catch`.

### NestJS 12

En NestJS la misma API se reparte en varios ficheros, porque así lo pide su arquitectura. Primero el **servicio**, que contiene la lógica y no sabe nada de HTTP:

```typescript
// src/tareas.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { z } from 'zod';

export const TareaSchema = z.object({
  titulo: z.string().min(1).max(200),
  hecha: z.boolean().default(false),
});
export type NuevaTarea = z.infer<typeof TareaSchema>;
export type Tarea = NuevaTarea & { id: number };

@Injectable()
export class TareasService {
  private readonly tareas = new Map<number, Tarea>();
  private siguienteId = 1;

  listar(): Tarea[] {
    return [...this.tareas.values()];
  }

  obtener(id: number): Tarea {
    const tarea = this.tareas.get(id);
    if (!tarea) throw new NotFoundException('Tarea no encontrada');
    return tarea;
  }

  crear(datos: NuevaTarea): Tarea {
    const tarea = { id: this.siguienteId++, ...datos };
    this.tareas.set(tarea.id, tarea);
    return tarea;
  }

  reemplazar(id: number, datos: NuevaTarea): Tarea {
    this.obtener(id); // lanza 404 si no existe
    const tarea = { id, ...datos };
    this.tareas.set(id, tarea);
    return tarea;
  }

  borrar(id: number): void {
    if (!this.tareas.delete(id)) throw new NotFoundException('Tarea no encontrada');
  }
}
```

Después el **controlador**, que traduce HTTP a llamadas al servicio. Fíjate en la novedad de la versión 12: `@Body({ schema: TareaSchema })` y `@Param('id', { schema: Id })` aceptan directamente un esquema de Zod (o de cualquier librería compatible con Standard Schema), sin clases DTO ni class-validator:

```typescript
// src/tareas.controller.ts
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { z } from 'zod';
import { TareaSchema, TareasService } from './tareas.service.js';
import type { NuevaTarea } from './tareas.service.js';

// Convierte "7" en 7 y rechaza lo que no sea un entero positivo
const Id = z.coerce.number().int().positive();

@Controller('tareas')
export class TareasController {
  constructor(private readonly tareas: TareasService) {}

  @Get()
  listar() {
    return this.tareas.listar();
  }

  @Get(':id')
  obtener(@Param('id', { schema: Id }) id: number) {
    return this.tareas.obtener(id);
  }

  @Post()
  crear(@Body({ schema: TareaSchema }) datos: NuevaTarea) {
    return this.tareas.crear(datos);
  }

  @Put(':id')
  reemplazar(@Param('id', { schema: Id }) id: number, @Body({ schema: TareaSchema }) datos: NuevaTarea) {
    return this.tareas.reemplazar(id, datos);
  }

  @Delete(':id')
  @HttpCode(204)
  borrar(@Param('id', { schema: Id }) id: number) {
    this.tareas.borrar(id);
  }
}
```

Las rutas del benchmark, en su propio controlador:

```typescript
// src/bench.controller.ts
import { Body, Controller, Get, Header, Param, Post } from '@nestjs/common';
import { z } from 'zod';

const Eco = z.object({ nombre: z.string().min(1), edad: z.number().int().min(0) });

// Rutas del benchmark
@Controller()
export class BenchController {
  @Get()
  @Header('Content-Type', 'text/plain; charset=utf-8')
  hola() {
    return 'Hola, mundo';
  }

  @Get('json')
  json() {
    return { hola: 'mundo' };
  }

  @Get('users/:id')
  usuario(@Param('id') id: string) {
    return { id, nombre: 'Ada' };
  }

  @Post('echo')
  eco(@Body({ schema: Eco }) body: z.infer<typeof Eco>) {
    return body;
  }
}
```

El **módulo** que lo une todo, y el `main.ts`, donde se elige el adaptador (Express o Fastify) y se registra el `StandardSchemaValidationPipe` que hace que esos esquemas se apliquen de verdad:

```typescript
// src/app.module.ts
import { Module } from '@nestjs/common';
import { BenchController } from './bench.controller.js';
import { TareasController } from './tareas.controller.js';
import { TareasService } from './tareas.service.js';

@Module({
  controllers: [BenchController, TareasController],
  providers: [TareasService],
})
export class AppModule {}
```

```typescript
// src/main.ts
import 'reflect-metadata';
import { StandardSchemaValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter } from '@nestjs/platform-fastify';
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
import { AppModule } from './app.module.js';

async function bootstrap() {
  // ADAPTADOR=fastify arranca Nest sobre Fastify; si no, sobre Express
  const app = process.env.ADAPTADOR === 'fastify'
    ? await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(), { logger: ['error', 'warn'] })
    : await NestFactory.create(AppModule, { logger: ['error', 'warn'] });

  app.useGlobalPipes(new StandardSchemaValidationPipe());
  app.enableShutdownHooks();

  const port = Number(process.env.PORT) || 3000;
  await app.listen(port, '0.0.0.0');
  console.log(`NestJS (${process.env.ADAPTADOR ?? 'express'}) escuchando en http://localhost:${port}`);
}
bootstrap();
```

```json
{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "es2023",
    "outDir": "dist",
    "rootDir": "src",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "strict": true,
    "strictPropertyInitialization": false,
    "skipLibCheck": true
  }
}
```

Es bastante más código, sí. A cambio, cada pieza tiene un sitio fijo, el servicio se puede probar sin HTTP y cambiar Express por Fastify es cambiar **una línea**, como verás en las pruebas.

### Comprobando que hacen lo mismo

Antes de medir nada, pasé la misma batería de peticiones con `curl` a los cinco servidores (los cuatro frameworks más NestJS con su segundo adaptador):

```bash
#!/bin/sh
# Prueba funcional idéntica para los cuatro: uso ./smoke.sh PUERTO
B=http://127.0.0.1:${1:-3000}
r() { printf '%-40s ' "$1 $2"; curl -s -o /tmp/smoke.out -w '%{http_code} ' -X $1 ${3:+-H content-type:application/json -d "$3"} $B$2; head -c 160 /tmp/smoke.out; echo; }
r GET /
r GET /json
r GET /users/42
r POST /echo '{"nombre":"Ada","edad":36}'
r POST /echo '{"nombre":"","edad":-1}'
r POST /tareas '{"titulo":"Comprar pan"}'
r POST /tareas '{"titulo":""}'
r POST /tareas '{"titulo":"x"'
r GET /tareas
r GET /tareas/1
r PUT /tareas/1 '{"titulo":"Comprar pan","hecha":true}'
r GET /tareas/99
r DELETE /tareas/1
r DELETE /tareas/1
r GET /no-existe

# Resultado (Hono; los demás, igual salvo lo que se comenta abajo)
GET /                                    200 Hola, mundo
GET /json                                200 {"hola":"mundo"}
GET /users/42                            200 {"id":"42","nombre":"Ada"}
POST /echo                               200 {"nombre":"Ada","edad":36}
POST /echo                               400 {"error":"Datos no válidos","detalles":[{"origin":"string","code":"too_small","minimum":1,"inclusive":true,"path":["nombre"],"message":"Too small: expected str…
POST /tareas                             201 {"id":1,"titulo":"Comprar pan","hecha":false}
POST /tareas                             400 {"error":"Datos no válidos","detalles":[{"origin":"string","code":"too_small","minimum":1,"inclusive":true,"path":["titulo"],"message":"Too small: expected str…
POST /tareas                             400 {"error":"Malformed JSON in request body"}
GET /tareas                              200 [{"id":1,"titulo":"Comprar pan","hecha":false}]
GET /tareas/1                            200 {"id":1,"titulo":"Comprar pan","hecha":false}
PUT /tareas/1                            200 {"id":1,"titulo":"Comprar pan","hecha":true}
GET /tareas/99                           404 {"error":"Tarea no encontrada"}
DELETE /tareas/1                         204 
DELETE /tareas/1                         404 {"error":"Tarea no encontrada"}
GET /no-existe                           404 {"error":"Ruta no encontrada"}
```

Todos devuelven los mismos datos, pero no exactamente igual. Estas son las diferencias que encontré, y todas dicen algo del carácter de cada framework:

- **NestJS responde `201 Created` a cualquier `POST`**, también a `/echo`, que no crea nada. Es su comportamiento por defecto; si quieres un 200, pon `@HttpCode(200)`.
- **El formato de los errores cambia.** NestJS usa siempre `{ message, error, statusCode }`; en los otros tres el formato es el que tú escribas. Si tienes clientes que ya esperan un formato concreto, en Nest tendrás que escribir un *exception filter*.
- **Los mensajes de validación también.** Zod da `path` y `message`; Ajv (Fastify) da `instancePath` y `keyword`. Nest 12 los aplana en una lista de textos del tipo `titulo: Too small…`.
- **Fastify rechaza un `DELETE` con `Content-Type: application/json` y cuerpo vacío** ("Body cannot be empty when content-type is set to 'application/json'"). Me pasó con mi primera versión del script, que mandaba esa cabecera en todas las peticiones. Express y Hono lo aceptan sin quejarse. Algunos clientes HTTP añaden esa cabecera siempre: tenlo en cuenta si migras a Fastify (o a Nest sobre Fastify).
- **El JSON mal formado** lo gestiona cada uno a su manera: Hono responde "Malformed JSON in request body", Fastify un 400 con su propio mensaje y en Express tienes que reconocer `err.type === 'entity.parse.failed'` en tu manejador de errores, o devolverá un 400 con una página HTML.

## Tabla de características

Las cifras de descargas son las de npm de la última semana completa (del 21 al 27 de septiembre de 2026) y las estrellas, las de GitHub el día de las pruebas.

|                                   | Hono 4                                                                | Fastify 5                                                                | Express 5                                            | NestJS 12                                                                                  |
| --------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| **Versión probada**               | 4.13.11                                                               | 5.12.5                                                                   | 5.2.1                                                | 12.1.1                                                                                     |
| **Primera versión**               | 2021                                                                  | 2016                                                                     | 2010                                                 | 2017                                                                                       |
| **Licencia**                      | MIT                                                                   | MIT                                                                      | MIT                                                  | MIT                                                                                        |
| **Descargas npm (última semana)** | 73.156.540                                                            | 15.716.294                                                               | 155.840.030                                          | 17.002.306 (@nestjs/core)                                                                  |
| **Estrellas en GitHub**           | 32.386                                                                | 37.210                                                                   | 69.498                                               | 76.756                                                                                     |
| **Runtimes**                      | Node, Bun, Deno, Cloudflare Workers, Fastly, Lambda, Vercel, Netlify… | Node                                                                     | Node (Bun y Deno por compatibilidad)                 | Node (sobre Express o Fastify)                                                             |
| **Modelo de petición**            | Request/Response estándar de la web                                   | Objetos propios sobre node:http                                          | req/res de node:http ampliados                       | Los del adaptador, pero rara vez los tocas                                                 |
| **Estilo**                        | Funcional, encadenado, minimalista                                    | Rutas con esquema, hooks y plugins encapsulados                          | Middleware en cadena, libertad total                 | Clases, decoradores, módulos (a lo Angular / Spring)                                       |
| **TypeScript**                    | Nativo; inferencia de rutas, params y validadores                     | Tipos incluidos; *type providers* para inferir desde el esquema          | Tipos aparte (@types/express)                        | Obligatorio en la práctica; todo gira en torno a él                                        |
| **Validación**                    | Middleware validator o @hono/zod-validator, Valibot, etc.             | JSON Schema integrado (Ajv), compilado al arrancar                       | Ninguna: la añades tú (Zod, Joi, express-validator…) | ValidationPipe con class-validator o, desde la 12, Standard Schema (Zod, Valibot, ArkType) |
| **Serialización**                 | JSON.stringify                                                        | fast-json-stringify si defines el esquema de respuesta (y filtra campos) | JSON.stringify                                       | Interceptores de serialización (clases o Standard Schema)                                  |
| **OpenAPI**                       | @hono/zod-openapi                                                     | @fastify/swagger desde los esquemas que ya tienes                        | swagger-jsdoc, tsoa u otros, a mano                  | @nestjs/swagger, desde decoradores y esquemas                                              |
| **Inyección de dependencias**     | No (variables de contexto c.set/c.get)                                | No (decoradores de instancia decorate)                                   | No (req/res.locals)                                  | Sí, el corazón del framework                                                               |
| **Cliente tipado**                | Sí: RPC con hc                                                        | No integrado                                                             | No                                                   | No integrado (sí vía OpenAPI)                                                              |
| **WebSockets**                    | Helper upgradeWebSocket por runtime                                   | @fastify/websocket                                                       | ws o Socket.IO aparte                                | @nestjs/websockets (Socket.IO o ws)                                                        |
| **Pruebas**                       | app.request() en memoria                                              | app.inject() en memoria                                                  | supertest                                            | @nestjs/testing \+ supertest, con sustitución de proveedores                               |
| **Logs**                          | Middleware logger sencillo                                            | Pino integrado, JSON, muy rápido                                         | Ninguno (morgan, pino-http…)                         | ConsoleLogger con JSON y parámetros estructurados                                          |
| **Extras de fábrica**             | CORS, JWT, ETag, compresión, caché, JSX, streaming…                   | Ecosistema @fastify/\* oficial: CORS, JWT, multipart, rate limit…        | Muy pocos; ecosistema enorme de terceros             | GraphQL, microservicios, colas, CQRS, cron, CSRF y cookies (12.1)…                         |
| **Curva de aprendizaje**          | Baja                                                                  | Media                                                                    | Muy baja                                             | Alta                                                                                       |
| **Rendimiento en esta prueba**    | Muy alto (empata con Fastify en GET)                                  | Muy alto (el mejor con validación)                                       | El más bajo                                          | Sobre Fastify, alto; sobre Express, bajo                                                   |

Un apunte sobre las descargas: los números de Express y Hono están inflados porque muchas herramientas los usan por dentro (Hono, por ejemplo, es la base de varios servidores de desarrollo y SDKs), así que no equivalen a "proyectos que lo usan". Aun así, reflejan bien el tamaño de cada ecosistema.

## Pruebas de rendimiento reales

### Cómo las hice

- **Máquina:** el servidor que sirve este blog, un VPS de Hetzner con un procesador AMD EPYC (Rome), 4 núcleos virtuales y 8 GB de RAM, **en producción** (con Ghost, MySQL, Coolify y otras aplicaciones funcionando al mismo tiempo).
- **Software:** Node 20.20.2 en el propio servidor y, para comparar, Node 24.21.0 en un contenedor `node:24-alpine` con red del anfitrión.
- **Carga:** autocannon con 100 conexiones simultáneas, un calentamiento de 2 segundos (para que el compilador JIT de V8 haga su trabajo) y 3 rondas de 8 segundos por ruta. Me quedo con la **mediana**.
- **Carga a un servidor cada vez.** Los cinco servidores están arrancados a la vez en puertos distintos (el que no recibe carga no gasta CPU) y las rondas se **intercalan**: ronda 1 de todos, ronda 2 de todos… Así, si otra aplicación del servidor se pone a trabajar un rato, perjudica a todos por igual. Cada servidor es un solo proceso de Node, sin clúster, sin logs y sin proxy delante. El generador de carga corre en la misma máquina, en otro proceso.
- **Memoria:** el RSS del proceso leído de `/proc/<pid>/status`, en reposo (tras arrancar y esperar 2 segundos) y el máximo durante la carga.
- **Arranque:** desde que lanzo `node` hasta que `GET /json` responde por primera vez, incluido el arranque del propio Node. Los servidores se arrancan de uno en uno para medirlo.

Este es el script completo, por si quieres repetirlo en tu máquina:

```javascript
// Benchmark: los cinco servidores arrancados a la vez en puertos distintos
// (los que no reciben carga no gastan CPU) y rondas intercaladas, para que
// los cambios de carga del servidor afecten a todos por igual.
import { spawn } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import autocannon from 'autocannon';

const REPS = Number(process.env.REPS ?? 3), DUR = Number(process.env.DUR ?? 8), CONN = 100;
const NODE = process.env.NODE_BIN ?? process.execPath;
const servers = {
  hono: ['apps/hono.js'], fastify: ['apps/fastify.js'], express: ['apps/express.js'],
  'nest-express': ['nest/dist/main.js', { ADAPTADOR: 'express' }],
  'nest-fastify': ['nest/dist/main.js', { ADAPTADOR: 'fastify' }],
};
const routes = [
  { name: 'texto', path: '/' },
  { name: 'json', path: '/json' },
  { name: 'param', path: '/users/42' },
  { name: 'post-validado', path: '/echo', method: 'POST',
    headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nombre: 'Ada', edad: 36 }) },
];
const rss = (pid) => Number(/VmRSS:\s+(\d+)/.exec(readFileSync(`/proc/${pid}/status`, 'utf8'))[1]) / 1024;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const median = (a) => [...a].sort((x, y) => x - y)[Math.floor(a.length / 2)];
const load = () => Number(readFileSync('/proc/loadavg', 'utf8').split(' ')[0]);
const run = (port, o, duration = DUR) => new Promise((res, rej) => autocannon({
  url: `http://127.0.0.1:${port}${o.path}`, connections: CONN, duration,
  method: o.method ?? 'GET', headers: o.headers, body: o.body,
}, (e, r) => (e ? rej(e) : res(r))));

const out = { node: process.versions.node, fecha: new Date().toISOString(), reps: REPS, duracion: DUR, conexiones: CONN, carga: [], servidores: {} };
const names = Object.keys(servers).filter((n) => !process.env.ONLY || process.env.ONLY.split(',').includes(n));
const procs = {};

// 1. Arranque: uno a uno, midiendo hasta la primera respuesta
let port = 3300;
for (const name of names) {
  const [file, env = {}] = servers[name];
  const t0 = performance.now();
  const p = spawn(NODE, [file], { env: { ...process.env, ...env, PORT: String(port) }, stdio: 'ignore' });
  for (;;) {
    try { if ((await fetch(`http://127.0.0.1:${port}/json`)).ok) break; } catch {}
    await sleep(5);
  }
  out.servidores[name] = { arranque_ms: Math.round(performance.now() - t0), rutas: {}, pico: 0 };
  procs[name] = { p, port: port++ };
}
await sleep(2000);
for (const name of names) out.servidores[name].rss_reposo_mb = +rss(procs[name].p.pid).toFixed(1);

// 2. Calentamiento de todas las rutas
for (const name of names) for (const rt of routes) await run(procs[name].port, rt, 2);

// 3. Rondas intercaladas: ronda → ruta → servidor
const raw = {};
for (let i = 0; i < REPS; i++) {
  for (const rt of routes) {
    for (const name of names) {
      const r = await run(procs[name].port, rt);
      const s = out.servidores[name];
      s.pico = Math.max(s.pico, rss(procs[name].p.pid));
      if (r.non2xx || r.errors) console.error(name, rt.name, 'non2xx', r.non2xx, 'errors', r.errors);
      ((raw[name] ??= {})[rt.name] ??= []).push({ rps: r.requests.average, lat: r.latency.average, p99: r.latency.p99 });
      out.carga.push(load());
      console.log(`ronda ${i + 1}`, name, rt.name, Math.round(r.requests.average), 'req/s');
    }
  }
}
for (const name of names) {
  const s = out.servidores[name];
  for (const rt of routes) {
    const reps = raw[name][rt.name];
    s.rutas[rt.name] = { rps: Math.round(median(reps.map((x) => x.rps))), lat_ms: +median(reps.map((x) => x.lat)).toFixed(2), p99_ms: median(reps.map((x) => x.p99)), reps };
  }
  s.rss_pico_mb = +s.pico.toFixed(1); delete s.pico;
  procs[name].p.kill();
}
writeFileSync(process.env.OUT ?? `resultados-node${process.versions.node.split('.')[0]}.json`, JSON.stringify(out, null, 1));
console.log('carga media durante la prueba', (out.carga.reduce((a, b) => a + b) / out.carga.length).toFixed(2));
```

### Peticiones por segundo

![Gráfico de barras con las peticiones por segundo de Hono, Fastify, Express, NestJS con Express y NestJS con Fastify en cuatro rutas](https://blog.devutilites.com/content/images/2026/09/comparativa-rps.png)

Peticiones por segundo en cada ruta. Mismo servidor, un framework cada vez. Diagrama: devutilites.

| Servidor             | GET / (texto) | GET /json | GET /users/:id | POST /echo | Latencia media /json | p99 /json |
| -------------------- | ------------- | --------- | -------------- | ---------- | -------------------- | --------- |
| **Hono**             | 19.216        | 17.976    | 16.814         | 10.943     | 5,07 ms              | 11 ms     |
| **Fastify**          | 18.026        | 17.910    | 17.958         | 13.120     | 5,09 ms              | 12 ms     |
| **Express**          | 4.277         | 4.234     | 4.248          | 3.525      | 23,08 ms             | 43 ms     |
| **NestJS + Express** | 3.958         | 3.947     | 3.682          | 3.119      | 24,81 ms             | 48 ms     |
| **NestJS + Fastify** | 13.671        | 14.487    | 13.423         | 9.604      | 6,41 ms              | 13 ms     |

**Hono y Fastify van empatados en cabeza** en las tres rutas sin cuerpo: entre 16.800 y 19.200 peticiones por segundo, con diferencias de un 5-7 % que cambian de lado según la ruta (Hono gana en `GET /`, Fastify en `GET /users/:id` y en `GET /json` empatan). La diferencia aparece al **validar un cuerpo JSON**: Fastify saca 13.120 peticiones por segundo frente a las 10.943 de Hono, un 20 % más. Es el efecto de Ajv, que convierte el JSON Schema en una función de JavaScript al arrancar, frente a Zod, que interpreta el esquema en cada petición.

**Express se queda muy atrás:** unas 4.200 peticiones por segundo, **más de cuatro veces menos** que Hono o Fastify con Node 20 (enseguida verás que con Node 24 la historia cambia mucho). **NestJS sobre Express** pierde otro 7-13 % respecto a Express "a pelo": es el precio de su capa de controladores, pipes e interceptores. En cambio, **NestJS sobre Fastify** llega a 14.487 en `GET /json`, más de tres veces y media lo de NestJS sobre Express y alrededor del 80 % de Fastify solo. Con validación baja al 73 % de Fastify (9.604), porque aquí la validación pasa por el pipe de Nest y por Zod en lugar de por Ajv.

La latencia cuenta la misma historia desde el otro lado: con 100 conexiones simultáneas, Hono y Fastify responden de media en unos 5 ms (el 99 % de las peticiones por debajo de 11-13 ms), mientras que Express tarda unos 23 ms de media y su percentil 99 llega a 43 ms. No es que Express sea lento con una petición; es que, bajo carga, cada petición espera más en la cola porque el proceso tarda más en despachar cada una.

### Arranque, memoria y peso en disco

![Cuatro gráficos de barras: tiempo de arranque, memoria en reposo, memoria máxima bajo carga y tamaño de node_modules de cada framework](https://blog.devutilites.com/content/images/2026/09/comparativa-recursos.png)

Lo que casi nunca sale en las comparativas: arranque, memoria y peso. Diagrama: devutilites.

| Servidor             | Arranque | RSS en reposo | RSS pico bajo carga | node\_modules | Paquetes instalados |
| -------------------- | -------- | ------------- | ------------------- | ------------- | ------------------- |
| **Hono**             | 302 ms   | 62 MB         | 79,1 MB             | 1,4 MB        | 2                   |
| **Fastify**          | 323 ms   | 68,7 MB       | 82 MB               | 7,9 MB        | 49                  |
| **Express**          | 257 ms   | 64,6 MB       | 137,8 MB            | 2,2 MB        | 68                  |
| **NestJS + Express** | 802 ms   | 101,8 MB      | 181,2 MB            | 9,3 MB        | 98                  |
| **NestJS + Fastify** | 933 ms   | 106,7 MB      | 108,4 MB            | 14,9 MB       | 76                  |

El tamaño de `node_modules` y el número de paquetes los medí instalando **solo** lo imprescindible de cada uno en una carpeta vacía (`hono` \+ `@hono/node-server`; `fastify`; `express`; y `@nestjs/core`, `@nestjs/common`, `@nestjs/platform-express`, `reflect-metadata` y `rxjs` para Nest), sin validadores ni TypeScript.

**Arranque:** Hono, Fastify y Express arrancan en unos 0,25-0,3 segundos, y la mayor parte de ese tiempo es el propio Node cargando y, en Hono y Express, también Zod. **NestJS tarda tres veces más** (0,8-0,9 s) porque al arrancar recorre todos los módulos, lee los metadatos de los decoradores y construye el contenedor de inyección de dependencias. En un servidor que arranca una vez al día da igual; en una función *serverless* con arranques en frío, se nota. **Memoria:** en reposo los tres ligeros rondan los 62-69 MB y Nest los 102-107 MB. Bajo carga, Hono y Fastify apenas suben (79-82 MB), mientras que Express llega a 138 MB y Nest sobre Express a 181 MB: más objetos por petición, más trabajo para el recolector de basura. **Peso:** Hono son dos paquetes y 1,4 MB en total; Express pesa poco (2,2 MB) pero son 68 paquetes pequeñitos; Fastify, 49 paquetes y 7,9 MB; y NestJS entre 9 y 15 MB según el adaptador, con hasta 98 paquetes. Menos paquetes es menos superficie para problemas de seguridad y menos avisos de `npm audit`.

### Node 20 frente a Node 24

![Gráfico de barras que compara las peticiones por segundo en GET /json con Node 20 y Node 24 para cada framework](https://blog.devutilites.com/content/images/2026/09/comparativa-node24.png)

El mismo código con dos versiones de Node. Diagrama: devutilites.

| Servidor             | GET /json · Node 20 | GET /json · Node 24 | Diferencia | POST /echo · Node 20 | POST /echo · Node 24 |
| -------------------- | ------------------- | ------------------- | ---------- | -------------------- | -------------------- |
| **Hono**             | 17.976              | 18.030              | +0 %       | 10.943               | 11.054               |
| **Fastify**          | 17.910              | 19.010              | +6 %       | 13.120               | 12.705               |
| **Express**          | 4.234               | 9.868               | +133 %     | 3.525                | 7.690                |
| **NestJS + Express** | 3.947               | 8.824               | +124 %     | 3.119                | 5.855                |
| **NestJS + Fastify** | 14.487              | 16.229              | +12 %      | 9.604                | 9.858                |

Repetí todas las pruebas con **Node 24.21.0** (la LTS activa) en un contenedor `node:24-alpine` con la red del anfitrión, mismo código, misma máquina. Hono y Fastify apenas cambian (0 % y +6 %), lo que además confirma que el contenedor no distorsiona las cifras. NestJS sobre Fastify mejora un 12 %. Pero **Express más que duplica su rendimiento** (+133 %: de 4.234 a 9.868 peticiones por segundo) y NestJS sobre Express, igual (+124 %). Con Node 24, Express sigue siendo el más lento de los cuatro, pero ya "solo" rinde la mitad que Fastify, no la cuarta parte.

### Por qué Express es tan lento en Node 20 (y deja de serlo en Node 24)

Una diferencia de más del doble con el mismo código merecía una explicación, así que investigué un poco. Primero descarté que fuera cosa de Docker o de Alpine: con las imágenes `node:20-alpine` y `node:22-alpine` Express se queda en unas 4.000 peticiones por segundo, igual que en el servidor, y con `node:24-slim` (Debian, no Alpine) sube a unas 10.000\. Tampoco es cosa de Express 5: Express 4 da prácticamente lo mismo en Node 20 (4.824 frente a 4.726 en `GET /json`).

Un perfil de CPU apuntó a lo que Express hace al principio de **cada** petición: cambia el prototipo de los objetos `req` y `res` con `Object.setPrototypeOf` para añadirles sus métodos (`res.json`, `req.get`…). Cambiar el prototipo de un objeto ya creado es algo que los motores de JavaScript optimizan mal. Para comprobarlo, escribí un servidor con `node:http` puro que hace solo eso:

```javascript
// node:http puro, con y sin el cambio de prototipo que hace Express en cada petición
import http from 'node:http';
const reqProto = Object.create(http.IncomingMessage.prototype);
const resProto = Object.create(http.ServerResponse.prototype);
const cambiar = process.env.PROTO === '1';
http.createServer((req, res) => {
  if (cambiar) { Object.setPrototypeOf(req, reqProto); Object.setPrototypeOf(res, resProto); }
  res.setHeader('content-type', 'application/json');
  res.end('{"hola":"mundo"}');
}).listen(3403);
```

| node:http puro, GET /                    | Node 20   | Node 24   |
| ---------------------------------------- | --------- | --------- |
| Sin cambiar prototipos                   | 23.963    | 24.110    |
| Con Object.setPrototypeOf (como Express) | 10.356    | 20.386    |
| **Pérdida**                              | **−57 %** | **−15 %** |

Ahí está: en Node 20, **solo** cambiar esos dos prototipos reduce el rendimiento a menos de la mitad (−57 %), mientras que en Node 24 el mismo truco cuesta un 15 %. El motor V8 de Node 24 maneja mucho mejor este patrón, y Express y NestJS sobre Express son los que más lo notan. Fastify y Hono no cambian prototipos (Fastify envuelve `req` y `res` en sus propios objetos y Hono crea un `Request` estándar), por eso apenas varían entre versiones.

💡

****Consejo práctico:** si tienes una aplicación Express o NestJS con Express en producción, actualizar a ****Node 24** es probablemente la mejora de rendimiento más barata que vas a encontrar: en mis pruebas, más del doble de peticiones por segundo sin cambiar una línea de código.

### Lo que estas cifras no dicen

⚠️

****Un "hola mundo" no es tu aplicación.** En estas pruebas cada petición hace casi nada, así que lo que se mide es el coste del propio framework. En una API real, cada petición consulta una base de datos, llama a otros servicios o genera HTML, y eso tarda ****milisegundos**, mientras que la diferencia entre frameworks son ****microsegundos**. Si tu endpoint tarda 20 ms en la base de datos, da igual que el framework añada 0,05 o 0,1 ms.

- **El servidor estaba en producción.** Otras aplicaciones usaban CPU a la vez (la carga media rondaba 3 sobre 4 núcleos), así que las cifras absolutas tienen ruido. Por eso repetí cada medida y me quedo con la mediana, y por eso importan más las **proporciones** que los números exactos.
- **Generador y servidor comparten máquina.** autocannon también gasta CPU. En una prueba "de laboratorio" irían en máquinas distintas, y todas las cifras serían más altas.
- **Un solo proceso.** En producción probablemente uses varios (con el módulo `cluster`, PM2 o varias réplicas del contenedor), y la cifra total se multiplica por el número de núcleos.
- **La validación sí pesa.** Fíjate en `POST /echo`: leer y validar un cuerpo JSON cuesta más que todo lo demás junto. Y aquí la forma de validar de cada framework (Ajv compilado en Fastify, Zod en el resto) influye tanto como el propio framework.
- **Hono juega fuera de casa.** Hono está diseñado para los estándares de la web; en Node paga el adaptador que convierte los objetos de `node:http` en `Request`/`Response`. En Bun o en Cloudflare Workers no existe ese peaje.

Dicho de otra forma: el rendimiento es un buen motivo para **no** elegir un framework lento cuando vas a recibir mucho tráfico, pero casi nunca es el motivo principal para elegir entre los cuatro. Para eso están las secciones siguientes.

## Cómo se prueba cada uno

Una diferencia práctica que se nota en el día a día: cómo escribes los tests. Hono y Fastify pueden **inyectar peticiones en memoria**, sin abrir ningún puerto; Express necesita supertest, que levanta un servidor efímero; y NestJS tiene su propio módulo de pruebas que, además, te deja sustituir cualquier dependencia. Estos cuatro tests usan el ejecutor integrado de Node (`node --test`) y pasan tal cual:

```javascript
// hono.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Hono } from 'hono';

const app = new Hono().get('/json', (c) => c.json({ hola: 'mundo' }));

test('Hono: GET /json sin levantar servidor', async () => {
  const res = await app.request('/json'); // un Request de verdad, en memoria
  assert.equal(res.status, 200);
  assert.deepEqual(await res.json(), { hola: 'mundo' });
});
```

```javascript
// fastify.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import Fastify from 'fastify';

const app = Fastify();
app.get('/json', async () => ({ hola: 'mundo' }));

test('Fastify: GET /json con inject()', async () => {
  const res = await app.inject({ method: 'GET', url: '/json' }); // light-my-request
  assert.equal(res.statusCode, 200);
  assert.deepEqual(res.json(), { hola: 'mundo' });
});
```

```javascript
// express.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
import request from 'supertest';

const app = express();
app.get('/json', (req, res) => res.json({ hola: 'mundo' }));

test('Express: GET /json con supertest', async () => {
  const res = await request(app).get('/json'); // abre un puerto efímero
  assert.equal(res.status, 200);
  assert.deepEqual(res.body, { hola: 'mundo' });
});
```

```typescript
// src/tareas.spec.ts
import 'reflect-metadata';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { StandardSchemaValidationPipe } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { TareasController } from './tareas.controller.js';
import { TareasService } from './tareas.service.js';

test('NestJS: POST /tareas valida y crea', async () => {
  const modulo = await Test.createTestingModule({
    controllers: [TareasController],
    providers: [TareasService], // aquí podrías sustituirlo por un doble de pruebas
  }).compile();

  const app = modulo.createNestApplication();
  app.useGlobalPipes(new StandardSchemaValidationPipe());
  await app.init();

  await request(app.getHttpServer()).post('/tareas').send({ titulo: '' }).expect(400);
  const res = await request(app.getHttpServer()).post('/tareas').send({ titulo: 'Probar Nest' }).expect(201);
  assert.deepEqual(res.body, { id: 1, titulo: 'Probar Nest', hecha: false });

  await app.close();
});
```

```bash
# Ejecuta los cuatro (el de Nest, tras compilarlo con tsc)
$ node --test *.test.js dist/tareas.spec.js
✔ NestJS: POST /tareas valida y crea
✔ Express: GET /json con supertest
✔ Fastify: GET /json con inject()
✔ Hono: GET /json sin levantar servidor
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms …
```

## Ventajas y desventajas de cada uno

### Hono

**Ventajas**

- **Funciona en todas partes.** El mismo código corre en Node, Bun, Deno, Cloudflare Workers, Fastly, AWS Lambda, Vercel o Netlify. Ningún otro de los cuatro puede decirlo.
- **Ligerísimo:** `hono` no tiene dependencias y es, con diferencia, el que menos pesa y el que arranca más rápido. Ideal para funciones *serverless* y *edge*, donde el arranque en frío se paga en cada petición.
- **TypeScript de primera:** los parámetros de la ruta, el cuerpo validado y las respuestas se infieren solos. Con su cliente RPC (`hc`), el frontend conoce los tipos de la API sin generar nada.
- **Muy rápido** incluso en Node, a pesar del adaptador: en las rutas sin validación, empatado con Fastify.
- **Muchas utilidades incluidas** (CORS, JWT, ETag, compresión, caché, streaming, JSX para HTML en el servidor) sin instalar nada más.

**Desventajas**

- **Ecosistema más joven:** hay middleware oficial para lo habitual, pero para cosas menos comunes encontrarás menos opciones y menos respuestas en Stack Overflow que para Express.
- **Cero estructura:** como Express, no te dice cómo organizar una aplicación grande. Eso es trabajo tuyo.
- **Los estándares web tienen sus reglas:** el cuerpo de una petición solo se puede leer una vez, algunas cosas de Node (como el socket de la conexión) no están a mano, y parte del middleware de Express no se puede reutilizar.
- **En Node, paga un pequeño peaje** por traducir entre `node:http` y `Request`/`Response`.

### Fastify

**Ventajas**

- **De los más rápidos en Node:** empata con Hono en las rutas sencillas y es el más rápido, con diferencia, cuando hay validación, gracias a que Ajv compila los esquemas a funciones de JavaScript al arrancar.
- **El esquema lo es todo:** una sola definición valida la entrada, convierte tipos, acelera y filtra la salida y genera la documentación OpenAPI con `@fastify/swagger`.
- **Plugins encapsulados:** cada plugin tiene su propio ámbito de hooks y decoradores, lo que permite dividir una aplicación grande sin que las piezas se pisen.
- **Logs de producción de serie** con Pino, en JSON y con identificador de petición.
- **Ecosistema oficial** (`@fastify/*`) mantenido por el mismo equipo: CORS, JWT, cookies, multipart, rate limit, static, websocket…
- **Pruebas cómodas** con `inject()`, sin abrir puertos.

**Desventajas**

- **Más conceptos que aprender:** ciclo de vida con hooks, encapsulación de plugins, `fastify-plugin` para romperla, decoradores, esquemas… La primera semana confunde.
- **JSON Schema es verboso** comparado con Zod. Se puede usar Zod o TypeBox con *type providers*, pero es otra pieza más.
- **Solo Node.**
- **Más estricto:** el `DELETE` con `Content-Type` sin cuerpo, las respuestas que no cumplen el esquema… Es bueno a la larga, pero migrar desde Express saca a la luz cosas que antes "funcionaban".
- **El middleware de Express no funciona directamente**; necesitas `@fastify/express` o reescribirlo como hooks.

### Express 5

**Ventajas**

- **Todo el mundo lo conoce.** Es el framework de los tutoriales, de los cursos y de miles de respuestas. Cualquier persona que se incorpore al equipo lo sabrá usar.
- **El ecosistema más grande:** passport, multer, helmet, morgan, express-session, express-rate-limit… Si algo existe en Node, existe para Express.
- **Simplicidad absoluta:** `(req, res, next)` y poco más. Se aprende en una tarde.
- **Express 5 lo ha modernizado:** errores `async` capturados, `path-to-regexp` 8 (más seguro contra ReDoS), Node 18 o superior y limpieza de APIs antiguas.
- **Estabilidad:** cambia poco y despacio, lo que en muchos proyectos es una virtud.

**Desventajas**

- **El más lento** de los cuatro en todas las rutas de estas pruebas: la cuarta parte que Fastify con Node 20 y la mitad con Node 24.
- **No trae nada:** ni validación, ni logs, ni OpenAPI, ni estructura. Cada proyecto Express acaba siendo distinto del anterior.
- **TypeScript va aparte** (`@types/express`) y no infiere nada: `req.body` es `any` hasta que tú digas lo contrario.
- **Solo Node** (Bun y Deno lo ejecutan por compatibilidad, pero no es su terreno).
- **Migrar de 4 a 5 tiene letra pequeña:** la sintaxis de las rutas con comodines cambió (`/*` pasa a ser `/*splat`), se eliminaron métodos como `res.send(status)` y `req.param()`, y `req.body` ya no es `{}` por defecto.

### NestJS 12

**Ventajas**

- **Arquitectura incluida:** módulos, controladores, servicios e inyección de dependencias. Diez personas escribiendo en el mismo proyecto acaban escribiendo código que se parece.
- **Probar es fácil:** como todo se inyecta, sustituyes la base de datos o un servicio externo por un doble en una línea.
- **Todo el ecosistema "empresarial" oficial:** GraphQL, microservicios (Kafka, NATS, gRPC, Redis, RabbitMQ), colas, tareas programadas, WebSockets, CQRS, Swagger, configuración, salud…
- **La versión 12 moderniza mucho:** paquetes ESM, validación y serialización con Standard Schema (Zod, Valibot, ArkType), CLI reconstruida con Vitest, oxlint y Rspack, logs estructurados, códigos de error legibles por máquina y, desde la 12.1, cookies, CSRF y cabeceras de seguridad integradas.
- **Elige tu motor:** sobre Express para máxima compatibilidad o sobre Fastify para rendimiento, cambiando una línea.

**Desventajas**

- **La curva de aprendizaje más alta:** decoradores, módulos, proveedores, pipes, guards, interceptores, filtros, ámbitos de inyección… Para una API de cinco rutas es matar moscas a cañonazos.
- **Mucho más código y más ficheros** para lo mismo, como has visto arriba.
- **Más pesado:** el que más tarda en arrancar, el que más memoria gasta en reposo y el que más dependencias instala.
- **Sobre Express, el más lento** de todas las combinaciones probadas.
- **Dependes de los decoradores de TypeScript** (los "experimentales", no los del estándar) y de `reflect-metadata`.
- **Las versiones mayores obligan a migrar:** la 12 exige Node 20.19+, cambia NATS, GraphQL y la configuración. `nest upgrade` hace casi todo, pero hay que revisar.

## Cuándo elegir cada uno

![Diagrama de decisión con cuatro preguntas que llevan a Hono, NestJS, Express o Fastify](https://blog.devutilites.com/content/images/2026/09/comparativa-decision.png)

Cuatro preguntas que resuelven la mayoría de los casos. Diagrama: devutilites.

### Elige Hono si…

- Vas a desplegar en **Cloudflare Workers**, Deno Deploy, Vercel Functions, Lambda o cualquier sitio que no sea un Node "de toda la vida".
- Quieres usar **Bun** por su velocidad y no atarte a él.
- Tu frontend es TypeScript y quieres que **conozca los tipos de la API** sin generar código (RPC con `hc`).
- Es un microservicio pequeño, un *webhook*, un proxy o un BFF (el backend de un frontend concreto).
- Ejemplo real: la API de un formulario de contacto o un acortador de enlaces en Workers, que cuesta cero euros al mes.

### Elige Fastify si…

- Es una **API nueva en Node** que va a recibir mucho tráfico.
- Quieres **validación y documentación OpenAPI** que salgan del mismo sitio.
- Te importan los **logs estructurados** desde el primer día.
- Quieres algo más organizado que Express pero sin la ceremonia de NestJS: los plugins encapsulados dan una estructura ligera.
- Ejemplo real: la API pública de un producto SaaS, un servicio de ingesta de eventos, un backend para una app móvil.

### Elige Express si…

- Ya tienes una aplicación Express que **funciona**. Actualiza a la 5 y no la reescribas por moda.
- Dependes de middleware que solo existe para Express (algunos proveedores de autenticación, SDKs antiguos).
- Estás **aprendiendo** backend: todo lo que aprendas con Express te servirá para entender los otros tres.
- Es un prototipo o una herramienta interna con poco tráfico, donde la familiaridad pesa más que todo lo demás.

### Elige NestJS si…

- Sois un **equipo** (digamos, de cuatro personas en adelante) y el proyecto va a vivir años.
- El dominio es complejo: muchas entidades, reglas de negocio, permisos, integraciones.
- Necesitas **microservicios, colas, GraphQL o WebSockets** y los quieres con el mismo estilo que tu API REST.
- Vienes de Angular, Spring o .NET y la inyección de dependencias te resulta natural.
- Y en ese caso, **úsalo con el adaptador de Fastify** salvo que dependas de algo de Express: es gratis en rendimiento.

## Migrar de uno a otro

### De Express a Fastify, poco a poco

No hace falta reescribirlo todo de golpe. El plugin oficial `@fastify/express` permite montar tus routers y middleware de Express dentro de Fastify, y escribir lo nuevo ya al estilo Fastify:

```bash
npm install fastify @fastify/express
```

```javascript
import Fastify from 'fastify';
import fastifyExpress from '@fastify/express';
import express from 'express';

// El router de Express que ya tenías, sin tocar
const legado = express.Router();
legado.get('/usuarios', (req, res) => res.json([{ id: 1, nombre: 'Ada' }]));

const app = Fastify();
await app.register(fastifyExpress); // añade app.use() compatible con Express
app.use('/v1', legado);             // lo viejo sigue funcionando...

// ...y lo nuevo ya se escribe al estilo Fastify
app.get('/v2/usuarios', async () => [{ id: 1, nombre: 'Ada' }]);

await app.listen({ port: 3000 });
```

```bash
$ curl -i localhost:3000/v1/usuarios
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
[{"id":1,"nombre":"Ada"}]

$ curl -i localhost:3000/v2/usuarios
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
[{"id":1,"nombre":"Ada"}]
```

Las rutas que pasan por la capa de Express no disfrutan de las ventajas de Fastify (ni esquemas ni rendimiento), y Node avisa de que el router de Express usa manejadores que no son promesas nativas (un aviso de *deprecation* inofensivo que me apareció al arrancar). Así que es una solución de transición: vas moviendo rutas de `/v1` a `/v2` y, al final, quitas el plugin.

### De Express a Hono

Aquí no hay capa de compatibilidad: el modelo es distinto (se devuelve una `Response` en lugar de escribir en `res`). La buena noticia es que la traducción es casi mecánica:

| Express                                    | Hono                                            |
| ------------------------------------------ | ----------------------------------------------- |
| req.params.id                              | c.req.param('id')                               |
| req.query.q                                | c.req.query('q')                                |
| req.body (con express.json())              | await c.req.json() o c.req.valid('json')        |
| req.get('x-token')                         | c.req.header('x-token')                         |
| res.status(201).json(obj)                  | return c.json(obj, 201)                         |
| res.send('texto')                          | return c.text('texto')                          |
| res.redirect('/login')                     | return c.redirect('/login')                     |
| res.locals.usuario = u                     | c.set('usuario', u) / c.get('usuario')          |
| app.use((req, res, next) => { …; next() }) | app.use(async (c, next) => { …; await next() }) |
| app.use((err, req, res, next) => …)        | app.onError((err, c) => …)                      |
| express.Router() \+ app.use('/x', r)       | new Hono() \+ app.route('/x', r)                |

### De Express o Fastify a NestJS

NestJS se monta encima de ellos, así que el middleware existente se puede reutilizar con `app.use()` o dentro de un módulo con `configure(consumer)`. La migración consiste en ir moviendo la lógica a servicios y las rutas a controladores, módulo a módulo. Y dentro de Nest, **pasar de Express a Fastify** es esto (y revisar el middleware que dependa de `req`/`res` de Express):

```typescript
// Antes: Nest sobre Express (por defecto)
const app = await NestFactory.create(AppModule);

// Después: Nest sobre Fastify
import { FastifyAdapter } from '@nestjs/platform-fastify';
import type { NestFastifyApplication } from '@nestjs/platform-fastify';

const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter());
await app.listen(3000, '0.0.0.0'); // Fastify escucha solo en localhost si no le dices lo contrario
```

## Veredicto

Después de escribir la misma API cuatro veces y medirla en un servidor real, mi resumen es este:

- **Para una API nueva en Node, mi opción por defecto es Fastify.** Empata con Hono en velocidad, es el más rápido cuando hay validación, el que mejor valida y el que trae de serie lo que en producción acabas necesitando (logs, esquemas, OpenAPI). Cuesta un poco más al principio y compensa pronto.
- **Si hay la más mínima posibilidad de salir de Node** (Workers, Bun, Deno, *serverless*), **Hono**, sin dudarlo. Y también es una opción excelente en Node para servicios pequeños y equipos que viven en TypeScript.
- **Para un equipo y un proyecto grande, NestJS 12 sobre Fastify.** La versión 12 le ha quitado mucho peso de encima con Standard Schema y ESM, y el precio en rendimiento frente a Fastify "a pelo" es pequeño.
- **Express, para lo que ya existe y para aprender.** Express 5 es mucho mejor que el 4 y es perfectamente válido; simplemente, en una API nueva los otros tres te dan más por el mismo esfuerzo.

Y recuerda la lección del benchmark: la diferencia entre el más rápido y el más lento existe, pero se mide en fracciones de milisegundo por petición. La elegancia del código, el tamaño del equipo, dónde vas a desplegar y lo que ya sabéis usar pesan mucho más.

## Enlaces

- **Hono:** [hono.dev](https://hono.dev/?ref=blog.devutilites.com) · [npm](https://www.npmjs.com/package/hono?ref=blog.devutilites.com) · [GitHub](https://github.com/honojs/hono?ref=blog.devutilites.com) · `npm create hono@latest` · [Hono a fondo](https://blog.devutilites.com/hono-a-fondo/)
- **Fastify:** [fastify.dev](https://fastify.dev/?ref=blog.devutilites.com) · [npm](https://www.npmjs.com/package/fastify?ref=blog.devutilites.com) · [GitHub](https://github.com/fastify/fastify?ref=blog.devutilites.com) · `npm install fastify` · [Fastify a fondo](https://blog.devutilites.com/fastify-a-fondo/)
- **Express:** [expressjs.com](https://expressjs.com/?ref=blog.devutilites.com) · [npm](https://www.npmjs.com/package/express?ref=blog.devutilites.com) · [GitHub](https://github.com/expressjs/express?ref=blog.devutilites.com) · `npm install express` · [Express a fondo](https://blog.devutilites.com/express-a-fondo/)
- **NestJS:** [nestjs.com](https://nestjs.com/?ref=blog.devutilites.com) · [documentación](https://docs.nestjs.com/?ref=blog.devutilites.com) · [npm](https://www.npmjs.com/package/@nestjs/core?ref=blog.devutilites.com) · [GitHub](https://github.com/nestjs/nest?ref=blog.devutilites.com) · `npm i -g @nestjs/cli && nest new mi-api` · [NestJS 12 a fondo](https://blog.devutilites.com/nestjs-12-a-fondo/)
- **Herramienta de las pruebas:** [autocannon](https://github.com/mcollina/autocannon?ref=blog.devutilites.com).