DEV Community

Juan Torchia
Juan Torchia Subscriber

Posted on • Originally published at juanchi.dev

Spring Boot 2026: por qué medir solo startup time es una trampa

Hay una pregunta que aparece cada vez que alguien toca GraalVM o Spring AOT en una reunión técnica: ¿cuánto tarda en arrancar? Es la primera métrica que vuela a la pantalla, el número que cierra el debate en cinco minutos. El problema es que esa pregunta sola no alcanza para tomar ninguna decisión de arquitectura seria, y en 2026 tenemos suficiente evidencia para demostrarlo con un laboratorio reproducible.

Armé JuanTorchia/springboot-jvm-2026 (tag editorial-final-startup-matrix) exactamente con esa hipótesis de trabajo: si solo mirás startup time, estás ignorando la mitad de los costos que importan en producción.

El backend de laboratorio no es un Hello World

Elegir qué medir importa tanto como medir. Un endpoint GET /ping que devuelve {"status":"ok"} no activa el mismo grafo de beans ni el mismo comportamiento de JIT que una aplicación real. Por eso el backend del lab tiene superficie concreta:

  • POST /api/orders con Jakarta Validation sobre un record
  • GET /api/orders/{id} con Spring Data JDBC sobre PostgreSQL 17
  • POST /api/work con trabajo determinístico (CRC32 iterativo, hasta 5.000 iteraciones)
  • Flyway para migraciones, Actuator para readiness/liveness
  • HikariCP con pool configurado explícitamente en el perfil benchmark

El WorkService merece un párrafo aparte porque es el único endpoint que mezcla CPU real con una query de base de datos (countOrders()). Eso importa: sin ese endpoint, native y JVM clásica se ven prácticamente iguales en warm latency porque el JIT no tiene nada interesante que optimizar.

// WorkService.java — trabajo determinístico para forzar diferencias reales entre modos
public long calculateScore(String input, int iterations) {
    byte[] seed = input.getBytes(StandardCharsets.UTF_8);
    long score = 17;
    for (int i = 0; i < iterations; i++) {
        CRC32 crc = new CRC32();
        crc.update(seed);
        crc.update(longToBytes(score + i));
        // rotación + constante Fibonacci aurea para dispersión
        score = Long.rotateLeft(score ^ crc.getValue(), 7) + 0x9E3779B97F4A7C15L;
    }
    return score & Long.MAX_VALUE;
}
Enter fullscreen mode Exit fullscreen mode

El límite de 5_000 iteraciones no es arbitrario: lo validé con WorkServiceTest para que el cap sea predecible y el benchmark no se vuelva una prueba de throughput accidental.

Cuatro modos, cuatro superficies operativas distintas

El lab compara:

  • jvm: java -jar sobre Eclipse Temurin 21, el baseline de toda empresa que no tocó nada
  • cds: JVM con archivo AppCDS dinámico preparado en una fase separada
  • aot-jvm: Spring Boot AOT sobre JVM, con -Dspring.aot.enabled=true verificado en el contenedor
  • native: GraalVM Native Image compilado dentro de ghcr.io/graalvm/native-image-community:21

Ese último punto del AOT tiene historia. En la corrida editorial del 17 de mayo de 2026 (17:31–17:44 hora Buenos Aires), los resultados de aot-jvm no tenían sentido hasta que confirmé que el flag estaba llegando al contenedor. Sin spring.aot.enabled=true verificado en el env del runtime, el modo AOT no se diferencia del JVM clásico en startup. El results/environment.json captura eso exactamente para que cualquiera que reproduzca el lab sepa qué estaba corriendo.

El Dockerfile.native hace el build completo adentro del contenedor builder:

# Dockerfile.native — el build de native ocurre dentro del builder, no requiere GraalVM local
FROM ghcr.io/graalvm/native-image-community:21 AS builder
WORKDIR /workspace
RUN microdnf install -y maven && microdnf clean all
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
COPY src/ src/
RUN chmod +x ./mvnw && ./mvnw -Pnative -DskipTests native:compile

FROM ubuntu:24.04
# imagen final sin JRE: solo el binario compilado
COPY --from=builder /workspace/target/startup-lab /workspace/startup-lab
ENTRYPOINT ["/workspace/startup-lab"]
Enter fullscreen mode Exit fullscreen mode

Eso significa que el binario startup-lab corre sin JRE en la imagen final. Imagen más chica, startup mucho más rápido, pero el costo se desplazó completamente al build. Esa es la decisión central del modo native: no eliminás trabajo, lo movés de runtime a build time.

Lo que el número de startup no captura

En esta matriz local, native redujo el startup time y el RSS respecto a los modos JVM. Eso es cierto y reproducible en el tag editorial-final-startup-matrix. Pero ese número solo no cuenta la historia completa.

El build time de native es un orden de magnitud mayor que mvn package clásico. Si estás en un pipeline de CI con deploy frecuente, ese costo aparece en cada merge a main. No es un costo de startup: es un costo de ciclo de desarrollo.

La latencia de primer request puede diferir materialmente de la latencia warm. En JVM clásica, el primer request paga el costo de clases no cargadas y JIT frío. En native no hay JIT, así que el primer request y el request número mil tienen perfil similar. Eso puede ser una ventaja o una desventaja dependiendo del perfil de carga real.

El costo de preparación de AppCDS es un tercer momento que aparece solo en el modo cds: hay una fase de dump del archivo que corre antes de que el contenedor esté listo para tráfico. Operativamente eso implica un paso de inicialización que no existe en los otros modos, y que hay que modelar en el pipeline de deploy si CDS es la opción.

La warm latency bajo carga sostenida, el comportamiento del GC en memoria alta, y el scheduling en Kubernetes son dimensiones que este lab no mide intencionalmente. Correr tres iteraciones en Docker Desktop sobre WSL2 en Windows no es producción. Lo que el lab sí garantiza es reproducibilidad local: cualquiera puede clonar el repo y reproducir la matriz con:

# Windows — corrida editorial completa con 3 runs por modo y native habilitado
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-lab.ps1 -Preset editorial
Enter fullscreen mode Exit fullscreen mode

La decisión que el número de startup no puede tomar sola

Mi postura después de armar esto: el startup time es útil como tiebreaker cuando todo lo demás está empatado. Usarlo como métrica primaria para elegir entre JVM clásica, AppCDS, AOT-JVM y native es tomar una decisión de arquitectura con un solo eje.

Lo que sí puedo afirmar con evidencia de esta matriz:

  • Si el requisito es startup alrededor de 1,4 segundos y RSS controlado en esta matriz, native entrega eso, pero pagás con build time mayor y pérdida de JIT en warm.
  • Si el equipo necesita ciclos de CI rápidos y el startup actual es tolerable, AOT-JVM con -Dspring.aot.enabled=true mejora el arranque sin cambiar el artefacto de deploy.
  • AppCDS tiene el menor costo de cambio operativo de todos, pero tiene esa fase de preparación que hay que modelar explícitamente.
  • JVM clásica todavía es el baseline correcto para cualquier comparativa. Abandonarla sin medir los otros tres ejes es puro vibes.

No hay un ganador universal. Hay trade-offs que dependen de cuántas veces por hora escala el servicio, qué tan pesado es el pipeline de CI, y si el equipo puede asumir la complejidad operativa adicional de native.

El repo está en JuanTorchia/springboot-jvm-2026, tag editorial-final-startup-matrix. Los resultados raw están en results/raw/*.json y la matriz agregada en results/comparison.md. Si vas a citarlo, usá el wording del README: "In the editorial-final-startup-matrix tag of JuanTorchia/springboot-jvm-2026, measured locally on Windows Docker Desktop/WSL2..." — ese contexto de entorno no es un disclaimer decorativo, es parte del dato.

¿Cuál es la dimensión que más te mueve en la decisión entre estos cuatro modos? ¿Build time, warm latency, o compatibilidad de librerías en native?


Este artículo fue publicado originalmente en juanchi.dev

Top comments (0)