01Eine Zahl reicht nicht
"Java auf Lambda ist langsam." Das stimmt. Die Frage ist nur: wie viel, wo, und ab wann nicht mehr.
Die meisten Cold-Start-Posts da draussen messen ein Hello-World mit 1 KB Payload und schreiben dann eine Schlagzeile. Das hilft niemandem, der vor der Wahl zwischen Quarkus JVM, Quarkus Native, Node.js und SnapStart steht. Also habe ich es selbst gemessen, sauber, mit klarer Methodik und ohne Marketing-Layer.
Was du in diesem Beitrag bekommst: harte Zahlen aus eu-central-2 (Zürich), arm64, vier Runtimes nebeneinander, plus zwei Befunde, die so noch nicht in den AWS-Blog-Posts standen.
02Setup, knapp
Wenig Variablen, viele Wiederholungen.
Dieselbe Workload in allen vier Runtimes: JSON rein, UUID validieren, SHA-256 über den Payload, in DynamoDB schreiben, lesen, JSON raus. Identisch in Quarkus 3.34 und Node 24. Die Java-Codebasis ist eine, der einzige Unterschied ist mvn package versus mvn package -Dnative.
- Region: eu-central-2 (Zürich), arm64 (Graviton)
- Runtimes: Quarkus JVM auf Java 25, Quarkus Native via GraalVM, Node.js 24, JVM mit SnapStart
- Memory: 512, 1024, 1769 MB (1769 entspricht 1 vCPU)
- Payloads: 1 KB, 100 KB, 1 MB
- Iterationen: 50 Cold + 50 Warm pro Konfig (25 + 25 für SnapStart)
- Cold Starts forcieren:
update-function-configurationmit Nonce-Env-Var, dannwait function-updated - Mess-Quelle: REPORT-Zeile aus
aws lambda invoke --log-type Tail, kein Warten auf CloudWatch-Ingestion - Code und Daten: vollständiges Repo unter github.com/k-i-soft/lambda-coldstart-bench, inklusive der Roh-CSVs aus allen Messläufen unter
results/raw/
java25). Quarkus selbst bringt keine JVM mit, es ist nur das Framework. "Quarkus Native" ist via GraalVM/Mandrel zu einem statischen Binary kompiliert, ohne JVM darunter, ausgeführt auf der Custom Runtime provided.al2023. Wer einen anderen JDK-Anbieter benutzen will (Temurin, Zulu, Liberica), muss sich über eine Custom Runtime selbst was bauen, das ist nicht Teil dieser Studie.
Bewusst nicht gemessen: VPC-Lambdas (anderes Cold-Start-Profil), Provisioned Concurrency (kein Cold Start), Lambda@Edge (anderer Stack). Ehrliche Caveats sind wichtiger als breite Aussagen.
Die Tabellen unten zeigen die p50-Zahlen für 1 KB Payload. Die 100 KB- und 1 MB-Daten haben dieselbe Form und liegen in den CSVs im Repo.
03Init Duration: Memory ist (fast) egal
Die erste Überraschung kommt bei den reinen Initialisierungszeiten.
Wenn du erwartest, dass mehr Memory den Cold Start beschleunigt, weil Lambda CPU proportional zu Memory zuteilt: das stimmt nur für eine der vier Runtimes. Für die anderen drei ist die Init Duration bemerkenswert konstant.
| Runtime | 512 MB | 1024 MB | 1769 MB |
|---|---|---|---|
| Quarkus JVM | 1109 | 1112 | 1125 |
| JVM mit SnapStart | 941 | 705 | 658 |
| Quarkus Native | 390 | 387 | 390 |
| Node.js 24 | 320 | 316 | 319 |
Cold Start Init Duration p50 (ms), 1 KB Payload.
Quarkus JVM braucht ~1100 ms, egal ob 512 oder 1769 MB. Native bleibt bei ~390 ms, Node bei ~320 ms. Mehr CPU hilft offenbar nicht beim Class-Loading und CDI-Bootstrap.
Der einzige Ausreisser ist SnapStart, dessen Restore Duration sehr wohl von Memory abhängt. Plausibel: Snapshot-Deserialize ist CPU-gebunden, mehr Memory bringt mehr Threads zum Auspacken.
Erste Lehre: Wenn dein Engpass wirklich Init ist, hilft mehr Memory nicht. Du musst die Runtime wechseln.
04Total Cold Duration: hier kippt das Bild
Init Duration ist nur die halbe Geschichte. Was der User erlebt, ist Init plus erste Ausführung.
Die JVM hat ein zweites Problem, das in der Init Duration nicht sichtbar ist: der JIT compiliert erst beim ersten echten Invoke. Diese Compile-Arbeit landet als Duration im REPORT, nicht als Init Duration. Und sie ist proportional zur verfügbaren CPU.
| Runtime | 512 MB | 1024 MB | 1769 MB |
|---|---|---|---|
| Quarkus JVM | 5778 | 3320 | 2397 |
| JVM mit SnapStart | 10315 | 5463 | 3553 |
| Quarkus Native | 437 | 425 | 432 |
| Node.js 24 | 541 | 430 | 407 |
Cold Start Total Duration p50 (ms), 1 KB Payload (Init + erste Ausführung).
Hier passiert was Wichtiges: Quarkus JVM bei 512 MB braucht 5.8 Sekunden bis zur ersten Antwort. Bei 1769 MB sind es noch 2.4 Sekunden. Mehr CPU lässt den JIT schneller arbeiten und drückt die Total-Cold-Latenz drastisch.
Native und Node sind in einer komplett anderen Liga, beide unter 600 ms, weil keine JIT-Compile-Phase nötig ist. Zwischen Native und Node entscheidet nur noch das Bisschen Bootstrap-Overhead. Native ist sogar leicht schneller bei Total Cold.
Der wichtigste Befund: Mehr Memory hilft nur indirekt, über den JIT, nicht über den Init. Wenn du mit JVM auf Lambda lebst und unter 1 Sekunde Cold Start brauchst, musst du über 1769 MB hinaus oder zu Native wechseln.
05Warm: alles wird gleich
Sobald die Container warm sind, verschwindet der Unterschied fast vollständig.
| Runtime | 512 MB | 1024 MB | 1769 MB |
|---|---|---|---|
| Quarkus JVM | 23 | 14 | 15 |
| JVM mit SnapStart | 51 | 21 | 17 |
| Quarkus Native | 11 | 11 | 10 |
| Node.js 24 | 10 | 11 | 10 |
Warm Duration p50 (ms), 1 KB Payload.
Native und Node bei 10-11 ms, JVM bei 14-15 ms, SnapStart leicht dahinter. Bei realen Lasten mit gehaltenen Containern ist die Runtime-Wahl performance-technisch fast egal. Die ganze Cold-Start-Diskussion reduziert sich auf das Init-Profil und die ersten ein bis zwei Aufrufe.
Der praktische Schluss: Wenn deine Function genug Traffic hat um warm zu bleiben, ist auch JVM auf Lambda akzeptabel. Wenn sie sporadisch läuft (Webhooks, Cron-Trigger, niedrige Latenz-Anforderungen am ersten Request), brauchst du Native oder Node.
06SnapStart, der unerwartete Verlierer
AWS verkauft SnapStart als 10x schnelleren Cold Start für Java. Meine naive Out-of-the-Box-Messung sagt: ohne Priming wird es sogar langsamer als Standard-JVM.
Schau noch einmal auf Tabelle 04: SnapStart Total Cold ist bei 512 MB 10315 ms, fast doppelt so lang wie Standard-JVM mit 5778 ms. Auch bei 1024 und 1769 MB liegt SnapStart hinten.
Wie kann das sein? Restore Duration (Tabelle 03) ist klar besser als Init Duration, also was läuft schief?
Die Erklärung steckt in der Mechanik: Lambda nimmt den Snapshot direkt nach Quarkus’ Init-Phase, bevor der JIT die Handler-Code-Pfade gesehen hat. Beim Restore springt Lambda direkt zur Handler-Ausführung. Da steht der JIT bei Null. Plus: AWS SDK Connections im Snapshot sind tote Sockets, der DynamoDB-Client muss komplett neu connecten.
Das Resultat: Restore ist schnell, der erste Invoke aber teurer als beim Standard-JVM-Cold-Start, weil dort der Init-Loop schon ein paar Pfade kompiliert hat.
org.crac.Resource, registriert sich beim Boot, ruft sich vor dem Snapshot einmal selbst auf). Cold Total bei 1024 MB / 1 KB: 5698 ms primed gegen 5463 ms unprimed. 4 Prozent Differenz, im Messrauschen. Priming hat in dieser Konfiguration nicht geholfen.
Warum? AWS SDK Connections im Snapshot sind nach Restore tot. Der erste Invoke nach Restore muss eine frische TCP-Connection zu DynamoDB aufbauen, einen TLS-Handshake durchziehen und das Endpoint resolven, unabhängig davon ob Priming gelaufen ist. JIT-Compilation und Class-Loading, die der Snapshot tatsächlich konserviert, tragen offenbar nicht genug zum First-Invoke-Cost bei. Die teuren Anteile (Netzwerk-Aufbau plus AWS-SDK-Connection-State) sind strukturell nicht primingbar.
Pragmatisch heisst das: SnapStart einschalten und Priming hinzufügen reicht in unserer Konfiguration nicht. Auf einer DDB-Roundtrip-lastigen Workload kann SnapStart das Versprechen nicht halten. Das “10x schneller mit Priming” gilt für Workloads die überwiegend lokal arbeiten (CPU-bound, nicht IO-bound). Sobald externe Dienste im Hot-Path sind, bleibt Native der zuverlässigere Weg zu sub-Sekunde Cold Starts.
07Was du daraus mitnimmst
Vier Empfehlungen, alle aus den Daten oben abgeleitet.
- Cold-Start-kritisch und sporadisch: Quarkus Native oder Node. Sub-Sekunde Cold, kein JIT-Risiko, vergleichbarer Aufwand
- Hoher Traffic, Container-warm: alle vier Runtimes sind brauchbar. Wähle nach Team-Skill und Ökosystem, nicht nach Cold-Start-Marketing
- JVM auf Lambda: mindestens 1024 MB, eher 1769 MB. Bei 512 MB ist die JIT-Phase brutal
- SnapStart: nur mit Priming. Naiv aktiviert macht es die Latenz schlechter, nicht besser
Und ein letzter Punkt: arm64 statt x86. Alle Messungen oben laufen auf Graviton. Du sparst Kosten und bekommst leicht bessere Werte. Es gibt 2026 keinen guten Grund mehr für x86-Lambda, ausser Legacy-Bibliotheken die nicht für arm64 gebaut sind.
08Caveats: was Priming schwierig macht
Priming klingt einfach. Sobald du es einsetzt, lernst du die Lifecycle-Details.
Was wir oben gemacht haben: der Handler implementiert org.crac.Resource, registriert sich beim CRaC-Core und ruft sich in beforeCheckpoint einmal mit einer Dummy-Payload auf. Lambda triggert das vor der Snapshot-Erstellung, JIT und SDK-Connections landen im Snapshot, beim Restore springt der erste Aufruf direkt in den warmen Code.
Klingt sauber. Ist an mehreren Stellen empfindlich:
- Echter Aufruf, echte Seiteneffekte. Unser Priming schreibt einen Dummy-Eintrag mit ID
00000000-0000-0000-0000-000000000000in DynamoDB. Pro publishter Version landet so ein Item in der Tabelle. Bei einem ernsthaften System musst du entweder einen separaten Priming-Pfad bauen, der NICHT in den echten Datentopf schreibt, oder die Einträge später wegfiltern - Connections im Snapshot sind tot nach Restore. TLS-Sessions, Sockets, HTTP-Keepalives sind bei Snapshot-Erstellung valide, beim Restore Stunden später hängt da nichts mehr dran. Das AWS SDK v2 reconnectet meist lazy, aber nicht immer sauber. Klassische JDBC-Treiber über TCP brauchen oft explizites Reconnect
- SecureRandom muss reseeded werden. Wenn der Snapshot einen geseededen
SecureRandomenthält, generieren alle Restored-Instanzen die gleiche Sequenz. UUIDs kollidieren, JWT-IDs kollidieren, Sessions kollidieren. Lambda kümmert sich um den Standard-SecureRandom, aber nur um den. Eigene RNGs musst du inafterRestoreselbst neu seeden - Cache-Inhalte werden alt. In-Memory-Caches im Snapshot sind nach Restore noch da, aber ihre Daten sind ggf. veraltet. Was beim normalen Cold Start nicht passiert (leerer Cache nach Init) wird mit SnapStart zu einem Wartungs-Thema
- Priming-Exception blockiert den Snapshot. Jede unhandled Exception in
beforeCheckpointschlägt bis zur Snapshot-Erstellung durch und lässt sie scheitern. Wir fangen alles ab (try { ... } catch (Exception ignored) {}), das ist pragmatisch, maskiert aber echte Probleme. In Produktion willst du wenigstens loggen und alarmieren - Deploy-Zeit steigt. Jeder publish-version durchläuft jetzt Init plus Priming plus Snapshot. Statt 5-10 Sekunden zählst du 20-40. Bei Canary-Releases oder Blue-Green-Switches summiert sich das über alle Functions
- Test-Coverage-Lücke. Dein Priming-Code läuft in einem anderen Lifecycle als dein Handler-Code, und in den meisten Test-Setups läuft er gar nicht. Wenn du den Handler refactoring machst und dabei eine Klasse umbenennst, fällt die Änderung in
beforeCheckpointvielleicht erst beim ersten Cold Restore in Produktion auf
Pragmatisch heisst das: Priming ist kein “an/aus-Schalter”, sondern eine bewusste Architektur-Entscheidung mit eigener Wartungslast. Wer das nicht stemmen will, fährt mit Quarkus Native oder Node ruhiger und ohne Überraschungen beim ersten Restore.
09Caveats: Native Builds und die Reflection-Falle
Native sieht in den Tabellen wie der klare Sieger aus. Die Hausaufgaben dafür macht der Build, nicht die Runtime.
GraalVM native-image ist Ahead-of-Time-Compilation: dein Code, deine Libraries, die Runtime, alles wird zur Build-Zeit zu einem statischen Binary kompiliert. Der grosse Vorteil ist Startup unter 100 ms (im Quarkus-Idealfall) und tiefer Memory-Footprint. Der Preis ist die “Closed-World-Assumption”: alles was zur Laufzeit ausgeführt werden kann, muss zur Build-Zeit sichtbar sein. Was der Compiler nicht sieht, ist nicht im Image. Punkt.
Was das praktisch bedeutet:
- Reflection ist die Hauptfalle. Klassen die nur via
Class.forName(),getDeclaredField()oder ähnlichen reflexiven APIs erreicht werden, fehlen standardmässig im Image. Der Build crasht nicht, das Binary läuft, aber zur Laufzeit kommt eineClassNotFoundExceptionoderNoSuchFieldException, oft tief in einer Library, oft erst auf einem produktiven Code-Pfad den deine Tests nicht abgedeckt haben - Gson ist das klassische Beispiel. Gson serialisiert und deserialisiert über Reflection auf Felder. Ohne Reflection-Hints findet es deine Klassen nicht und gibt entweder leere Objekte zurück oder crasht zur Laufzeit. Workaround:
@RegisterForReflectionauf jede Bean die Gson anfasst, oder zu Jackson wechseln, weil die Quarkus-Jackson-Extension die Reflection-Configs gleich mitliefert - Jackson, Hibernate, JAX-RS, Quarkus-Eigenes funktionieren problemlos, weil die Extensions die Reflection-Configs zur Build-Zeit generieren. Sobald du eine Library nutzt, für die keine Quarkus-Extension existiert (zB. eine Nischen-Crypto-Lib, ein Custom-XML-Parser, ein hauseigenes ORM), bist du selbst dran. Das ist die Stelle wo Native-Migrationen typischerweise wochenlang hängen
- Dynamic Proxies, Resources, Java-Serialization brauchen jeweils eigene Konfiguration. Resources (
application.propertiesist standardmässig dabei, deinconfig.jsonist es nicht) registrierst du via-H:IncludeResourcesoder die Quarkus-Äquivalente. Java-Serialisierung erfordert eine explizite Liste serialisierbarer Klassen, sonst Klassen-not-found zur Laufzeit - JNI-Bibliotheken sind problematisch. Wer Dependencies mit native-glibc-Bindings hat (manche Crypto-Libraries, einige XML-Parser, alte File-IO-Wrapper, JNI-basierte DB-Treiber), bekommt entweder Linker-Errors zur Build-Zeit oder Laufzeit-Crashes. Manche Libraries sind schlicht nicht native-image-kompatibel
- Tracing Agent als Erste Hilfe. GraalVM hat einen Tracing Agent der während eines normalen JVM-Laufs aufzeichnet, welche Reflection und welche Resources tatsächlich benutzt werden. Output ist eine
reflect-config.jsondie du in den Build steckst. Funktioniert gut, aber nur für Code-Pfade die deine Tests tatsächlich abdecken. Was die Tests nicht durchlaufen, fehlt im Image. Coverage wird damit plötzlich auch eine Build-Sicherheits-Frage
Plus die Build-Realität, die mit der Runtime nichts mehr zu tun hat:
- Build-Zeit: 5-15 Minuten für Quarkus Native, statt 30 Sekunden für JVM. In der CI planst du eine eigene Pipeline-Stage dafür ein, sonst blockiert sie alle Pull Requests
- Build-RAM: native-image braucht 8-12 GB RAM zur Compile-Zeit. CI-Runner mit 4 GB werfen den Build mit OOM raus
- Container-Build: cross-compile (linux/arm64-Binary von macOS aus) erfordert Docker oder Podman mit dem passenden Builder-Image.
quarkus.native.container-build=truemacht das transparent, kostet aber den Image-Pull beim ersten Lauf (~1.5 GB Mandrel-Image) - Debugging ist anders: kein JMX, kein
jmap, keinjstack, kein Live-Profiling mit den üblichen Tools. Stack-Traces sind kürzer, weil viele Methoden inlined sind.-ghilft, macht das Binary aber grösser - Peak Throughput: AOT-Optimierungen sind statisch. Der JIT in einer warmgelaufenen JVM kann adaptiv optimieren und schlägt das Native-Binary bei langlebigen Workloads oft um 10-30 Prozent. Auf Lambda mit kurzen Sessions ist das egal, für langlaufende Services kann es relevant sein
- Versions-Lock-In: das Native-Binary wird gegen eine bestimmte GraalVM-JDK-Version und ein bestimmtes glibc/Linux-Image gebaut. Wechsel der Lambda-Runtime (zB Amazon Linux 2 zu 2023) erfordert Rebuild, sonst kommen Linker-Errors. Das ist eine Bindung die du in einem Multi-Service-Stack im Auge behalten musst
Praktischer Fluss bei mir: JVM-Mode in Dev und CI-Tests (schneller Build-Cycle, voller Debug-Komfort, schnelles Feedback), Native nur für den finalen Build der nach Lambda geht. Quarkus macht das einfach durch das gleiche Maven-Profil, derselbe Code-Stand wird beidseitig validiert. Wer das umkehrt und ausschliesslich nativ baut, verliert die schnelle Iterationsschleife und entdeckt Reflection-Probleme erst in der CI.
Was Native dafür strukturell nicht hat: Snapshot-Restore-Probleme. Es gibt keinen Snapshot, jeder Cold Start initialisiert Connections frisch, es gibt keinen toten Pool im Speicher. Was bei SnapStart als “stale state nach Restore” zur Bürde wird (siehe Section 06), ist bei Native nicht relevant. Auf IO-lastigen Workloads (DDB, RDS, andere AWS-Dienste im Hot-Path) macht das den Unterschied zu SnapStart aus, der durch Priming nicht aufgehoben werden kann.
10Vergleich auf einen Blick
Drei Runtimes, zehn Aspekte. Welche Zeile den Ausschlag gibt, hängt an deinem Workload.
| Aspekt | Quarkus JVM | JVM mit SnapStart | Quarkus Native |
|---|---|---|---|
| Build-Zeit | ~30 s | ~30 s | 5-15 min |
| Build-Setup | JDK | JDK | 8-12 GB RAM, Mandrel-Container |
| Handler-Änderung | keine | CRaC-Hook plus Priming-Pfad | ggf. @RegisterForReflection |
| Cold Init/Restore p50 (1024 MB) | ~1100 ms | ~700 ms | ~390 ms |
| Cold Total p50 (1024 MB, 1 KB) | 3320 ms | 5463 ms* | 425 ms |
| Warm p50 (1024 MB, 1 KB) | 14 ms | 21 ms | 11 ms |
| Reflection | unproblematisch | unproblematisch | Hints zwingend |
| Library-Kompatibilität | volle JVM | volle JVM plus CRaC-Bewusstsein | nur native-kompatibel |
| Debugging | JMX, jstack, JFR | JMX, JFR | stark eingeschränkt |
| Deploy pro Version | ~5 s | ~20-40 s (Snapshot) | ~5 s |
| Lambda-Runtime | java25 | java25 mit SnapStart | provided.al2023 |
| Wartungslast | tief | mittel (Priming-Lifecycle) | mittel (Reflection, Build-Pipeline) |
* Wert aus der Messung ohne CRaC-Priming. Eine zweite Messreihe mit aktiviertem Priming ergab 5698 ms (1024 MB / 1 KB), also +4 Prozent im Messrauschen. Priming hat in dieser Konfiguration nicht geholfen, Erklärung in Section 06.
Wenn du nur eine Zeile vergleichst: Cold Total bei 1024 MB. Das ist die Zeit die dein User zwischen Klick und Antwort bei einer kalten Funktion sieht. Native gewinnt um Faktor 8 gegen JVM und um Faktor 13 gegen SnapStart. CRaC-Priming hat den SnapStart-Wert nicht messbar verbessert, weil Connection-State nach Restore neu aufgebaut werden muss und das der dominante Anteil am First-Invoke-Cost ist.