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

# Hono frente a NestJS: dos plantillas de producción con el mismo contrato y números reales
- URL: https://blog.devutilites.com/hono-frente-a-nestjs/
- Published: 2026-10-25T11:58:57.000Z
- Updated: 2026-10-25T11:58:57.000Z
- Description: 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.
- Author: Luis Collantes
- Tags: Backend, Hono, NestJS

Ya tienes en el blog [mi plantilla de Hono + Prisma para APIs en producción](https://blog.devutilites.com/hono-prisma-production-starter/) y [la de NestJS](https://blog.devutilites.com/nestjs-prisma-production-starter/). 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)](https://blog.devutilites.com/hono-fastify-express-nestjs-comparativa/) 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](https://github.com/Collanteslu/hono-prisma-production-starter?ref=blog.devutilites.com) en el commit `644a5d2` y [nestjs-prisma-production-starter](https://github.com/Collanteslu/nestjs-prisma-production-starter?ref=blog.devutilites.com) 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](https://github.com/Collanteslu/hono-prisma-production-starter/pull/23?ref=blog.devutilites.com)) y Nest `623e447` ([PR #8](https://github.com/Collanteslu/nestjs-prisma-production-starter/pull/8?ref=blog.devutilites.com)); **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](https://github.com/Collanteslu/hono-prisma-production-starter/pull/23?ref=blog.devutilites.com) y [NestJS #8](https://github.com/Collanteslu/nestjs-prisma-production-starter/pull/8?ref=blog.devutilites.com). **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`:

```bash
$ 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.

```bash
$ 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):

```json
hono  LOGIN_FAILED     {"email":"admin@example.com"}
nest  LOGIN_FAILED     {"account":"f2a17a4412b0499d02ea85eec653d736"}
hono  DELETE_PERMANENT {"email":"contract-user-4ughk0jv@example.com"}
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](https://blog.devutilites.com/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](https://blog.devutilites.com/nestjs-12-a-fondo/) y, aplicado a esta plantilla, en [su artículo](https://blog.devutilites.com/nestjs-prisma-production-starter/).

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:

```ts
// 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." });
});
```

```ts
// 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)](https://blog.devutilites.com/content/images/2026/10/hono-vs-nestjs-ruta.png)

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:

```ts
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:

```ts
@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:

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

```bash
# 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):

```text
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](https://github.com/mcollina/autocannon?ref=blog.devutilites.com) 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.

```bash
$ 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](https://blog.devutilites.com/hono-fastify-express-nestjs-comparativa/) 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](https://blog.devutilites.com/content/images/2026/10/hono-vs-nestjs-medidas-v2.png)

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

```bash
$ 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`:

```bash
# 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](https://github.com/Collanteslu/hono-prisma-production-starter/pull/23?ref=blog.devutilites.com) 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ó:

```bash
$ 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](https://blog.devutilites.com/cloudflare-workers-a-fondo/) y en el tutorial [API de tareas serverless con Cloudflare, Hono, D1, R2 y Prisma](https://blog.devutilites.com/api-tareas-serverless-cloudflare-hono-d1-r2-prisma/) tienes Hono desplegado en Workers; si te interesan los límites y precios, están en [D1](https://blog.devutilites.com/cloudflare-d1-a-fondo/), [R2](https://blog.devutilites.com/cloudflare-r2-a-fondo/) y [Workers: gratis o de pago](https://blog.devutilites.com/cloudflare-workers-free-vs-paid/). 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`):

```bash
$ 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í:

```bash
$ 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](https://blog.devutilites.com/content/images/2026/10/hono-vs-nestjs-elegir.png)

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 `ana@example.com`, su hash pasó de `$2b$12$…` (60 caracteres) a `$argon2id$v=19$m=19456…` (97 caracteres).

```bash
$ node q.mjs migra.db            # antes del login de ana, con la base creada por Hono
admin@example.com  $argon2id$v=19$m=194…  (97)
ana@example.com  $2b$12$nUxHabdYJzjOt…  (60)

$ curl -X POST …/api/auth/login -d '{"email":"ana@example.com","password":"…"}'
login ana (bcrypt de Hono) -> 200

$ node q.mjs t.db                # después del login, en una copia de la base
admin@example.com  $argon2id$v=19$m=194…  (97)
ana@example.com  $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:

```bash
$ 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](https://github.com/Collanteslu/nestjs-prisma-production-starter/pull/8?ref=blog.devutilites.com)): **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](https://github.com/Collanteslu/hono-prisma-production-starter/pull/23?ref=blog.devutilites.com)): **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](https://blog.devutilites.com/hono-prisma-production-starter/) y [nestjs-prisma-production-starter](https://blog.devutilites.com/nestjs-prisma-production-starter/) tienen cada una su artículo; el detalle de cada framework está en [Hono a fondo](https://blog.devutilites.com/hono-a-fondo/), [Express a fondo](https://blog.devutilites.com/express-a-fondo/), [Fastify a fondo](https://blog.devutilites.com/fastify-a-fondo/) y [NestJS 12 a fondo](https://blog.devutilites.com/nestjs-12-a-fondo/); la comparativa con servidores mínimos es [Hono, Fastify, Express y NestJS: comparativa](https://blog.devutilites.com/hono-fastify-express-nestjs-comparativa/). Si despliegas Hono en el borde, empieza por [Cloudflare Workers a fondo](https://blog.devutilites.com/cloudflare-workers-a-fondo/) y el [tutorial de la API de tareas serverless](https://blog.devutilites.com/api-tareas-serverless-cloudflare-hono-d1-r2-prisma/); para exponer una API desde casa o un servidor, [Cloudflare Tunnel a fondo](https://blog.devutilites.com/cloudflare-tunnel-a-fondo/), [WAF y DDoS en Cloudflare](https://blog.devutilites.com/cloudflare-waf-ddos-a-fondo/) y [Coolify, el PaaS autoalojado](https://blog.devutilites.com/coolify-paas-autoalojado/).