Building a serverless architecture with Java and Quarkus

My talk at the AWS User Group UK
At the AWS User Group UK I recently talked about how we build a serverless SaaS application in Java at Ennovative Solutions, and how Quarkus helps us keep the cold starts of our Lambda functions under control.
In this post I sum up the key points. You can watch the full recording here: Creating a serverless architecture using Java & Quarkus.
Update November 2026: at the bottom of this post you'll find what has changed since and what I would do differently today.
The context: a SaaS product, fully serverless
For a SaaS product, serverless is a logical choice. You pay per request instead of per running server, every customer scales automatically, and there is no infrastructure to patch or monitor at the OS level.
Our architecture rests on the classic AWS building blocks: API Gateway as the front door, Lambda functions for the business logic and managed services for storage and events. Each function does one thing and can be deployed and scaled separately.
Choosing Java is the obvious move: it's the language our team is strongest in, with a mature ecosystem and tooling. Java on Lambda just has one well-known weak spot.
Concretely, this is PURQR, our platform that lets retailers manage their showroom through QR codes: in-store analytics, appointments and a personalised follow-up after the visit. The backend consists of seven Quarkus microservices, most of them HTTP-based behind API Gateway, with SQS for asynchronous emails and DynamoDB for storage. They have been running in production since October 2019.
The problem: Java and cold starts
A cold start is the first invocation of a Lambda function for which no warm execution environment is ready. AWS then has to set up a new environment, load your code and initialise the runtime.
For Java, that has traditionally been expensive. The JVM has to start, load and verify classes, and frameworks like Spring do a lot of work at startup through reflection and classpath scanning. A cold start of several seconds is no exception.
For a SaaS product that is noticeable: the user who happens to hit that cold function has to wait. And in an architecture with many small functions it happens more often than you'd think, especially with low traffic or right after a new deploy.
Our approach: Quarkus and native images
Quarkus tackles the problem at its source: as much work as possible happens at build time instead of at startup. Configuration, dependency injection and annotations are processed up front, leaving far less to do during a cold start.
The real gain comes from the native build. With GraalVM, Quarkus compiles your application ahead of time into a native binary. It runs without a JVM, starts in milliseconds and uses significantly less memory, which on Lambda translates directly into lower costs.
This is what the setup looks like:
- The
quarkus-amazon-lambdaextension connects your handler to the Lambda runtime. - The native build runs in a container, so the binary is compatible with Lambda's Linux environment.
- The binary is deployed as a custom runtime (
provided), with abootstrapfile as its entry point.
That comes at a price. Native builds take minutes instead of seconds, libraries that rely on reflection need extra configuration, and not every library works out of the box in a native image. That's why debugging is best done in JVM mode, with the native build as a step in the pipeline.
Wrapping up
Quarkus makes Java a first-class choice for serverless. The native build adds some extra work to your pipeline, but the gains in startup time and memory usage are more than worth it.
Are you building a serverless architecture in Java yourself, or weighing native against SnapStart? At Ennovative Solutions we're happy to think along with you. Feel free to get in touch.
Update November 2026: what has changed since
The biggest change: AWS has tackled the Java cold-start problem itself. A native build is no longer the only way to get fast Java functions.
| Date | What changed | Why it matters |
|---|---|---|
| 2026 | provided.al2 deprecated, succeeded by provided.al2023 (supported until June 2029) | Native Quarkus functions on the old custom runtime need to migrate |
| November 2025 | Java 25 runtime on Lambda, with AOT caches replacing Class Data Sharing | Faster cold starts on the plain JVM, even without SnapStart |
| November 2022 | Lambda SnapStart launched for Java 11 | Cold starts up to ten times faster, without a native build |
SnapStart in a nutshell
With SnapStart, Lambda initialises your function when you publish a version, takes a snapshot of that started environment and resumes new instances from that snapshot. Today it works for Java 11 and later, Python 3.12+ and .NET 8+, and for Java there is no extra charge.
There are conditions. SnapStart only works on published versions, not on $LATEST, and not on custom runtimes or container images. A native Quarkus binary and SnapStart are therefore mutually exclusive.
Quarkus has caught up
Quarkus 3 has built-in SnapStart optimisations in its Lambda extension, enabled by default. Classes are preloaded before the snapshot, and through the CRaC API you can initialise clients such as the DynamoDB client before the snapshot is taken (priming). That takes another big bite out of the first invocation.
Update: what I would do differently today
Today I would start with Quarkus on the managed Java runtime with SnapStart, and only reach for a native build when measurements justify it. Quarkus remains an excellent choice; what changes is the default way to deploy it.
| Approach | Cold start | Build and maintenance | When to choose |
|---|---|---|---|
| Quarkus + JVM + SnapStart | Short, especially with priming | Regular JVM build, managed runtime patched by AWS | The default for most APIs and background jobs |
Quarkus native (provided.al2023) | Shortest, plus low memory use | Slow builds, reflection configuration, maintaining the runtime yourself | High volumes where memory and cost per invocation matter |
| Quarkus + JVM without SnapStart | Longest, but better thanks to Java 25 | Simplest | Asynchronous functions where latency doesn't matter |
Three lessons I take with me:
- Measure first. Cold starts often affect only a small share of invocations. Look at your own numbers before adding complexity.
- Use the managed runtime where you can. AWS patches the JVM and the OS for you; with a custom runtime that's your responsibility, as the
provided.al2migration showed. - Design for snapshots. Unique values, network connections and credentials created during initialisation can be reused after a restore. Use the CRaC hooks to refresh them.
Sources
- Creating a serverless architecture using Java & Quarkus, AWS User Group UK, September 2021
- Improving startup performance with Lambda SnapStart, AWS documentation
- AWS Lambda now supports Java 25, AWS Compute Blog
- When to use Lambda's OS-only runtimes, AWS documentation
- AWS Lambda SnapStart Configuration, Quarkus guide
- AWS Introduces Lambda SnapStart for Java, ADTmag