La primera tabla me dejó incómodo: en mi máquina, con el workload realista del lab, Embedded GlassFish parecía ganarle a Spring Boot. Si publicaba ahí, el post tenía más punch, pero también era metodológicamente flojo. Paré la pelota y agregué lo que faltaba: JDK soportado para todos, warmup separado, ventanas medidas más largas, heap fijo, pool settings explícitos y pg_stat_statements para atribuir la base. Con eso, la conclusión cambió.
Este post no intenta decidir quién “gana para siempre”. Cuenta cómo cambió mi lectura cuando el benchmark se volvió justo. Y por qué, si hoy arranco un greenfield con un equipo que ya vive en Spring, sigo eligiendo Spring Boot; pero si estoy en una organización con Payara/Jakarta, pruebo Payara Micro; y si hay código Jakarta que busca ejecutable liviano, Embedded GlassFish entra a la conversación.
Por qué hice este experimento
- Venía con una idea fácil de repetir: “Spring Boot siempre es la opción obvia”. Quería desafiarla con evidencia, no con intuición.
- Me interesa Jakarta EE moderno sin nostalgia. Quería ver si hay espacio real hoy, no en 2012.
- Evité el Hello World. Armé una API chica pero realista, DB-heavy, con reads, writes, agregaciones y carga mixta.
- El objetivo editorial es simple: decidir con mediciones que se puedan defender, no con folklore.
El sistema que implementé
El dominio del lab fue shipment-intelligence, misma API servida en tres runtimes: Spring Boot, Embedded GlassFish y Payara Micro.
- PostgreSQL con dataset determinístico grande (100k envíos).
- Tracking read por trackingId.
- Summaries operativos (ruta y volúmenes).
- Delayed shipments paginados.
- Event ingestion real a la base.
- Health/readiness.
- k6 como generador de carga con escenarios compartidos.
- Medición de RSS, GC logs, stdout/stderr de los runtimes y pg_stat_statements para entender el costo de la DB.
No muestro código acá. Todo lo que importa para este post es que las tres versiones implementan el mismo contrato HTTP y apuntan a la misma base, con los mismos escenarios k6.
Cómo cambió la conclusión a medida que mejoré la metodología
El giro narrativo de este lab se explica con dos fotos: Phase 2 y Phase 4. La primera es la tentación de publicar rápido. La segunda es cuando el experimento se vuelve defendible.
Tabla Phase 2 (realistic operational benchmark, 3 corridas por runtime)
| Runtime | Median p50 | Median p95 | Median p99 | Median throughput |
|---|---|---|---|---|
| Embedded GlassFish | 4.66 ms | 58.77 ms | 111.85 ms | 86.46 req/s |
| Payara Micro | 16.32 ms | 135.76 ms | 238.61 ms | 71.17 req/s |
| Spring Boot | 36.59 ms | 340.50 ms | 594.74 ms | 53.36 req/s |
Lectura honesta de Phase 2: si frenaba ahí, el titular fácil era “GlassFish volvió”. Pero faltaban demasiadas cosas: no había pg_stat_statements, no capturé RSS por corrida, las muestras eran cortas, no separé warmup, el JDK no estaba uniformado y los pools no estaban todos declarados igual. Era una buena base para seguir, no para cerrar el tema.
Phase 3 agregó causalidad y complejidad (VU 10/25/50/100, tres corridas por combinación, DB attribution, RSS before/after, GC logs). GlassFish siguió fuerte en tail latency a VUs altos, Payara peleó throughput, Spring Boot se mantuvo con menor RSS. Pero apareció un warning clave: en algunas corridas Payara reclamaba JDK no soportado. Necesitaba una fase más justa.
Phase 4: el benchmark justo (base del post)
Acá está la foto que me importa para contar la historia. Controles:
- Temurin 21.0.10 para todos.
- Heap fijo: -Xms512m -Xmx512m.
- Warmup separado y ventana medida de 180s.
- Pool settings explícitos.
- pg_stat_statements reseteado después del warmup.
- Tres corridas por runtime/VU, con VUs 25 y 100.
Tabla principal Phase 4
| Runtime | VUs | Runs | Median p50 | Median p95 | Median p99 | Median throughput | Worst error rate | Check failures | Median RSS before |
|---|---|---|---|---|---|---|---|---|---|
| Spring Boot | 25 | 3 | 4.59 ms | 66.92 ms | 110.03 ms | 213.13 req/s | 0.01% | 2 | 517.5 MB |
| Payara Micro | 25 | 3 | 33.10 ms | 188.16 ms | 336.77 ms | 156.48 req/s | 0.00% | 0 | 694.3 MB |
| Embedded GlassFish | 25 | 3 | 38.03 ms | 198.83 ms | 371.96 ms | 151.26 req/s | 0.00% | 0 | 579.1 MB |
| Spring Boot | 100 | 3 | 149.36 ms | 341.69 ms | 473.41 ms | 372.56 req/s | 0.04% | 25 | 543.0 MB |
| Payara Micro | 100 | 3 | 204.61 ms | 588.31 ms | 870.53 ms | 284.29 req/s | 0.00% | 0 | 715.7 MB |
| Embedded GlassFish | 100 | 3 | 320.12 ms | 540.00 ms | 677.23 ms | 229.28 req/s | 0.01% | 5 | 593.9 MB |
Lectura editorial de Phase 4 (acotada a este workload local y a mi máquina):
- A 25 VUs, Spring Boot quedó claramente adelante en mediana de latencia y throughput, con menor RSS relativo dentro del heap fijo.
- A 100 VUs, Spring Boot también tuvo mejor p95/p99 y throughput mediano. El costo fue registrar check failures: 25 en el set de 100 VUs y 2 en 25 VUs. No lo escondo porque también habla del sistema bajo presión.
- Payara Micro fue el Jakarta EE más “limpio” por check failures en Phase 4: 0 a 25 y 0 a 100 VUs. En throughput quedó segundo y con la p50 más baja del grupo Jakarta a 100 VUs, aunque con mayor RSS.
- Embedded GlassFish siguió siendo viable y técnicamente interesante, pero dejó de liderar cuando el método se volvió más estricto.
Cómo la DB explicó parte de la historia
Con pg_stat_statements quedó claro que este lab es DB-heavy. Las agregaciones analíticas (ruta/volúmenes) dominaron la cola de latencia en presión. El tracking read, en cambio, fue barato. Eso no prueba que la diferencia venga “solo del runtime”. Muestra que la comparación se hace en un sistema donde PostgreSQL, el pool, JDBC, k6 en Docker y el host también cuentan. Es la clase de sintonía fina que quiero ver antes de sacar un titular.
La experiencia de desarrollo (breve y honesta)
- Spring Boot fue lo más rápido para iterar. No es un mérito absoluto del framework; es la realidad de un equipo chico que ya vive ahí. Config, packaging, health/readiness y observabilidad entraron casi sin pensar.
- Payara Micro se sintió pragmático si ya existe cultura WAR/Jakarta. En los runs de Phase 4 fue impecable en check failures. Requirió más interpretación de logs y detalles de runtime.
- Embedded GlassFish fue la sorpresa. Me acercó a un ejecutable Jakarta EE más liviano de lo que esperaba. No ganó la fase final, pero me hizo revisar prejuicios.
Mini mapa de evolución (de “parece que” a “conclusión justa”)
- Phase 2: GlassFish parecía ganador del workload realista.
- Phase 3: GlassFish fuerte en tail latency, Spring con menor RSS, Payara competitivo; JDK de Payara no soportado en parte de las corridas.
- Phase 4: con Temurin 21, heap fijo, warmup y ventanas largas, Spring Boot quedó con el mejor perfil local de latencia/throughput; Payara Micro sin check failures fue el Jakarta EE más limpio; GlassFish siguió viable.
- Phase 5: smoke externo en Railway, útil para portabilidad, no para performance.
Railway como smoke, no como podio
El 2026-05-25 reproduje un smoke en Railway: los tres runtimes desplegaron contra un PostgreSQL disposable, pasaron /ready, tracking read y un k6 mínimo (1 VU / 10s) sin check failures. Eso me alcanza para decir “esto se mueve fuera de mi máquina” y me cuadra con cómo vengo operando juanchi.dev en Railway. No lo uso para inferir performance de producción.
Tabla Phase 2 vs Phase 4 (qué cambió cuando el benchmark fue justo)
| Fase | Lectura rápida | Qué faltaba o qué se agregó | Quién quedó mejor posicionado |
|---|---|---|---|
| Phase 2 | GlassFish parecía liderar en p95/throughput | Sin pg_stat_statements, sin warmup separado, muestras cortas, JDK no uniformado, pools no explícitos | GlassFish (aparente), pero con método incompleto |
| Phase 4 | JDK soportado, heap fijo, warmup, 180s de ventana, pools explícitos, DB attribution | Sí a todo lo que faltaba | Spring Boot en latencia/throughput locales; Payara Micro sin check failures; GlassFish viable |
Decision tree (lo que me llevo a la práctica)
- Greenfield con equipo que ya conoce Spring: Spring Boot. Razones: menor fricción de adopción, ecosistema, observabilidad, hiring y en este lab mejor perfil local Phase 4.
- Organización con Payara/Jakarta/WAR ya instalada: probar Payara Micro antes de proponer migración. En el lab fue el Jakarta EE más limpio bajo presión (check failures 0) y competitivo en throughput.
- Código Jakarta que busca ejecutable más liviano y no necesita app server completo: evaluar Embedded GlassFish. Es más viable de lo que muchos piensan y puede ser el puente sin reescritura total.
- Discusión de migración por performance: correr un benchmark propio con el workload real de ese sistema. No alcanza con un post (ni con este).
- Si la decisión está dominada por operabilidad, integraciones y contratación: Spring Boot suele reducir riesgo para equipos como el mío.
Límites honestos (para no vender humo)
- Una sola workstation para Phase 4.
- Workload DB-heavy; no aísla runtime puro.
- Sin Kafka, PostGIS, native image, Kubernetes ni autoscaling.
- Sin soak test largo.
- Phase 5 es smoke externo, no matriz de performance.
- La experiencia de desarrollo está sesgada por familiaridad previa con Spring Boot.
- Logs de los runtimes Jakarta requieren interpretación y hay que contarlo, no esconderlo.
- Los check failures de Spring Boot en Phase 4 están preservados y mencionados; el root cause exacto no quedó completamente probado en esa sesión.
Lo que cambiaría si repito este lab mañana
- Corridas más largas todavía en presión (y un soak de varias horas) para capturar variación lenta.
- Replicación en otra máquina o en un runner CI para eliminar ruido local.
- Captura completa de consola k6 y stderr/stdout ya automatizada en el harness.
- Un pasito más en sintonía de pools iguales (Hikari en todos con mismas políticas finas) y límites de conexión en PostgreSQL para ver si la cola se mueve.
- Una versión con workload más CPU-bound (menos agregaciones pesadas) para aislar runtime/serializer.
Cómo encaja esto con mi trabajo actual
En mi día a día construyo backends Java/Spring Boot en un equipo chiquito que resuelve identidad digital, biometría, firma y storage. Hay mucha presión por entregar y por operar con confianza. Por eso, aunque Jakarta EE moderno me parezca viable (y después de este lab me lo parece más), en greenfield elijo Spring Boot. El costo marginal de ponerse en modo productivo y la claridad operativa siguen pesando. Al mismo tiempo, si llego a un cliente con Payara en producción y WARs estables, hoy tengo evidencia para decir “probemos Payara Micro y/o Embedded GlassFish antes de planear una reescritura entera”.
Qué me sorprendió en serio (el momento eureka)
El eureka fue cuando vi que, con Temurin 21 para todos, heap fijo y warmup serio, el ranking cambió. No fue que Spring “se volvió más rápido por arte de magia”; fue que la comparación se ordenó. Y que el factor dominante del p99 bajo presión estaba en la base, no en un if del framework. A partir de ahí el debate deja de ser religioso y se vuelve arquitectónico: ¿qué estoy midiendo de verdad?, ¿qué quiero optimizar?, ¿qué trade-off me conviene para este equipo?
Qué cambiaron los briefs en este post
Este post no salió de una sola generación ni de una tabla linda. Lo traté como un paquete editorial: primero armé el experimento, después escribí briefs para separar evidencia, claims permitidos, claims prohibidos y límites. Eso cambió bastante el texto final.
Los briefs me obligaron a frenar tres veces:
- No publicar Phase 2 como si fuera la verdad, aunque tenía más punch, porque todavía faltaban controles de fairness.
- No esconder los check failures de Spring Boot en Phase 4: si están en la evidencia, tienen que estar en el post.
- No vender Railway como benchmark de producción: Phase 5 fue smoke externo y portabilidad, no podio de performance.
La trazabilidad quedó pública en el repo: enterprise-runtime-lab. El tag canónico para leer el estado publicado es runtime-lab-final. También dejé el brief editorial, el mapa de evidencia y la nota de replicación Railway.
Para mí esta es la parte más importante del proceso: el brief no fue burocracia. Fue el mecanismo que evitó que el post se convirtiera en una pelea de frameworks. La historia real no es “Spring ganó”. La historia real es “la conclusión cambió cuando el benchmark dejó de ser cómodo y empezó a ser defendible”.
Notas de publicación y trazabilidad
Este post queda respaldado por evidencia pública. El lab está versionado en GitHub, con tag canónico runtime-lab-final y commit final d176ed6. Los tags por fase preservan cómo fue cambiando la metodología: scaffold, baseline, realistic benchmark, causal analysis, fairness matrix, Railway smoke y final.
Mi conclusión (opinable, pero con números al lado)
Si hoy arrancara un producto nuevo con un equipo que ya conoce Spring, uso Spring Boot. No porque “Jakarta EE no sirva”, sino porque la combinación de performance local en este lab, memoria, experiencia de desarrollo, documentación, integraciones y operación pesa. Si la organización ya tiene Jakarta EE/Payara/GlassFish, freno antes de proponer una reescritura: Payara Micro y Embedded GlassFish no ganan por default, pero merecen una prueba seria con el workload real. El resultado más importante no es “runtime X ganó”; es que las decisiones de migración deberían probarse contra el workload real, no contra intuiciones o benchmarks genéricos.
Cierro con una pregunta abierta: si mañana hay que decidir en el equipo, ¿conviene correr un benchmark propio primero o apostar por la intuición? Mi respuesta, después de este lab, quedó bastante menos romántica: primero evidencia, después preferencia.
Artículos Relacionados
Prisma query logging y PostgreSQL: dónde termina el ORM y empieza la base
Los query logs de Prisma ayudan a detectar patrones, pero si el problema vive adentro de PostgreSQL, el ORM no va a mostrártelo. Acá separo cuándo alcanza con Prisma logging y cuándo necesitás instrumentar la base directamente.
Rate limiting en aplicaciones web: qué proteger antes de elegir una librería
Antes de instalar cualquier middleware de rate limiting en Next.js, necesitás definir qué activo protegés, qué abuso esperás y qué cuesta un falso positivo. La librería es lo último. La política es lo primero.
Prisma Server Actions en Next.js 16: los patrones que funcionan y el N+1 que aparece cuando no lo esperás
Prisma en Server Actions de Next.js 16 tiene un vector de N+1 que no existe en API routes clásicas. El culpable no es el ORM — es cómo las Actions se componen. Acá están los patrones que lo previenen.
Comentarios (0)
¿Qué pensás de esto?
Dejá tu comentario en 10 segundos.
Usamos tu login solo para mostrar tu nombre y avatar. Nada de spam.