Hono frente a NestJS: dos plantillas de producción con el mismo contrato y números reales

Hono y NestJS con la misma API, el mismo esquema Prisma y 95 pruebas HTTP idénticas. Mido framework y plantilla completa, Docker, Trivy, npm audit y Bun, antes y después de corregir ambas en dos PR. Nest rinde más en la plantilla y Hono en el núcleo: por qué, y qué no pude probar.

Portada: Hono frente a NestJS: dos plantillas de producción con el mismo contrato y números reales

Ya tienes en el blog mi plantilla de Hono + Prisma para APIs en producción y la de NestJS. Las dos hacen lo mismo: la misma API de tareas con usuarios, el mismo contrato HTTP, el mismo esquema de base de datos con Prisma 7. Y eso es exactamente lo que necesitaba para contestar con datos una pregunta que me hacen mucho: ¿Hono o NestJS?

Ya hice una comparativa de cuatro frameworks (Hono, Fastify, Express y NestJS) con servidores mínimos. Era útil, pero un «hola mundo» no te dice cómo se comporta un framework cuando tiene que servir autenticación con sesiones, 2FA, recuperación de contraseña, rate limiting en base de datos y 25 rutas documentadas. Aquí la comparación es entre dos aplicaciones reales y equivalentes, y además mido por separado el framework solo para no atribuirle a uno lo que en realidad es culpa (o mérito) de otra cosa.

Te adelanto la conclusión que menos esperaba: en las plantillas completas, la de NestJS salió más rápida que la de Hono, aunque el núcleo de Hono rinde casi el doble que el de Nest cuando lo mido a solas. Más abajo verás por qué, qué parte de la explicación está medida y qué parte es solo una hipótesis.

🧪
Cómo lo he probado. Hice dos pasadas, y en cada cifra te digo de cuál es. Primera pasada: 24 de octubre de 2026 (hora de Madrid), sobre main: hono-prisma-production-starter en el commit 644a5d2 y nestjs-prisma-production-starter en el commit 758a0ac. Segunda pasada: 25 de octubre de 2026, tras corregir lo que encontré, sobre la rama fix/production-hardening de cada repositorio: Hono fc44204 (PR #23) y Nest 623e447 (PR #8); fusioné los dos PR esa misma mañana, después de esta segunda pasada (Hono a las 12:54 y Nest a las 12:55, hora de Madrid); el árbol de Git de main es idéntico al de cada rama que medí (lo comprobé comparando los tree de los commits), así que las cifras de la segunda pasada describen el main de hoy. Misma máquina Linux de 8 núcleos, con Node 24.21.0 en el host. Versiones reales en la segunda pasada: Hono 4.13.14 con @hono/node-server 2.1.5 y @hono/zod-openapi 1.6.3; NestJS 12.2.0 sobre Express 5.3.0; Prisma 7.10.1 en las dos; Zod 4.6.7 en las dos; TypeScript 7.0.2 (Hono) y 6.0.3 (Nest). En la primera pasada eran Hono 4.13.10, Prisma 7.10.0 (Hono) y Zod 4.6.5 (Hono). Docker 26.1.5 y Trivy 0.76.1. Las salidas de terminal son reales (recortadas cuando eran largas). Todo lo que no he podido comprobar está marcado como «sin probar», y lo que es una explicación mía sin medir, como «hipótesis».
⚠️
Actualización del 25 de octubre de 2026: qué ha cambiado desde la primera versión de este artículo. La primera pasada encontró problemas reales en las dos plantillas: avisos de npm audit, 38 hallazgos de Trivy en la imagen de Hono, una imagen de Hono que no arrancaba con --read-only, Node 20 (sin soporte) en Hono y Node 25 (sin LTS) en Nest, y otros detalles. Los corregí en dos PR: Hono #23 y NestJS #8. Los fusioné el 25 de octubre (12:54 Hono, 12:55 Nest, hora de Madrid) y main ya contiene las correcciones. Por eso verás dos cifras en muchos sitios: «antes» (main el 24 de octubre, antes de la fusión: Hono 644a5d2, Nest 758a0ac; donde el texto dice «en main» para una cifra «antes», se refiere a ese estado) y «después» (la rama del PR, 25 de octubre, hoy igual a main). Donde no repetí una medida, lo digo y conservo la fecha. Dos cosas que no hice: los cambios de CI (el token que usé no tenía permiso para tocar .github/workflows, así que ningún flujo de GitHub Actions se ha ejecutado con estos cambios) y portar el hash de Hono a Argon2.
⚠️
Lo que debes saber de las cifras de rendimiento. Generador de carga y servidor comparten máquina, y la máquina no es mía en exclusiva: la carga era baja (alrededor de 0,2) pero no cero. Por eso son cifras orientativas: sirven para comparar entre sí lo que medí con el mismo método, no para prometer nada en tu servidor. Repetí cada prueba 3 veces, alternando una plantilla y la otra en cada ronda, y doy la mediana (y el rango cuando importa).

Las dos plantillas, en una tabla

Antes de comparar, conviene dejar claro qué es igual para saber que lo que mido después es la diferencia real y no un efecto de que hagan cosas distintas.

Hono (plantilla) NestJS (plantilla)
Framework Hono 4.13.10 sobre @hono/node-server NestJS 12.2.0 sobre Express 5.3.0
Validación y OpenAPI @hono/zod-openapi: el esquema Zod es la ruta nestjs-zod + @nestjs/swagger
Rutas en el OpenAPI 25 rutas / 31 operaciones 25 rutas / 31 operaciones
Versión del documento OpenAPI 3.0.3 3.0.0
Hash de contraseñas bcryptjs (12 rondas, JavaScript puro) Argon2id (@node-rs/argon2, binario nativo)
Tests Vitest: 148 pruebas en 18 archivos (en main: 144 en 17) Jest: 84 pruebas en 13 suites (en main: 62 en 9)
Modos de auth none, basic, full none, basic, full
Esquema de Prisma idéntico idéntico, salvo una tabla más (EmailOutbox)
Líneas de TypeScript en src/ (sin el cliente generado) 5.303 en 36 archivos (en main: 5.281 en 35) 6.622 en 76 archivos (en main: 6.579 en 75)

Dos de esas filas las he comprobado de forma mecánica, no a ojo (el 24 de octubre sobre main; las ramas de los PR no tocan prisma/: lo comprobé con un diff contra main, y siguen pasando los 95 escenarios de contrato, pero no volví a descargar el /openapi.json). Primero, descargué el /openapi.json de cada servidor arrancado: los dos documentos tienen 25 rutas y 31 operaciones. Y segundo, hice un diff de los dos schema.prisma:

$ diff prisma/schema.prisma  ../nestjs-prisma-production-starter/prisma/schema.prisma
33a34
>   emailOutbox   EmailOutbox[]
52a54,78
> }
>
> /// Durable outbox for account emails. A request only persists an intent (together with its
> /// audit entry); a worker delivers it with bounded retries, so a crash or an SMTP outage
> ...
$ diff <(ls hono/prisma/migrations) <(ls nest/prisma/migrations)
9a10
> 20261023090922_email_outbox

Las dos plantillas comparten todo el modelo de datos salvo la tabla de la cola de correos (EmailOutbox) y su migración. Esto tiene una consecuencia práctica enorme que retomo en la sección de migración: cambiar de una a otra no toca la base de datos.

La prueba que más me ha sorprendido: el mismo contrato, 95 escenarios, 0 fallos en las dos

El repositorio de NestJS incluye un arnés de pruebas HTTP «de caja negra» (contract/run.mjs) que repite los mismos escenarios contra cualquier servidor que le indiques: no sabe si al otro lado hay Hono, Nest o lo que sea. Se diseñó para comprobar que el port a NestJS se comportaba como la plantilla original de Hono, y lo lancé contra las dos, cada una con su propia base de datos, en los tres modos de autenticación. La salida y los números de abajo son de la segunda pasada (ramas de los PR, 25 de octubre); en la primera, sobre main, el resultado fue el mismo: 95, 76 y 32 escenarios, 0 fallos en las dos.

$ node contract/run.mjs --base http://127.0.0.1:3911 --mode full --db h-full.db   # servidor Hono
  ✓ system (13 tests)      ✓ recovery (11 tests)
  ✓ mounting (6 tests)     ✓ mfa (10 tests)
  ✓ auth (14 tests)        ✓ ratelimit (5 tests)
  ✓ sessions (8 tests)     ✓ ratelimit-login (4 tests)
  ✓ tasks (16 tests)       ✓ users (14 tests)    ✓ audit (5 tests)

contract: 95 tests, 0 failing (26561ms, mode=full)
Modo Escenarios Hono NestJS Tiempo Hono Tiempo Nest
full 95 0 fallos 0 fallos 26,6 s (antes, 26,7) 7,2 s (antes, 7,3)
basic 76 0 fallos 0 fallos 13,5 s (antes, 13,1) 2,0 s (antes, 1,8)
none 32 0 fallos 0 fallos 0,25 s (antes, 0,25) 0,22 s (antes, 0,21)

Esa última columna ya adelanta algo: en el modo sin usuarios (none) los dos tardan lo mismo; en los modos con usuarios, Hono tarda entre 3,7 y 7 veces más. Es el coste de hacer login con bcryptjs, y lo mido de forma directa más abajo.

El arnés también puede volcar una transcripción normalizada de cada petición y respuesta, así que fui un paso más allá y comparé las dos transcripciones, petición a petición (normalizando los UUID de las rutas, que cambian en cada ejecución). Esta comparación es de la primera pasada (24 de octubre, main): no repetí el volcado con las ramas de los PR (sí el arnés, que pasa entero):

Modo Peticiones Mismo código HTTP Cuerpo idéntico
none 71 71 65
basic 279 279 216
full 360 360 274

En las 710 peticiones, las dos plantillas devuelven el mismo código HTTP. Las diferencias de cuerpo son pocas y de tres tipos: datos que cambian en cada ejecución (correos de prueba aleatorios, códigos de recuperación), textos como el nombre y el título del documento OpenAPI, y tres diferencias reales que me parecen interesantes:

  1. La IP. Hono guarda ::ffff:127.0.0.1 (la forma «IPv4 mapeada en IPv6») y Nest guarda 127.0.0.1. No he investigado de dónde sale la diferencia (probablemente de cómo cada servidor lee la dirección del socket). Es cosmético, pero si guardas IP en tus auditorías y mezclas servicios, te toparás con las dos formas.
  2. El detalle de las auditorías. En los eventos LOGIN_FAILED y DELETE_PERMANENT, Hono guarda el correo en claro y Nest guarda en su lugar un identificador opaco de 32 caracteres hexadecimales (el correo no aparece):
hono  LOGIN_FAILED     {"email":"[email protected]"}
nest  LOGIN_FAILED     {"account":"f2a17a4412b0499d02ea85eec653d736"}
hono  DELETE_PERMANENT {"email":"[email protected]"}
nest  DELETE_PERMANENT {"account":"1d1c5b45504c9441edd640fa292cd5e3"}

Es una decisión de privacidad (que el registro de auditoría no guarde correos de personas) que solo toma la plantilla de Nest. 3. Un error 400. Para un cuerpo mal formado en el login, Hono responde Malformed JSON in request body y Nest Validation error in request payload (con una lista errors por campo). El código es el mismo, el texto no.

💡
Este es el mejor argumento a favor de migrar entre las dos si algún día lo necesitas: un cliente que hable con una no nota que ha cambiado a la otra. Si el contrato HTTP lo ejecutas como pruebas que no dependen del framework (como hace este arnés), cambiar de framework deja de dar miedo.

Dos filosofías: composición de funciones o estructura impuesta

Las diferencias de fondo vienen de aquí, y casi todo lo demás es consecuencia.

Hono parte de la idea de que una aplicación web es una función: recibe una Request estándar (la misma de fetch) y devuelve una Response. Alrededor de esa función se encadenan middlewares con app.use(...), y las rutas se registran con métodos como app.get. No hay contenedor de dependencias, no hay decoradores, no hay módulos: importas lo que necesitas y lo usas. Lo cuento en detalle en Hono a fondo.

NestJS parte de la idea contraria: una aplicación grande necesita estructura para que muchas personas la toquen sin pisarse. Por eso te da módulos, controladores, proveedores con inyección de dependencias, guards, pipes, interceptores y filtros, cada uno con su sitio y su orden de ejecución. Lo cuento en NestJS 12 a fondo y, aplicado a esta plantilla, en su artículo.

Mejor que describirlo es verlo. Esta es la misma ruta (crear una tarea) en las dos plantillas, con el código real de cada repositorio:

// Hono · src/routes/tasks.ts
const createTaskRoute = createRoute({
  method: "post",
  path: "/",
  tags: ["Tasks"],
  summary: "Crear tarea",
  security,
  request: jsonBody(createTaskSchema),
  responses: {
    201: jsonResponse(successSchema(taskSchema), "Tarea creada"),
    ...errorResponses({ 400: "Error de validación", ...authErrors }),
  },
});

// … y más abajo, encadenada al router:
.openapi(createTaskRoute, async (c) => {
  const { title, description, completed } = c.req.valid("json");
  const { userId } = viewerOf(c);

  const newTask = await prisma.task.create({
    data: { userId, title, description, completed },
  });

  await recordAudit(c, { userId, action: "CREATE", entity: "Task",
    entityId: newTask.id, details: { title: newTask.title } });

  return successResponse(c, newTask, { status: 201, message: "Task created successfully." });
});
// NestJS · src/tasks/tasks.controller.ts
@Post()
@ApiOperation({ summary: "Crear tarea", description: "…" })
@ApiCreatedResponse({ description: "Tarea creada" })
@ApiBadRequestResponse({ description: "Error de validación" })
@ApiUnauthorizedResponse({ description: "Token ausente, inválido o sesión revocada" })
async create(@Body() dto: CreateTaskRequest, @Req() req: Request) {
  const { title, description, completed } = dto;
  const { userId } = this.viewerOf(req);

  const newTask = await this.prisma.task.create({
    data: { userId, title, description, completed },
  });

  await this.audit.record(req, { userId, action: "CREATE", entity: "Task",
    entityId: newTask.id, details: { title: newTask.title } });

  return { message: "Task created successfully.", data: newTask };
}

// src/tasks/dto/task.dtos.ts (7 líneas)
export class CreateTaskRequest extends createZodDto(createTaskSchema) {}

El cuerpo del handler es casi idéntico: prisma.task.create, auditoría, respuesta. Lo que cambia es el cableado a su alrededor, y eso es lo que resume este diagrama:

La misma ruta POST /api/tasks montada en Hono (composición de funciones) y en NestJS (módulos, decoradores e inyección)
La misma ruta POST /api/tasks montada en Hono (composición de funciones) y en NestJS (módulos, decoradores e inyección)

Hay tres diferencias que se ven en el código:

  1. Dónde vive el esquema. En Hono, la propia ruta declara el esquema de la petición y el de cada respuesta (successSchema(taskSchema)), y de ahí salen la validación, los tipos de c.req.valid("json") y el OpenAPI. En Nest, el esquema de la petición se convierte en una clase (createZodDto) para que el @Body() la reconozca, pero las respuestas se describen con decoradores (@ApiCreatedResponse({ description })) que, en esta plantilla, llevan solo una descripción de texto, no un esquema.
  2. Cómo llega Prisma al código. En Hono, prisma es un módulo que se importa. En Nest, PrismaService es un proveedor que el contenedor entrega por constructor.
  3. Cuánto ocupa la ruta. tasks.ts de Hono tiene 443 líneas (seis rutas con sus esquemas de documentación y sus handlers); el controlador de Nest tiene 400, más 7 de DTO y 11 del módulo (tasks.module.ts). Prácticamente empate; no hay un framework que te ahorre código de forma significativa en este recurso.
💡
Si vienes de Express, el modelo mental de Hono es el que ya conoces: una cadena de funciones. Si vienes de Angular, Spring o .NET, el de Nest es el tuyo: clases, decoradores, contenedor de inyección. La mayor parte del «cuál es mejor» que se discute en internet es, en realidad, «cuál se parece más a lo que ya sabía».

Inyección de dependencias frente a importar módulos

Esta es la diferencia que más pesa en el día a día y la que más se discute. Dejo los hechos antes que mi opinión.

En Hono no hay contenedor. Un servicio es una función o un módulo que importas:

import { prisma } from "../db.js";
import { recordAudit } from "../lib/audit.js";

Es simple, sin magia, y se sigue con «ir a la definición». El coste: las dependencias están cableadas por import. Si quieres sustituir prisma en una prueba, necesitas sustituir el módulo (con vi.mock) o, como hace esta plantilla, no sustituirlo: sus pruebas no tienen ningún vi.mock; usan una base de datos SQLite temporal real (con las migraciones aplicadas) y llaman a la aplicación en memoria con app.request("/api/tasks", …), sin abrir ningún puerto.

En Nest, cada clase declara lo que necesita en el constructor y el contenedor lo entrega:

@Controller("api/tasks")
export class TasksController {
  constructor(
    private readonly prisma: PrismaService,
    private readonly audit: AuditService,
    @Inject(APP_FEATURES) private readonly features: Features,
  ) {}

Lo bueno: las dependencias son explícitas, se pueden reemplazar en las pruebas (Test.createTestingModule({ providers: […] }); esta plantilla no lo necesita: ninguna de sus pruebas sustituye un proveedor) y el módulo documenta qué usa cada cosa. Lo malo: más ceremonia, un concepto más que aprender (proveedores, ámbitos, tokens de inyección) y errores de configuración que solo aparecen al arrancar (Nest can't resolve dependencies of…).

Mi lectura, sin rodeos: la DI de Nest merece la pena cuando hay mucha gente y muchos módulos; en una API de un puñado de recursos es sobre todo ceremonia. La plantilla de Nest lo demuestra: 76 archivos para lo que Hono resuelve en 36, con el mismo contrato (75 y 35 en main). Y a la vez, esos 75 archivos son más fáciles de repartir entre cinco personas que un index.ts con la cadena de middlewares de otro. No hay respuesta universal.

Validación y OpenAPI: una declaración o dos

Las dos plantillas validan con Zod 4 y publican su documentación con Scalar (en /docs) sobre un /openapi.json. La diferencia está en cómo llegan ahí.

Hono NestJS
Fuente de verdad El esquema Zod de la ruta (createRoute) El esquema Zod, más decoradores @Api…
Validación de la entrada c.req.valid("json") (ya tipado) ContractValidationPipe sobre el DTO
Respuestas en el OpenAPI Con esquema (successSchema(taskSchema)) Con descripción de texto (en esta plantilla)
Cliente tipado para el front Sí: hc<AppType> (3 pruebas lo vigilan) No incluido
Documento resultante 25 rutas / 31 operaciones 25 rutas / 31 operaciones

Lo que más me gusta de Hono aquí es que el OpenAPI no puede mentir sobre la entrada: la ruta que valida es la misma que se documenta, porque es el mismo objeto. En Nest, la validación (el pipe con Zod) y la documentación (los decoradores de Swagger) son dos cosas que hay que mantener en paralelo; si cambias una y olvidas la otra, el documento queda desfasado sin que nada falle.

Y la ventaja que no tiene equivalente directo en Nest es el cliente tipado: como AppType lleva los esquemas de todas las rutas, un front en TypeScript puede llamar a la API con autocompletado y comprobación de tipos, sin generar código:

// En el front (idea; la plantilla exporta AppType y lo vigila con tests/rpc.test.ts)
import { hc } from "hono/client";
import type { AppType } from "api/src/index";

const client = hc<AppType>("https://api.ejemplo.com");
const res = await client.api.tasks.$post({ json: { title: "Comprar pan" } });
⚠️
Ese fragmento del cliente es ilustrativo: no lo he ejecutado en un front real. Lo que sí he comprobado es que la plantilla exporta AppType y que npm run typecheck compila el archivo tests/rpc.test.ts, que usa hc<AppType> y fallaría al compilar si el tipo dejase de llevar los esquemas de las rutas.

Con Nest, la alternativa habitual es generar un cliente a partir del /openapi.json con herramientas externas. Funciona, pero como las respuestas en esta plantilla solo llevan descripción y no esquema, los tipos de salida de ese cliente generado serían pobres. No lo he probado: es una consecuencia de lo que contiene el documento, no una medida.

Seguridad y autenticación: lo que comparten y lo que no

El arnés de contrato de antes cubre, en los dos servidores, el registro y el login, las sesiones revocables, la propiedad de las tareas (una tarea ajena responde como si no existiera), el CRUD de usuarios con roles, la auditoría, la recuperación de contraseña, el 2FA con TOTP y el rate limiting. Que pasen los mismos 95 escenarios es la mejor prueba de paridad que he podido hacer.

Las diferencias que sí encontré, leyendo el código y ejecutándolo:

Hono NestJS
Hash de contraseña bcryptjs, 12 rondas Argon2id (@node-rs/argon2)
Login medido (mediana, en caliente) 288 ms 33 ms (23 ms el 24 de octubre)
Cola de correos durable (outbox) No Sí (tabla EmailOutbox, reintentos con espera)
Auditoría Guarda el correo en claro Guarda un identificador opaco
Autenticación de rutas authMiddleware aplicado a cuatro prefijos Guard global «denegar por defecto» + AuthPrefixMiddleware
Cabeceras de seguridad secureHeaders() de Hono helmet

Dos matices importantes sobre esa tabla:

  • El hash es, con diferencia, lo que más separa a las dos plantillas en tiempo de ejecución, y no es culpa de ningún framework: bcryptjs está escrito en JavaScript puro, así que el cálculo gasta CPU del propio hilo de Node (la plantilla usa la versión asíncrona, que reparte el trabajo en trozos, pero el tiempo sigue siendo el de ese mismo hilo); Argon2id va en un módulo nativo. Cualquier plantilla de Hono podría usar Argon2 y cualquier de Nest, bcrypt. Lo cuento en la sección de medidas porque cambia por completo la lectura de los números.
  • El enfoque de autenticación es distinto en filosofía: en Hono, proteges lo que enumeras (cuatro prefijos); en Nest, el guard global protege todo y marcas lo público con @Public(). El primero es más fácil de leer; el segundo falla en el lado seguro si olvidas algo. Con el arnés comprobé que las dos devuelven 401 donde toca; no he intentado forzar un olvido en ninguna.
💡
Si añades rutas con frecuencia y quien las añade no es siempre la misma persona, el «denegar por defecto» de Nest es un seguro de verdad. En Hono lo puedes conseguir con un app.use("*", authMiddleware) y una lista de excepciones; simplemente, la plantilla no lo hace así.

Pruebas: dos enfoques y un tiempo muy distinto

Hono NestJS
Ejecutor Vitest 5.0.2 Jest 30.5.2
Resultado 148 pruebas en 18 archivos, todas verdes (144 en 17 en main) 84 pruebas en 13 suites, todas verdes (62 en 9 en main)
Duración ≈82 s (81,84 s en la última pasada; 79,99 s en main) ≈11,8 s (11,79 s; 8,5 s en main, con 22 pruebas menos)
Cómo llaman a la app app.request(...) en memoria supertest sobre un servidor HTTP real
Base de datos SQLite temporal real SQLite temporal real (en main respetaba un DATABASE_URL exportado; el PR #8 lo fuerza a un archivo temporal)
Dobles de prueba ninguno (vi.mock no aparece) ninguno (overrideProvider no aparece)
Peticiones de Bruno 32 archivos .bru 32 archivos .bru
# NestJS (rama del PR #8)
Test Suites: 13 passed, 13 total
Tests:       84 passed, 84 total
Time:        11.792 s

# Hono (rama del PR #23)
 Test Files  18 passed (18)
      Tests  148 passed (148)
   Duration  81.84s (tests 94%, import 5%, transform 1%)

No son comparables en número: la plantilla de Hono tiene casi el doble de pruebas (148 frente a 84), pero esas cifras dependen de cómo cuente cada autor (una prueba que comprueba muchas cosas cuenta como una). Lo que sí cuenta es lo que cubren, y eso lo da el arnés de contrato compartido.

Lo más llamativo es el tiempo. Pasé la suite de Hono con el informe en JSON para ver qué archivos tardan (medido el 24 de octubre sobre main; no repetí este desglose con la rama del PR, pero la suma total sigue en torno a los 80 s):

account-security.test.ts      28.7 s  (26 pruebas)
users.test.ts                 13.7 s  (13)
security.test.ts               8.8 s  (18)
hardening.test.ts              6.3 s  (8)
account-security-hardening     5.7 s  (9)
health.test.ts                 0.01 s (4)

Los archivos lentos parecen ser los que más usuarios y sesiones crean (no he contado las llamadas una a una). Es coherente con la hipótesis del hash (cada bcryptjs de 12 rondas ronda los 280–290 ms que medí en el login), pero no lo he demostrado, por ejemplo, cambiando el número de rondas en las pruebas.

⚠️
Un tropiezo que debo contarte. Una de mis pasadas de la suite de Hono dio 32 fallos y no he sabido reproducirla ni explicarla: las tres siguientes, y la última con el informe JSON, pasaron las 144 en limpio. Sí identifiqué otra causa de fallos que era mía: tenía variables de entorno exportadas en mi shell (JWT_SECRET y similares) y eso hizo fallar 3 pruebas hasta que las borré. Las pruebas de Hono no necesitan ningún secreto exportado. Lo apunto porque una suite que dependa del entorno de quien la ejecuta es un riesgo, y porque no quiero esconder el dato raro. Y en el lado de Nest, en main había el mismo tipo de trampa: su setup-env.ts respetaba un DATABASE_URL exportado y la suite escribía en esa base (con una base ya usada fallaban 21 de 62). El PR #8 lo corrige (fuerza una base SQLite temporal) y lo fija con una prueba; con las tres variables de entorno relevantes exportadas, las 84 pasan.

Una diferencia de enfoque que me gusta: Hono prueba sin abrir puertos (app.request llama directamente a la función), de modo que no hay problemas de puertos ocupados. Nest levanta un servidor real con supertest, que es más fiel al despliegue pero más lento de arrancar. En la práctica, con estas dos suites, la diferencia de tiempo (82 s frente a 12 s) no la explica eso, sino, con toda probabilidad, el hash (hipótesis).

Rendimiento: dos pruebas distintas para no engañarnos

Aquí está el riesgo de este tipo de artículos: medir la plantilla completa y concluir «el framework X es más rápido». Para evitarlo hice dos pruebas con el mismo método:

  1. El framework solo (hello world): un servidor mínimo por framework que responde GET / con un JSON fijo. Hono con @hono/node-server, Express 5.3.0 y NestJS 12.2.0 (un controlador, sin Prisma ni auth).
  2. La plantilla completa: cada plantilla compilada (dist/), con su base de datos SQLite fresca, sus middlewares, su autenticación y su Prisma.

Método

  • Una máquina Linux de 8 núcleos, Node 24.21.0, generador y servidor en la misma máquina (por eso las cifras son orientativas). La prueba del framework solo es del 24 de octubre y no la repetí: no depende de las plantillas, que es justo lo que cambió. La de las plantillas completas la repetí el 25 de octubre sobre las ramas de los PR (Nest 623e447, Hono fc44204), con el mismo script y el mismo método; entre paréntesis dejo los valores del 24 de octubre sobre main.
  • autocannon con 20 conexiones durante 8 segundos, tres rondas. En las plantillas, las rondas se alternan (Nest, Hono, Nest, Hono…) para que, si la máquina compartida se carga a ratos, el ruido afecte a las dos por igual.
  • Cada ronda arranca el servidor desde cero con una base de datos nueva (prisma migrate deploy antes de medir), NODE_ENV=development, LOG_LEVEL=error y el límite de intentos de login subido a 1.000 para que el propio rate limiting no distorsione la prueba de carga.
  • Mido: tiempo hasta que /healthz responde 200, memoria residente (RSS) 3 s después, el primer login (frío) y la mediana de 8 logins en caliente, peticiones por segundo en GET /healthz y en GET /api/tasks (con token), memoria tras la carga y el tiempo de cierre con SIGTERM.
  • En GET /api/tasks, cero respuestas distintas de 2xx en las seis rondas, en las dos pasadas.
$ node bench.mjs nest ../nestjs-prisma-production-starter dist/main.js     # ronda 2 del 25 de octubre
{"name":"nest","startMs":883,"idleMiB":188,"coldLoginMs":148,"warmLoginMedianMs":28,"healthzRps":6011,
 "healthzMs":2.81,"tasksRps":904,"tasksMs":21.59,"tasks2xx":7232,"tasksNon2xx":0,"afterLoadMiB":439}
SIGTERM→exit ms: 43
$ node bench.mjs hono ../hono-prisma-production-starter dist/index.js
{"name":"hono","startMs":642,"idleMiB":153,"coldLoginMs":300,"warmLoginMedianMs":283,"healthzRps":4174,
 "healthzMs":4.3,"tasksRps":742,"tasksMs":26.4,"tasks2xx":5935,"tasksNon2xx":0,"afterLoadMiB":384}
SIGTERM→exit ms: 28

Resultado 1: el framework solo (24 de octubre, no repetido)

Servidor mínimo Peticiones por segundo (mediana) Rango de las 3 rondas Arranque RSS en reposo RSS tras carga
Hono + @hono/node-server 78.040 76.080 – 80.424 78 – 89 ms 62 MiB 85 – 89 MiB
Express 5.3.0 42.904 40.301 – 45.416 96 – 116 ms 67 MiB 89 – 91 MiB
NestJS 12.2.0 40.628 40.032 – 43.104 261 – 311 ms 99 – 102 MiB 133 MiB

El núcleo de Hono rinde entre 1,8 y 1,9 veces lo que Express y Nest en este hello world. Dos comprobaciones de sentido común:

  • Coincide con mi comparativa anterior. En la de cuatro frameworks con Node 24, Hono (≈18.000 peticiones por segundo) rendía 1,8 veces lo de Express (≈9.900) y NestJS sobre Express quedaba cerca del 89 % de Express. Aquí: 1,8 veces, y Nest en el 95 % de Express. Las cifras absolutas son distintas (aquella prueba usaba contenedores, 100 conexiones y otras rutas; esta, procesos locales y 20 conexiones), pero las proporciones se repiten.
  • autocannon tiene un techo. Con latencias de 0,01–0,06 ms es muy posible que el generador, que es de un solo hilo, esté limitando los números de Hono. Es decir, el 78.000 de Hono es probablemente un mínimo de lo que podría dar con otro generador. No lo he comprobado con otra herramienta.

Resultado 2: las plantillas completas

Métrica (mediana de 3 rondas, 25 de octubre; entre paréntesis, 24 de octubre) Hono NestJS Rango Hono Rango Nest
GET /healthz (peticiones/s) 3.994 (4.110) 6.011 (5.328) 3.859 – 4.174 5.444 – 6.060
GET /healthz (latencia media) 4,5 ms (4,4) 2,8 ms (3,2) 4,3 – 4,7 2,8 – 3,1
GET /api/tasks autenticado (peticiones/s) 756 (770) 904 (909) 742 – 772 876 – 930
GET /api/tasks (latencia media) 25,9 ms (25,4) 21,6 ms (21,5) 25,4 – 26,4 21,0 – 22,3
Login, primera vez 310 ms (306) 173 ms (56) 300 – 334 148 – 203
Login en caliente (mediana de 8) 288 ms (288) 33 ms (23) 283 – 305 28 – 55
Arranque hasta /healthz 642 ms (612) 883 ms (761) 626 – 677 882 – 909
RSS en reposo 154 MiB (153) 188 MiB (187) 153 – 154 186 – 188
RSS tras la carga 384 MiB (393) 439 MiB (439) 374 – 392 437 – 442
Cierre con SIGTERM 45 ms (45) 42 ms (56) 28 – 53 34 – 43

Lo que medí: el framework solo y las plantillas completas
Lo que medí: el framework solo y las plantillas completas

Antes de interpretar nada, dos advertencias sobre la comparación entre las dos pasadas. Hono casi no cambió (mismas cifras dentro del ruido). Nest sí cambió algo: el /healthz sube de 5.328 a 6.011 peticiones por segundo, el arranque pasa de 761 a 883 ms y el primer login de 56 a 173 ms (la ronda 1 fue la más lenta: 203 ms en frío y 55 ms en el login en caliente, un valor atípico que dejo dentro de la mediana y del rango). No sé atribuir esos cambios: el PR #8 quitó un interceptor global que no hacía nada, quitó cinco dependencias y subió versiones en rango, y cualquiera de esas cosas, o el ruido de una máquina compartida, podría explicar una parte; no lo he medido ni lo he aislado. Lo único que sostengo son las medidas de cada pasada.

Léelo despacio, porque cuenta dos historias que se contradicen:

  • En capacidad de servir peticiones, Nest gana en la plantilla: un 50 % más en /healthz (6.011 frente a 3.994) y un 20 % más en /api/tasks (904 frente a 756). En las dos rutas los rangos de las tres rondas no se solapan (en /api/tasks, 876–930 frente a 742–772), pero son solo tres rondas, así que no te atrevas a poner decimales a esas diferencias. En la primera pasada fueron +30 % y +18 %: la dirección se mantiene, el tamaño no es estable.
  • En memoria y arranque, Hono gana: 34 MiB menos en reposo (154 frente a 188), unos 240 ms antes de responder (esta vez los rangos no se solapan: 626–677 frente a 882–909; el 24 de octubre sí se solapaban, 600–785 frente a 701–812, así que trátalo como una tendencia) y 55 MiB menos tras la carga (384 frente a 439).

Y, sin embargo, en el hello world el núcleo de Hono es casi el doble de rápido. ¿Cómo puede ser que la plantilla de Hono sea más lenta?

Por qué lo que gana un framework no se ve en la plantilla

Lo que está medido: la plantilla de Hono hace más trabajo por petición que su hello world, igual que la de Nest. El gran cambio entre una prueba y otra (de 78.000 a unas 4.000 peticiones por segundo en Hono) no es el framework: es todo lo demás. /healthz consulta la base de datos para medir la latencia (la respuesta lleva database.latencyMs) y /api/tasks comprueba el token, relee el usuario y la sesión de la base de datos (las dos plantillas lo hacen, con consultas en paralelo) y pagina la lista. En la plantilla, el framework pasa a ser una parte pequeña del tiempo total.

Lo que es una hipótesis (no la he medido): por qué ahí la de Nest sale entre un 20 % y un 50 % por delante. Mis sospechosos son la cadena de middlewares de la plantilla de Hono (requestId, secureHeaders, cors, un logger que construye un mensaje por cada petición (en la prueba el nivel era error, así que no llegaba a escribirlo), prettyJSON y bodyLimit) y la capa de zod-openapi, que construye y valida objetos con Zod en cada ruta. Pero Nest también tiene su cadena (helmet, CORS, guards, pipes, interceptores), y no he hecho un perfil de CPU de ninguna de las dos. Podría ser otra cosa (la forma de acceder a SQLite, el serializador JSON, cómo se pagina). Si quisieras sacar de aquí una conclusión de rendimiento para tu proyecto, la única válida es: en una plantilla equivalente, no hay ventaja de velocidad de Hono; mídelo con tu propio código.

⚠️
No saques de aquí «Nest es más rápido que Hono». La medida correcta es «esta plantilla de Nest sirvió más peticiones por segundo que esta plantilla de Hono, en mi máquina compartida, en tres rondas (dos veces)». Una plantilla de Hono optimizada (sin prettyJSON, con un logger más barato, con Argon2 en lugar de bcrypt…) podría invertir el resultado. La parte del framework solo sí es una comparación limpia, y ahí Hono gana de largo.

El login: lo único que sí es un abismo, y lo explica el hash

El login en caliente tarda 288 ms en Hono y 33 ms en Nest (el 24 de octubre: 288 y 23 ms), una diferencia de entre 9 y 12 veces. La sospecha era el algoritmo de contraseñas, así que la comprobé aislándolo del framework, con las dos librerías tal y como las usan las plantillas. Lo repetí el 25 de octubre (mediana de 15 repeticiones, en la misma máquina):

$ node hash.mjs
{"bcryptjs12_hash_ms":"277.8","bcryptjs12_verify_ms":"283.8",
 "argon2id_hash_ms":"11.4","argon2id_verify_ms":"7.9",
 "argon_hash_prefix":"$argon2id$v=19$m=19456,t=2,p=1"}

Verificar una contraseña cuesta 284 ms con bcryptjs (12 rondas) y unos 8–10 ms con Argon2id (7,9 ms en esta serie de 15) (la configuración de la plantilla: 19 MiB de memoria, 2 pasadas, 1 hilo). El 24 de octubre medí 264 y 9,5 ms: son medidas sueltas y varían unos milisegundos de un día a otro. Hice además una serie más larga con exactamente los parámetros de la plantilla de Nest (@node-rs/argon2 2.2.2, Argon2id, memoryCost 19 456 KiB, timeCost 2, parallelism 1; bcryptjs 3.0.3 con 12 rondas), 50 llamadas tras 3 de calentamiento: el hash con Argon2id tiene una mediana de 8,3 ms (p90 9,6 ms, máximo 48 ms) y la verificación, 9,9 ms (p90 39,5 ms, máximo 115 ms); bcryptjs, 276 ms por hash y 275 ms por verificación (n = 20). En resumen: entre 8 y 10 ms con Argon2id, con picos ocasionales de decenas de milisegundos (no he identificado su causa; hipótesis: la reserva de 19 MiB en cada llamada y que la máquina es compartida). (El PR #23 citaba ≈51 ms para Argon2id. Los parámetros de aquel script eran los mismos, pero calculaba la media de solo 5 llamadas seguidas, sin calentar, y unos pocos picos de 30–80 ms bastan para arrastrarla: repetido tal cual, ese script dio 46, 26 y 11 ms en tres ejecuciones. El 51 era una media ruidosa, no la cifra típica, y ya he corregido el texto del PR; el ≈278 ms de bcryptjs sí coincide con lo que mido aquí.) Eso explica casi todo el abismo del login (el resto es la consulta a la base de datos y la creación de la sesión) y es la hipótesis más probable de por qué las pruebas de Hono tardan 82 segundos y las de Nest 12 (en los tests no lo he comprobado).

Dos cosas que conviene entender sobre esto:

  • No es una ventaja de NestJS. Es una decisión de cada plantilla. Pero tiene consecuencias de verdad: con bcryptjs en un solo hilo de Node, un servidor de Hono con esta configuración puede atender unos 3-4 logins por segundo por proceso (1.000 ms ÷ 284 ms) antes de que el login monopolice la CPU. Es una estimación a partir de la medida, no una prueba de carga de login.
  • Más lento no es más seguro ni menos. 12 rondas de bcrypt son una elección razonable; Argon2id con 19 MiB y 2 pasadas es la configuración mínima que recomienda OWASP. Aquí el coste es tiempo de CPU del servidor, no la seguridad: los dos funcionan.
💡
Si escoges la plantilla de Hono y te molesta ese coste, cambiar a un módulo de Argon2 nativo es un cambio pequeño y contenido (el hash vive en src/utils/password.ts). Lo he medido en aislamiento, pero no he reescrito la plantilla de Hono con Argon2 para comprobar el efecto en sus pruebas y en su login. El PR #23 lo deja como seguimiento a propósito: cambiar el hash cambia cómo se guardan las contraseñas (y una versión anterior de la plantilla no podría verificar los hashes Argon2id ya guardados), y habría que comprobar el módulo nativo con Bun y Alpine.

Peso, dependencias y seguridad de la cadena de suministro

Aquí no hay ambigüedad: son medidas de disco y de informes de herramientas. La primera vez las ejecuté sobre los dos repositorios tal cual están en main (con el package-lock.json de cada uno) y las repetí el 25 de octubre sobre las ramas de los PR; la tabla da las cifras de la segunda pasada y, entre paréntesis, las de main.

Hono NestJS
Carpeta dist/ compilada 428 kB (52 archivos) (420 kB) 1.000 kB (94 archivos) (996 kB)
Cliente de Prisma generado 640 kB 724 kB
node_modules completo (con desarrollo) 567 MB (530) 567 MB (566)
Instalación de producción (npm ci --omit=dev --ignore-scripts) 396 MB (387) 435 MB (436)
Paquetes en el lockfile (total / producción) 359 / 224 (359 / 225) 812 / 312 (846 / 336 en main; el 24 de octubre conté 845 / 335)
Dependencias declaradas (producción + desarrollo) 13 + 12 (13 + 12) 19 + 19 (23 + 20)

Dos lecturas honestas:

  • La mayor parte del peso es de Prisma, no del framework. @prisma/* y prisma pesan lo mismo en las dos: unos 189 MB en la instalación de producción (en el node_modules completo, du da 167–168 MB para @prisma y 41 MB para prisma en las dos). La diferencia atribuible al framework y a su ecosistema es de unos 40 MB en producción (396 frente a 435 MB; el 24 de octubre, unos 50), no de órdenes de magnitud. El «Hono es diminuto» es cierto para el framework (compárate con los 1,4 MB de node_modules de mi comparativa anterior) y casi irrelevante cuando le añades Prisma.
  • Sí hay una diferencia grande en número de paquetes: 812 frente a 359 en el lockfile completo (846 antes de que el PR #8 quitara cinco dependencias que nada importaba). Más paquetes es más superficie que auditar, más actualizaciones y más ruido en los informes de vulnerabilidades. Es el coste del ecosistema de Nest (@nestjs/*, rxjs, reflect-metadata, Jest, ESLint y sus plugins).

npm audit: las dos fallaban; con los PR, ninguna

Ejecuté npm audit el 24 de octubre de 2026 sobre main. Ninguna plantilla pasaba un --audit-level=high:

# Hono, main (salida recortada)
$ npm audit --omit=dev
zod  <4.6.6
Severity: high
Zod can exhaust memory while validating oversized constrained collections - GHSA-mrhx-c7xc-m3w5
...
4 vulnerabilities (3 moderate, 1 high)

# NestJS, main (salida recortada)
$ npm audit --omit=dev
multer  2.0.0 - 2.4.0
Severity: high
multer vulnerable to Denial of Service via a pending asynchronous fileFilter after request teardown - GHSA-q7qp-rjg7-q26m
  @nestjs/platform-express  10.4.18 - 10.4.22 || >=11.1.2
    @nestjs/core  >=12.0.1
3 high severity vulnerabilities
Hono (main → PR #23) NestJS (main → PR #8)
Producción (--omit=dev) 4 (1 alta, 3 moderadas) → 0 3 (3 altas, una sola causa) → 0
Todo el árbol (con desarrollo) 9 (3 altas, 6 moderadas) → 0 4 altas → 0
Qué era zod 4.6.5 (alta), hono 4.13.10 (serveStatic), nodemailer 10.0.13, fast-uri, y en desarrollo undici, vite y vitest multer 2.4.0, que arrastra a @nestjs/platform-express y a @nestjs/core (y a @nestjs/testing)
Cómo se arregló Subir versiones dentro del rango: hono 4.13.14, zod 4.6.7, nodemailer 10.2.1, @hono/node-server, Prisma 7.10.1, Vitest y Biome "overrides": { "multer": "^2.5.0" } en el package.json
npm audit --audit-level=high, 25 de octubre pasa (código 0) pasa (código 0)

Dos detalles. La de Nest era una sola causa (una dependencia indirecta, multer, que el código de la plantilla no usa: no hay ni un FileInterceptor en src/) repetida en tres paquetes, y npm audit proponía una «solución» con --force que degradaría NestJS a la versión 11, que no es lo que quieres; el overrides es la salida correcta mientras @nestjs/platform-express siga fijando multer 2.4.0. La de Hono eran cuatro causas distintas, todas arreglables subiendo versiones. En la primera versión de este artículo dije que no había probado esa copia del lockfile con la aplicación; ahora sí: con las versiones nuevas, las 148 pruebas de Hono y los 95, 76 y 32 escenarios de contrato pasan.

⚠️
Un 0 en npm audit es una foto de hoy: la base de avisos cambia a diario y dentro de una semana puede dejar de ser 0 sin que nadie haya tocado nada. Lo que importa es tener el aviso (CI con npm audit, Dependabot) y actualizar. Las dos plantillas ya ejecutaban npm audit --audit-level=high en su CI (Hono solo con --omit=dev; con los avisos del 24 de octubre los dos pipelines se habrían puesto en rojo en ese paso). El CI de Nest además escanea la imagen con Trivy y publica un SBOM; el de Hono construye la imagen pero no la escanea. Para Hono preparé un cambio de CI (matriz Node 22 y 24, auditoría de todo el árbol, y un trabajo con prueba de humo de la imagen, Trivy y SBOM), pero no está en el PR: el token con el que lo subí no tiene permiso para tocar .github/workflows. Lo dejé como parche en la descripción del PR #23 y no se ha ejecutado en GitHub. Además, el PR #23 añade una configuración de Dependabot.

Docker: imagen, endurecimiento y qué dice Trivy

Construí las dos imágenes con su Dockerfile (Docker 26.1.5, sin caché) y las ejecuté, dos veces: sobre main (24 de octubre) y sobre las ramas de los PR (25 de octubre). La tabla pone «antes → después».

Hono NestJS
Imagen final 609 MB → 547 MB 719 MB → 570 MB
Base node:20-alpine (Node 20.20.2, sin soporte) → node:24-alpine fijada por digest node:25-alpine (fijada por hash; Node 25.9.0 sobre Alpine 3.23.4, línea sin LTS) → node:24-alpine fijada por digest
En la imagen después Node 24.22.1 sobre Alpine 3.24.3; sin npm, corepack, yarn ni typescript lo mismo: el mismo digest, y también sin npm, corepack, yarn ni typescript
Construcción sin caché ≈14,5 s → 13,3 s 20,2 s → 19,7 s
read_only, cap_drop ALL, no-new-privileges, 512 MB, 1 CPU No → sí (en el docker-compose.yml del PR) Sí → sí
Puerto publicado en todas las interfaces → solo 127.0.0.1 (cambio de comportamiento) solo 127.0.0.1
Memoria en reposo dentro del contenedor (docker stats) 58,6 MiB → 57,0 MiB 79,4 MiB → 93,8 MiB
Parada (docker stop) 99 ms → 151 ms, salida 0 73 ms → 156 ms, salida 0
Healthcheck sano en ≈35 s → sano (con docker-compose up, en ≈35 s) sano en ≈40 s → sano (lo comprueba scripts/docker-smoke.sh)
Logs JSON (pino) texto con colores ANSI → texto sin colores en producción
Entrypoint npx prisma migrate deploy → node …/prisma/build/index.js migrate deploy node …/prisma/build/index.js migrate deploy; ahora valida el entorno antes de migrar

Cuatro avisos metodológicos. (1) Después de los PR, las dos imágenes parten del mismo digest de Node 24, así que la comparación de memoria ya no mezcla versiones de Node (en la primera pasada sí: 20 frente a 25). (2) Las paradas y la memoria son medidas sueltas, no medianas de varias ejecuciones: la parada de Nest pasó de 73 a 156 ms y la de Hono de 99 a 151 ms, y no sé atribuir la diferencia (la propia aplicación sale en unos 40 ms según la prueba de rendimiento). (3) La memoria de Nest en el contenedor subió de 79,4 a 93,8 MiB con docker stats (y a unos 157 MiB de RSS dentro del contenedor, frente a unos 119 MiB de Hono: 160.880 kB y 121.748 kB); no lo atribuyo a nada concreto y no medí con el mismo protocolo exacto. (4) El docker stats de Docker y la memoria residente (RSS) del proceso no miden lo mismo, así que de aquí no saco una conclusión de memoria entre frameworks; la mejor medida de memoria de este artículo es la de la sección de rendimiento (154 frente a 188 MiB en reposo, fuera de Docker).

Trivy

Escaneé las dos imágenes con los mismos filtros que usa el CI de la plantilla de Nest (--severity HIGH,CRITICAL --ignore-unfixed), primero sobre main y luego sobre las ramas de los PR (Trivy 0.76.1 las dos veces):

Hono NestJS
Hallazgos HIGH/CRITICAL, 24 de octubre (main) 38 (1 crítico) 1 (HIGH)
Hallazgos HIGH/CRITICAL, 25 de octubre (PR) 0 0
Dónde estaban 22 en el npm que trae la imagen base (tar 6.2.1 con 1 crítico, brace-expansion, minimatch, glob, cross-spawn, pacote, sigstore, ip-address) + zod 4.6.5; y 15 en el binario de Go de tsc (TypeScript 7) multer 2.4.0 (el mismo del npm audit); 0 en Alpine
Sistema operativo (Alpine) tras el PR 3.24.3: 0 3.24.3: 0

La diferencia entre las dos plantillas tenía una causa clara y no era el framework: la imagen de Hono conservaba npm (con su tar y compañía, vulnerables) porque partía de node:20-alpine sin quitarlo; la de Nest borraba npm de la imagen final y recompilaba el motor de Prisma. Además, TypeScript 7 (que usa la plantilla de Hono) trae el compilador como binario de Go, y Trivy escanea sus dependencias de Go. Ese binario llegaba a la imagen de producción sin que la plantilla lo pidiera: typescript está en sus dependencias de desarrollo, pero npm también lo instalaba en producción como dependencia peer de Prisma (npm ls typescript --omit=dev lo mostraba colgando de @prisma/client y prisma).

Qué cambió en el PR #23 de Hono: la imagen final ya no lleva npm, corepack, yarn ni typescript (el entrypoint ejecuta la CLI de Prisma con node directamente), parte de Node 24 y se actualizaron zod y el resto. Qué cambió en el PR #8 de Nest: el overrides de multer, y la imagen también deja fuera typescript, corepack y yarn. Ojo con el 0: es el resultado de hoy, con la base de datos de Trivy de hoy, y solo cuenta lo que tiene arreglo publicado (--ignore-unfixed).

Lo que me llevó más tiempo: la imagen de Hono no arrancaba con --read-only (corregido en el PR #23)

El docker-compose.yml de Nest usa un sistema de archivos de solo lectura. En main probé a ejecutar la imagen de Hono igual y falló:

$ sudo docker run --read-only --rm --env-file compose.env hono-starter:lab
(salida recortada)
Error: Could not find schema-engine binary

La causa era el Dockerfile de Hono: instalaba con npm ci --omit=dev --ignore-scripts y no volvía a ejecutar @prisma/engines, así que Prisma se descargaba el motor de migraciones en el primer arranque. Eso exige un node_modules escribible y salida a Internet cuando arranca el contenedor. Sin --read-only funcionaba (lo comprobé), pero tienes un contenedor que descarga binarios al arrancar, que es justo lo que no quieres en producción. El de Nest ya lo resolvía con npm rebuild @prisma/engines en la construcción.

El PR #23 lo arregla de la misma manera: ahora el motor se descarga al construir la imagen (npm rebuild @prisma/engines prisma). Lo comprobé con scripts/docker-smoke.sh, que el PR añade a las dos plantillas: construye la imagen, la ejecuta con --read-only, aplica las migraciones, inicia sesión con el administrador sembrado, espera al HEALTHCHECK y comprueba que corre sin privilegios. En las dos ramas pasa entero. Con el docker-compose.yml nuevo de Hono, el contenedor queda Up (healthy) en 127.0.0.1:3011 y docker inspect confirma ReadonlyRootfs=true, CapDrop=ALL y no-new-privileges.

💡
Lo que yo haría con la plantilla de Hono en producción, y que ya está en main con el PR #23 (fusionado el 25 de octubre): precompilar el motor de Prisma en la construcción, quitar npm de la imagen final, subir la base a una versión de Node con soporte activo y endurecer el docker-compose.yml como el de Nest. Lo que no está: Trivy en la CI de Hono (el parche está en la descripción del PR), porque el token no podía tocar workflows, y el hash en Argon2. También queda sin cambiar el auto-baseline del entrypoint de Hono: si el volumen trae una base con datos pero sin historial de migraciones, el script marca la migración 0_init como aplicada (lo comprobé sobre una base ajena). La de Nest no lo hace sola.

Dos observaciones sobre la plantilla de Nest, que no eran ventajas, sino descuidos: sus logs salían con colores ANSI por defecto (62 líneas con códigos de escape en mi prueba; 0 con NO_COLOR=1), lo que ensucia los agregadores de logs; y la imagen pesaba 719 MB. El PR #8 los arregla: los logs de producción salen sin colores (0 líneas con secuencias de escape en la imagen del PR) y la imagen baja a 570 MB. Y una tercera, que sale de la misma prueba: el entrypoint de Nest migraba la base de datos antes de validar el entorno (un JWT_SECRET ausente dejaba creada y migrada la base de datos y solo entonces abortaba); el PR #8 invierte el orden y, con un volumen vacío y sin secretos, el volumen se queda vacío.

Portabilidad: el argumento que más se repite, y qué pude comprobar

La afirmación habitual es «Hono corre en todas partes y Nest solo en Node». Es cierta en espíritu, pero con las plantillas de este artículo la realidad es más incómoda. Lo separo en tres cosas.

1. El framework. Hono está construido sobre las APIs web estándar (Request, Response, fetch) y funciona en Cloudflare Workers, Deno, Bun, Node y otros. Esto no lo he medido aquí: lo cuento por su diseño, y lo practiqué en otro sitio. En Cloudflare Workers a fondo y en el tutorial API de tareas serverless con Cloudflare, Hono, D1, R2 y Prisma tienes Hono desplegado en Workers; si te interesan los límites y precios, están en D1, R2 y Workers: gratis o de pago. Ojo: ese tutorial es otra aplicación, pensada para Workers desde el principio; no es esta plantilla.

2. La plantilla de Hono de este artículo. Está escrita para Node: usa @hono/node-server, Prisma sobre SQLite local con @prisma/adapter-libsql y @libsql/client, nodemailer para el correo (SMTP), un proceso largo con un trabajo de limpieza en segundo plano y manejo de señales. Llevarla a Workers no es cambiar un import: habría que revisar la base de datos (D1 en lugar de un archivo SQLite), el envío de correo por SMTP, la tarea en segundo plano y el arranque. No lo he intentado con esta plantilla, y no digo que sea imposible; digo que «Hono corre en Workers» y «esta plantilla corre en Workers» son frases distintas.

3. Lo que sí probé: las dos plantillas, compiladas, en Bun. (Esta prueba es de la primera pasada, sobre main; el resultado después del PR #23 está más abajo.) Tenía Bun 1.4.2 en la máquina, así que ejecuté bun dist/… en lugar de node dist/… con el mismo entorno y las mismas pruebas de contrato que usé en la sección de validación (95 pruebas por HTTP, modo full):

$ bun dist/main.js          # NestJS
healthz 200   login 200
contract: 95 tests, 0 failing (6957ms, mode=full)

$ bun dist/index.js         # Hono, tal cual está en el repositorio
error: Failed to start server. Is port 3941 in use?
   code: "EADDRINUSE"
Bun v1.4.2 (Linux x64)

Resultado, y es lo contrario de lo que esperaba:

  • La plantilla de NestJS pasó las 95 pruebas en Bun, sin tocar nada.
  • La plantilla de Hono, tal cual, se cae al arrancar en Bun. La causa es concreta: el archivo de entrada hace export default app y además levanta su propio servidor con @hono/node-server. Bun, cuando ve un export default con un método fetch, arranca un servidor por su cuenta en el mismo puerto, y ese segundo servidor falla con EADDRINUSE. En Node no pasa porque Node ignora ese export default. Es un caso en que el diseño «portable» de Hono choca con su propia plantilla.

Quise ver si era solo eso. En una copia del dist/ (el repositorio no lo toqué) cambié export default app; por export { app }; y repetí:

$ bun dist/index.js         # Hono, copia con el export cambiado
healthz 200   login 200
contract: 95 tests, 3 failing (25950ms, mode=full)
  ✗ [ratelimit-login] 5 failed logins lock the account; the 6th → 429 with Retry-After
  ✗ [ratelimit-login] the lockout is scoped to the account, not the IP
  ✗ [ratelimit-login] per-IP login limiter (10/min) ends the flood with a 429

Con ese cambio, arranca y pasa 92 de 95. Las tres que fallan son las de ráfagas de logins en paralelo del límite de intentos (los tres errores son «fetch failed», es decir, conexiones que se cortan, o no se llegó a ver el 429 esperado). Las mismas pruebas, con el mismo entorno, pasan 95 de 95 con Node (lo repetí para descartar que fuera el entorno). Si lanzo los intentos de login uno a uno con curl contra el servidor en Bun, el bloqueo funciona (cinco 401 y luego 429), así que el fallo aparece con peticiones concurrentes. No investigué la causa: no sé si es de Bun, de @hono/node-server, de bcryptjs o de la plantilla.

Qué cambió con el PR #23 (25 de octubre). El PR separa la aplicación (src/app.ts) del arranque del proceso (src/index.ts), de modo que ya no hay un export default app junto a un serve(); los tests importan app.ts. Con esa rama, bun src/index.ts arranca con un único servidor y sin retoques (en la fase de correcciones lo lancé desde el código fuente, no desde dist/). El arnés de contrato en modo full contra ese servidor en Bun dio 92 de 95: fallan las mismas tres pruebas de ratelimit-login que ya fallaban en mi copia retocada de main (fetch failed en el primero, y en el segundo un 200 donde se esperaba el 429). Con Node, las mismas 95 pasan. Sigo sin saber la causa y no la he reproducido con peticiones sueltas, así que no te digo que sea de Bun, de la plantilla o de otra cosa. La de Nest en Bun (95 de 95) no la repetí con la rama del PR #8.

🧪
Bun 1.4.2, una sola máquina, una sola ejecución de cada una (más la repetición de Hono y la comprobación del 25 de octubre). Lo que demuestra: «el framework portable» y «la plantilla portable» no son lo mismo, y en main Nest funcionó en Bun donde la plantilla de Hono necesitó un retoque (que el PR #23 ya incluye). Lo que no demuestra: que Nest sea más portable en general. En Bun la compatibilidad con Node es muy alta, y es justo lo que se probó; el salto a Workers o a Deno es mucho mayor y no lo he probado (Deno ni siquiera estaba instalado en la máquina).

¿Y NestJS en Workers o Deno?

Sin probar. Mi hipótesis, y solo eso, es que Nest se lleva peor con los entornos que no son Node: se apoya en decoradores con metadatos (reflect-metadata), en el contenedor de inyección que se construye al arrancar, y la plantilla en @nestjs/platform-express, que es Express, que es Node. Hay adaptadores y gente que lo hace, pero no lo he comprobado en esta plantilla. Si tu destino es Workers, la ruta más corta es Hono, y a eso sí te puedo remitir a los artículos de arriba.

Experiencia de desarrollo, curva de aprendizaje y ecosistema

Esta sección mezcla datos con opinión, así que voy a decirte en cada párrafo cuál es cuál.

Lo que se puede contar (datos)

Hono (plantilla) NestJS (plantilla)
Archivos de TypeScript en src/ 36 (35 en main) 76 (75 en main)
Líneas en src/ 5.303 (5.281) 6.622 (6.579)
Dependencias declaradas (producción + desarrollo) 13 + 12 19 + 19 (23 + 20 en main)
Pruebas 148 (Vitest; 144 en main) 84 (Jest; 62 en main)
Descargas semanales en npm (16–22 oct 2026) hono: 48,0 M @nestjs/core: 10,4 M
Estrellas en GitHub (22–23 oct 2026) 32.652 76.892
Referencia express: 94,0 M descargas; 69.574 estrellas fastify: 10,1 M; 37.292

Las descargas y las estrellas no miden calidad ni adopción en empresas: las descargas incluyen instalaciones de integración continua y dependencias transitivas (no he descontado nada), y las estrellas miden popularidad en GitHub, no uso en producción. Lo único que me permiten decir con seguridad es que ninguno de los dos es un proyecto marginal, y que en 2026 Hono ya se descarga más que NestJS (con la advertencia de que parte de esas descargas pueden venir de otras herramientas que lo instalan por debajo; eso no lo he verificado).

Una cifra que sí me importa para el día a día: la plantilla de Hono tiene menos de la mitad de archivos que la de Nest (36 frente a 76) haciendo lo mismo, y buena parte de esa diferencia es estructura: la plantilla de Nest tiene (contados por nombre de archivo, el 24 de octubre en main) 14 módulos, 10 controladores, 15 servicios y 3 guards, que en Hono son funciones dentro de los archivos de rutas y de utilidades. No he contado cuántos de esos archivos son «solo cableado» ni repetí este recuento con el PR.

Lo que viví construyéndolas (opinión)

  • Hono se aprende en una tarde; Nest, en semanas. Hono tiene tres ideas: el contexto c, el middleware con next() y el enrutador. Nest te pide aprender módulos, proveedores, inyección de dependencias, decoradores, guards, pipes, interceptors y filtros, y entender el orden en que se ejecutan. Cada pieza es razonable, pero son muchas. No lo he medido con nadie: es mi experiencia y mi opinión.
  • Pero esa estructura es exactamente lo que se aprecia en un equipo grande. En Nest, la pregunta «¿dónde pongo esto?» tiene casi siempre una única respuesta, y eso reduce las discusiones en las revisiones de código. En Hono, la plantilla impone la estructura por convención, no el framework: si alguien no la respeta, nada se queja. Es una ventaja y un riesgo a la vez.
  • Las pruebas de Hono son más directas. Los tests llaman a app.request() (lo usan 18 archivos de tests/ y src/) y no necesitan levantar un contenedor de inyección ni sustituir proveedores. Al mismo tiempo, mis 84 pruebas de Nest corren en unos 12 s y las 148 de Hono en unos 82 s (en main, 62 en 8–9 s y 144 en unos 80 s; y esa diferencia, como vimos, no es del framework: es el coste del hash en JavaScript puro, que sospecho que se paga en los tests, aunque no he cambiado las rondas de bcrypt para comprobarlo).
  • La inferencia de tipos de extremo a extremo de Hono es muy atractiva, pero no la he probado en un frontend real. La plantilla exporta AppType, que es lo que alimenta hc<AppType> (el cliente tipado de Hono). No he escrito un frontend con él para esta comparativa, así que no te cuento cómo se siente. En Nest, lo equivalente es generar un cliente a partir del OpenAPI (herramientas externas); tampoco lo he probado aquí.
  • El ecosistema de Nest es más «de oficina»: módulos oficiales para colas, cron, GraphQL, microservicios, WebSockets, CQRS, y una documentación enorme. En esta comparativa no usé ninguno de esos módulos (la plantilla es una API REST), así que no puedo decirte cuánto valen. Hono tiene un ecosistema de middlewares oficiales y de terceros más ligero, y para cosas como colas o WebSockets tendrás que decidir más piezas tú.
  • Los números de «Hono es más rápido» no cambian el día a día de casi nadie. En la plantilla de Nest, a 20 conexiones, /api/tasks sirvió ≈900 peticiones por segundo, y la de Hono entre ≈750 y ≈770. Para una API de una startup, el cuello de botella va a ser la base de datos, el hash de contraseñas o la red mucho antes que el framework.

Mantener a lo largo del tiempo

Un dato que sí pude ver: el 24 de octubre las dos plantillas arrastraban avisos de seguridad (sección de npm audit), y ninguno era del código propio. En Nest, una dependencia transitiva (multer) afectaba a tres paquetes de Nest; en Hono, cuatro paquetes distintos, uno de ellos, zod, es una dependencia directa de la plantilla (y era el de gravedad alta). En ambos casos se arreglaba subiendo versiones, y con los PR las dos están en 0 hoy. Con 812 paquetes en el lockfile de Nest frente a 359 en el de Hono, Nest te dará más avisos que mirar a largo plazo, aunque cada uno sea pequeño. Es una consecuencia directa del tamaño del ecosistema, y eso también incluye herramientas de análisis, pruebas y lint que no van a producción. Los dos PR añaden o mantienen un Dependabot (npm, imagen base y Actions) para que los avisos lleguen solos.

Cuándo elegir cada uno

Cómo elegir: tres preguntas antes de mirar benchmarks
Cómo elegir: tres preguntas antes de mirar benchmarks

La tabla resume la comparación. La columna «Base» te dice de dónde sale cada veredicto: medido es algo que ejecuté en esta máquina; diseño es lo que se deduce de cómo están construidos; opinión es mía.

Criterio Mejor Base
Contrato HTTP, seguridad, esquema de datos Empate Medido: 95 escenarios y 710 peticiones con el mismo estado en las dos
Rendimiento del framework solo (hello world) Hono (1,8–1,9×) Medido, tres rondas
Rendimiento de la plantilla completa NestJS (+20 % a +50 %) Medido, tres rondas, dos veces; la causa es hipótesis
Login en caliente NestJS (33 ms frente a 288 ms) Medido; explicado por el hash (Argon2id nativo frente a bcryptjs puro)
Memoria en reposo y tras carga Hono (154 frente a 188 MiB; 384 frente a 439 tras la carga) Medido
Arranque Hono (≈640 frente a ≈880 ms) Medido; en la primera pasada los rangos se solapaban
Tamaño de dist/ y de la instalación de producción Hono (428 kB; 396 MB) Medido; el grueso es Prisma en las dos
Dependencias y superficie de auditoría Hono (359 frente a 812 paquetes) Medido
npm audit Empate (0 y 0 en las ramas de los PR; en main fallaban las dos) Medido el 25 de octubre; es una foto del día
Imagen Docker y endurecimiento Empate técnico con los PR (Trivy 0 en las dos; Hono 547 MB, Nest 570 MB; las dos con --read-only). En main ganaba Nest Medido; es cosa de las plantillas, no de los frameworks
Velocidad del conjunto de pruebas NestJS (12 s frente a 82 s) Medido; el motivo probable es el hash (sin comprobar en los tests)
Portabilidad a Workers y Deno Hono Diseño; en Workers lo practiqué en otros artículos, en Deno no probé nada
La plantilla en Bun NestJS (95 de 95 en main, sin tocar nada) Medido el 24 de octubre; Hono arranca con el PR #23, pero 3 de 95 siguen fallando (causa desconocida)
Curva de aprendizaje Hono Opinión
Equipos grandes, mucho cambio de personas NestJS Opinión, apoyada en el diseño
Tipos de extremo a extremo con el frontend Hono (hc<AppType>) Diseño; sin probar en un frontend real

Mi recomendación

  • Elige Hono si lo vas a desplegar en el borde (Workers) o quieres poder moverlo entre entornos; si tu API es pequeña o es un servicio concreto, y sobre todo si trabajas solo o con un equipo pequeño que ya conoce TypeScript y prefiere funciones a clases. Con los avisos de arriba: en main la plantilla necesitaba endurecer el Docker (el PR #23 lo hizo y ya está fusionado).
  • Elige NestJS si el equipo es grande o va a rotar, o si ya viene de Angular, Spring o .NET y la estructura impuesta le resulta natural; si necesitas módulos oficiales (colas, cron, GraphQL, microservicios) o si valoras que cada cosa tenga un sitio. Ten en cuenta el coste: más ceremonia, más dependencias (812 paquetes en el lockfile) y más superficie de actualizaciones.
  • No elijas por el benchmark del hello world. En mis plantillas, con la misma lógica, salió mejor Nest; y aun así no creo que sea un motivo para elegirlo. Lo que decide es el equipo, el destino de despliegue y cuánta estructura quieres que te imponga el framework.

Migrar de uno a otro: lo que probé

Como las dos plantillas comparten esquema de Prisma y contrato HTTP, probé las dos direcciones sobre una base de datos real (no un diagrama). Esta prueba es de la primera pasada (24 de octubre, main). No la repetí con las ramas de los PR; sí comprobé con un diff que ninguno de los dos PR toca prisma/ (ni el esquema ni las migraciones), y que el PR #23 no cambia el hash de las contraseñas, así que no tengo motivo para pensar que el resultado cambie, pero no lo he ejecutado.

De Hono a NestJS (la dirección fácil):

  1. Creé la base con la plantilla de Hono (prisma migrate deploy, 9 migraciones) y la dejé con sus datos de ejemplo.
  2. Ejecuté prisma migrate deploy con el repositorio de Nest sobre esa misma base: aplicó una sola migración (20261023090922_email_outbox, la tabla de la cola de correos) y la plantilla de Nest arrancó con los datos.
  3. Las contraseñas de Hono son bcryptjs ($2b$12$…) y Nest guarda Argon2id. Nest sabe verificar las dos y migra de forma progresiva: tras un inicio de sesión correcto del usuario [email protected], su hash pasó de $2b$12$… (60 caracteres) a $argon2id$v=19$m=19456… (97 caracteres).
$ node q.mjs migra.db            # antes del login de ana, con la base creada por Hono
[email protected]  $argon2id$v=19$m=194…  (97)
[email protected]  $2b$12$nUxHabdYJzjOt…  (60)

$ curl -X POST …/api/auth/login -d '{"email":"[email protected]","password":"…"}'
login ana (bcrypt de Hono) -> 200

$ node q.mjs t.db                # después del login, en una copia de la base
[email protected]  $argon2id$v=19$m=194…  (97)
[email protected]  $argon2id$v=19$m=194…  (97)

(El usuario admin aparece ya en Argon2id antes del login. No investigué por qué; mi sospecha, sin comprobar, es que la siembra de Nest reescribe la cuenta del administrador al arrancar. No cambia la conclusión sobre ana.)

De NestJS a Hono (la dirección incómoda): la base ya tiene aplicada la migración de la cola de correos, y Hono no conoce esa migración. La plantilla de Hono se niega a arrancar, y lo hace bien:

$ node dist/index.js        # Hono sobre la base que ya usó Nest
FATAL: ❌ Database migration history does not match prisma/migrations. Resolve it before starting the server.
  "message": "Migration history does not match prisma/migrations:\n  Applied but absent from prisma/migrations: 20261023090922_email_outbox — the migration files were deleted or renamed"

Es un buen comportamiento (falla pronto y con un mensaje claro en lugar de trabajar con un esquema que no conoce). Pero implica trabajo manual: llevar la migración y el modelo EmailOutbox a Hono, o borrar la tabla. Y hay un segundo obstáculo que no he ejecutado, solo leído en el código: tras unos cuantos inicios de sesión en Nest, parte de los usuarios tendrán hashes Argon2id, y la plantilla de Hono verifica solo con bcryptjs y no incluye Argon2. Lo esperable es que esos usuarios no puedan entrar hasta que restablezcan la contraseña. Es una inferencia, no un resultado.

💡
Si dudas entre los dos, lo más útil que saco de esta prueba es que no tienes que apostar para siempre: con Prisma, Zod y un contrato HTTP definido, cambiar de framework es un trabajo acotado (reescribir la capa HTTP), no una migración de datos. Esa es la mejor razón para escribir el contrato antes que el código.

Lo que no he podido comprobar

Prefiero que lo veas tú aquí que descubrirlo después:

  • Por qué la plantilla de Nest sirve más peticiones que la de Hono. Mi hipótesis es que la pila de la plantilla (los middlewares, el registro de peticiones y la validación) cuesta más en Hono que lo que cuesta el contenedor de Nest. No perfilé ninguna de las dos, y el hello world demuestra lo contrario para el framework solo.
  • Que bcrypt sea la causa de que los tests de Hono tarden 82 s. Es coherente con el micro-benchmark del hash (284 ms frente a 7,9–9,9 ms por verificación el 25 de octubre; 264 y 9,5 el 24), pero no cambié las rondas de bcrypt en los tests. Hubo además una pasada con 32 fallos que no he podido reproducir ni explicar (las tres siguientes pasaron limpias). Aparte, 3 fallos que sí fueron culpa mía: variables de entorno que se me habían quedado exportadas.
  • El techo de autocannon. Con un generador de un solo hilo, las 78.000 peticiones por segundo de Hono pueden ser un mínimo.
  • La plantilla de Hono en Workers o Deno, y la de Nest en Deno y Workers. Solo probé Bun.
  • hc<AppType> en un frontend real, y la generación de clientes desde el OpenAPI de Nest.
  • La memoria en el contenedor. docker stats y la memoria residente no miden lo mismo, y son medidas sueltas; la de Nest subió con el PR (79,4 → 93,8 MiB) sin que sepa por qué. Con los PR, las dos imágenes sí usan ya el mismo Node 24.
  • Un SBOM, Coolify, SMTP real, Turso o varias instancias. Nada de eso está en esta prueba.
  • La causa de que 3 pruebas de ráfagas de login fallen en Bun con Hono (siguen fallando con el PR #23), y la razón de que el admin aparezca ya en Argon2id en la prueba de migración.
  • Mi lista de descargas no descuenta instalaciones transitivas.
  • La CI de GitHub con los cambios de los PR. Los pasos los ejecuté en local (construcción, docker-smoke.sh, Trivy 0.76.1, arnés de contrato, pruebas con cobertura), pero ningún flujo de GitHub Actions ha corrido con ellos, y el cambio de CI de Hono ni siquiera está en el PR (el token no tenía permiso para tocar workflows).
  • Lo que no repetí tras los PR: el volcado petición a petición del contrato, la prueba de migración entre plantillas, Bun con la plantilla de Nest, el rendimiento del framework solo, Bruno y test-api.sh en Nest, y los experimentos de mutaciones (solo repetí el M8 de Nest). Esas cifras son del 24 de octubre sobre main.
  • Argon2 en la plantilla de Hono. El PR #23 lo deja como seguimiento y no lo he implementado ni medido en la propia plantilla.
  • Los trade-offs de diseño que no toqué en ninguna de las dos: el bloqueo de cuenta permite dejar fuera durante 15 minutos a un usuario cuyo correo conozcas (denegación de servicio dirigida); el registro revela si un correo ya existe (enumeración de usuarios); y la cobertura del rate limit hay que revisarla ruta a ruta. Los dos PR los señalan y los dejan a decisión del dueño. Los he comprobado en la plantilla de Nest (su artículo los detalla); en la de Hono los traslada su descripción del PR, y no los he vuelto a ejecutar aquí.

Lo que corregí en mis propias plantillas, y lo que queda

Esto es lo que encontré midiendo, lo que ya está corregido (en los PR, ya fusionados) y lo que queda:

  1. NestJS (PR #8): hecho: overrides de multer (npm audit y Trivy a 0), cinco dependencias sin uso fuera, logs sin colores ANSI en producción, validar el entorno antes de migrar en el entrypoint, imagen de 719 a 570 MB y 22 pruebas nuevas (84). Queda: logs en JSON, una prueba de concurrencia que distinga el código atómico en SQLite, los cambios de CI y los trade-offs de diseño de arriba.
  2. Hono (PR #23): hecho: dependencias subidas (npm audit de 9 a 0), motor de Prisma precompilado en la imagen (ya arranca con --read-only), npm, corepack, yarn y typescript fuera de la imagen final (Trivy de 38 a 0), compose endurecido y publicado en 127.0.0.1, scripts/docker-smoke.sh, Dependabot y 4 pruebas nuevas (148). Queda: Trivy y el resto del cambio de CI (preparado como parche, no subido), Argon2, el auto-baseline del entrypoint y los trade-offs de diseño.
  3. Node: hecho en las dos: imagen en node:24-alpine fijada por digest (Hono estaba en Node 20, por debajo de su propio engines ≥ 22.12; Nest, en la 25, sin LTS). Cuando salga la siguiente LTS habrá que cambiar el digest (Dependabot propone el bump).
  4. Hono, para Bun: hecho: app.ts separado de index.ts. Queda por entender el 92 de 95.
  5. Un cambio de comportamiento que debes conocer: el docker-compose.yml de Hono ahora publica 127.0.0.1:3011 y no todas las interfaces; pon un proxy inverso delante (como ya recomienda su README) o cambia el mapeo de puertos.

Conclusión

Si me preguntas cuál es «mejor», la honesta es que no hay uno. Con la misma API, el mismo esquema de datos y el mismo contrato, los dos hacen el trabajo y las diferencias de verdad están en otro sitio:

  • El framework solo: Hono es mucho más ligero y más rápido (1,8–1,9 veces), pero eso apenas se nota cuando le pones encima Prisma, validación y una API con autenticación. En mi plantilla completa, Nest sirvió más peticiones, en las dos pasadas (+20 % a +50 % en la segunda).
  • El hash de contraseñas: la diferencia más grande de mis medidas (288 ms frente a 33 ms por login) no es de ningún framework; es una decisión de la plantilla. Mira ahí antes de culpar a nadie.
  • El peso y la superficie de actualizaciones: Hono gana por un margen razonable (unos 40 MB y menos de la mitad de paquetes), pero lo que más pesa es Prisma en los dos.
  • Lo que fallaba en las dos plantillas era de las plantillas, no de los frameworks: avisos de dependencias, una imagen con npm dentro, una versión de Node sin soporte o sin LTS. Lo corregí en los dos PR (ya fusionados), y con ellos las dos pasan npm audit y Trivy con 0 hoy, con 547 y 570 MB de imagen.
  • La estructura y el equipo: Nest te impone una forma de trabajar; Hono te deja libertad y te exige disciplina. Esa es la decisión real, y los números de rendimiento no la contestan.

Si quieres seguir leyendo: las plantillas, hono-prisma-production-starter y nestjs-prisma-production-starter tienen cada una su artículo; el detalle de cada framework está en Hono a fondo, Express a fondo, Fastify a fondo y NestJS 12 a fondo; la comparativa con servidores mínimos es Hono, Fastify, Express y NestJS: comparativa. Si despliegas Hono en el borde, empieza por Cloudflare Workers a fondo y el tutorial de la API de tareas serverless; para exponer una API desde casa o un servidor, Cloudflare Tunnel a fondo, WAF y DDoS en Cloudflare y Coolify, el PaaS autoalojado.