Hono, Fastify, Express o NestJS 12: comparativa a fondo con pruebas reales

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.

Portada: Hono, Fastify, Express o NestJS 12: comparativa a fondo con pruebas reales

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, Fastify a fondo, Express a fondo y 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
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:

FrameworkVersiónWeb oficialnpmCódigo fuente
Hono4.13.11hono.devnpmjs.com/package/honogithub.com/honojs/hono
Fastify5.12.5fastify.devnpmjs.com/package/fastifygithub.com/fastify/fastify
Express5.2.1expressjs.comnpmjs.com/package/expressgithub.com/expressjs/express
NestJS12.1.1nestjs.com · docsnpmjs.com/package/@nestjs/coregithub.com/nestjs/nest

Y los comandos para empezar un proyecto con cada uno:

# 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

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

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

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:

// 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:

// 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:

// 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:

// 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 {}
// 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();
{
  "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):

#!/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 4Fastify 5Express 5NestJS 12
Versión probada4.13.115.12.55.2.112.1.1
Primera versión2021201620102017
LicenciaMITMITMITMIT
Descargas npm (última semana)73.156.54015.716.294155.840.03017.002.306 (@nestjs/core)
Estrellas en GitHub32.38637.21069.49876.756
RuntimesNode, Bun, Deno, Cloudflare Workers, Fastly, Lambda, Vercel, Netlify…NodeNode (Bun y Deno por compatibilidad)Node (sobre Express o Fastify)
Modelo de peticiónRequest/Response estándar de la webObjetos propios sobre node:httpreq/res de node:http ampliadosLos del adaptador, pero rara vez los tocas
EstiloFuncional, encadenado, minimalistaRutas con esquema, hooks y plugins encapsuladosMiddleware en cadena, libertad totalClases, decoradores, módulos (a lo Angular / Spring)
TypeScriptNativo; inferencia de rutas, params y validadoresTipos incluidos; type providers para inferir desde el esquemaTipos aparte (@types/express)Obligatorio en la práctica; todo gira en torno a él
ValidaciónMiddleware validator o @hono/zod-validator, Valibot, etc.JSON Schema integrado (Ajv), compilado al arrancarNinguna: la añades tú (Zod, Joi, express-validator…)ValidationPipe con class-validator o, desde la 12, Standard Schema (Zod, Valibot, ArkType)
SerializaciónJSON.stringifyfast-json-stringify si defines el esquema de respuesta (y filtra campos)JSON.stringifyInterceptores de serialización (clases o Standard Schema)
OpenAPI@hono/zod-openapi@fastify/swagger desde los esquemas que ya tienesswagger-jsdoc, tsoa u otros, a mano@nestjs/swagger, desde decoradores y esquemas
Inyección de dependenciasNo (variables de contexto c.set/c.get)No (decoradores de instancia decorate)No (req/res.locals)Sí, el corazón del framework
Cliente tipadoSí: RPC con hcNo integradoNoNo integrado (sí vía OpenAPI)
WebSocketsHelper upgradeWebSocket por runtime@fastify/websocketws o Socket.IO aparte@nestjs/websockets (Socket.IO o ws)
Pruebasapp.request() en memoriaapp.inject() en memoriasupertest@nestjs/testing + supertest, con sustitución de proveedores
LogsMiddleware logger sencilloPino integrado, JSON, muy rápidoNinguno (morgan, pino-http…)ConsoleLogger con JSON y parámetros estructurados
Extras de fábricaCORS, JWT, ETag, compresión, caché, JSX, streaming…Ecosistema @fastify/* oficial: CORS, JWT, multipart, rate limit…Muy pocos; ecosistema enorme de tercerosGraphQL, microservicios, colas, CQRS, cron, CSRF y cookies (12.1)…
Curva de aprendizajeBajaMediaMuy bajaAlta
Rendimiento en esta pruebaMuy alto (empata con Fastify en GET)Muy alto (el mejor con validación)El más bajoSobre 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:

// 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
Peticiones por segundo en cada ruta. Mismo servidor, un framework cada vez. Diagrama: devutilites.
ServidorGET / (texto)GET /jsonGET /users/:idPOST /echoLatencia media /jsonp99 /json
Hono19.21617.97616.81410.9435,07 ms11 ms
Fastify18.02617.91017.95813.1205,09 ms12 ms
Express4.2774.2344.2483.52523,08 ms43 ms
NestJS + Express3.9583.9473.6823.11924,81 ms48 ms
NestJS + Fastify13.67114.48713.4239.6046,41 ms13 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
Lo que casi nunca sale en las comparativas: arranque, memoria y peso. Diagrama: devutilites.
ServidorArranqueRSS en reposoRSS pico bajo carganode_modulesPaquetes instalados
Hono302 ms62 MB79,1 MB1,4 MB2
Fastify323 ms68,7 MB82 MB7,9 MB49
Express257 ms64,6 MB137,8 MB2,2 MB68
NestJS + Express802 ms101,8 MB181,2 MB9,3 MB98
NestJS + Fastify933 ms106,7 MB108,4 MB14,9 MB76

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
El mismo código con dos versiones de Node. Diagrama: devutilites.
ServidorGET /json · Node 20GET /json · Node 24DiferenciaPOST /echo · Node 20POST /echo · Node 24
Hono17.97618.030+0 %10.94311.054
Fastify17.91019.010+6 %13.12012.705
Express4.2349.868+133 %3.5257.690
NestJS + Express3.9478.824+124 %3.1195.855
NestJS + Fastify14.48716.229+12 %9.6049.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:

// 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 20Node 24
Sin cambiar prototipos23.96324.110
Con Object.setPrototypeOf (como Express)10.35620.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:

// 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' });
});
// 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' });
});
// 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' });
});
// 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();
});
# 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
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:

npm install fastify @fastify/express
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 });
$ 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:

ExpressHono
req.params.idc.req.param('id')
req.query.qc.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 = uc.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):

// 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