Blog

We ran one Spring Boot app on Lambda five ways. The defaults were the problem

On 11 October 2026 we ran one application on AWS Lambda five different ways and measured each one. The application is Spring PetClinic, split into seven functions, backed by PostgreSQL through RDS Proxy. Every function ran on arm64 with 1024 MB, behind API Gateway's HTTP API, in eu-central-1.

Version How it runs
Quarkus native Quarkus 3.38.2, compiled with GraalVM, no JVM
Spring Boot native Spring Boot 4.1.1, compiled with GraalVM, no JVM
Spring Boot on SnapStart, as shipped Java 25 runtime, SnapStart switched on, no other tuning
Spring Boot on SnapStart, tuned The same, with the tuning in this post
Quarkus on SnapStart, tuned Quarkus on the Java 25 runtime, with the tuning in this post

Where we ended up:

Version Cold start (median) Warm request (average)
Quarkus native 653 ms 12 ms
Spring Boot native 1,097 ms 18 ms
Spring Boot on SnapStart, tuned 1,218 ms 21 ms
Quarkus on SnapStart, tuned 1,369 ms 18 ms
Spring Boot on SnapStart, as shipped 3,451 ms 59 ms

Cold start is the init or snapshot restore Lambda reports, plus the first request, over about 360 cold starts per version. Warm is the server-side duration Lambda reports, averaged over two five-minute load runs. The four tuned versions were measured with Hibernate batch fetching on; the as-shipped version has it off, as Spring Boot ships.

The gap between the last row and the others is the subject of this post. Nothing in it is exotic. Each gap came from a default, and each sat a layer below the application code: in what the JVM had loaded, in the connections inside a pool, or in the SQL Hibernate wrote. The application code itself never changed.

How we measured

For each version we sent 30 requests at once to each of six functions, which makes Lambda start 30 new instances of each, then read the init, restore and request times Lambda logs for every one. We did that twice per version. The load tests ran from a container inside AWS, so no laptop network was in the numbers. Before any of it counted, every version had to return identical responses to the same 20 API requests, so we were comparing one application and not five slightly different ones.

SnapStart restores an app that has never served a request

SnapStart starts your function once, snapshots its memory, and restores new instances from the snapshot. The restore itself was quick: under a second at the median in every version. The first request after it was not: 2,449 ms at the median on Spring Boot as shipped, against 49 ms on Spring Boot native.

Every one of those requests returned 200. A team that switched SnapStart on and checked that the function worked would see a working function, and a cold start that was still slow.

The reason is what the snapshot contains. It is taken after Spring has started but before anything has called it, so none of the code that handles a request has been loaded or run. The fix is to run a request through the application before the snapshot, which AWS calls priming. It took the first request from 2,449 ms to 293 ms, and the whole cold start from 3.5 s to 1.2 s.

Where you prime matters. It has to wait until the application has fully started, and when that happens depends on how the Lambda adapter starts it. For Spring we prime in the handler's constructor; for Quarkus, in a hook that runs just before the snapshot.

Quarkus surprised us here. It starts the application before its snapshot by default, so we expected it to need less help. It did not: its first request after a restore took 2.8 s until we primed it, and 497 ms after.

The snapshot keeps your database connections too

A connection pool that is open when the snapshot is taken is restored with it, and those connections are no longer live. Spring Boot's pool, Hikari, checks a connection that has been idle before handing it out and replaces it if needed. Quarkus's pool does not check by default, so we turned the same check on. We also had both frameworks empty their pools just before the snapshot, so a restored instance starts with fresh connections.

These settings keep a restored instance working correctly. They don't make it faster: Quarkus's first request still took 2.8 s with them in place, until we added priming. Priming is what fixed the time.

One Hibernate default cost Spring 30 ms a request

Once warm, Spring Boot native looked four times slower than Quarkus native: 48.6 ms a request against 11.7. That is the kind of number that ends up in a slide as "Spring is slow." It was not mostly Spring.

The per-function numbers showed it. Functions that read one table were less than twice as slow on Spring. Functions that load related rows were up to five times slower. When Hibernate loads a list of owners and then their pets, it can fetch the pets for many owners at once or run one query per owner. Quarkus batches by default. Spring Boot does not, so GET /api/owners ran 11 queries on Spring and 2 with batching on.

The fix is one property, and it is the one thing in this post worth copying today:

spring.jpa.properties.hibernate.default_batch_fetch_size=16
Function Quarkus native Spring Boot native, default Spring Boot native, batch size 16
owners 9.5 ms 42.7 ms 15.9 ms
pets 17.9 ms 95.5 ms 24.0 ms
vets 9.8 ms 30.1 ms 16.9 ms
visits 11.9 ms 19.7 ms 17.5 ms
pet types 8.6 ms 16.1 ms 17.3 ms
specialties 8.2 ms 13.9 ms 13.7 ms
All functions 11.7 ms 48.6 ms 18.2 ms

A second run with batching averaged 18.5 ms. What is left, about 6 ms a request, is Spring's own overhead against Quarkus: about a fifth of the gap we started with. This applies to Spring Boot with Hibernate anywhere, not only on Lambda. In a real service with deeper object graphs, the extra queries usually cost more than they do in PetClinic.

Size the pool for many small instances

On Lambda, every instance of every function holds its own connection pool. A burst of 30 new instances of each of six functions needs far more database connections than a few long-running containers do. The smallest database's proxy allowed 36, which was not enough, so we ran on a larger one (853) and kept each pool to one or two connections. A pool of ten, Hikari's default, is fine in a container. Multiplied by every concurrent Lambda instance, it is what runs a database out of connections the first time traffic spikes.

What native costs at build time

The native versions had the fastest cold starts and the least memory: 109 MB on a Quarkus native cold start, 204 MB on Spring Boot native, around 300 MB on SnapStart. You pay for that at build time. On our build machine, building all seven functions took 7 minutes 13 seconds on Quarkus native and 11 minutes 11 seconds on Spring Boot native, against under a minute for the SnapStart versions. Each Spring Boot native build peaked at about 11 GB of memory, against about 6 GB for Quarkus, which is worth planning for when you size CI runners.

There is also a billing detail: Lambda bills a cold start's init along with the request. A typical Quarkus native cold start here spent 578 ms in init and 53 ms on the request, and was billed for both.

Existing code: it depends on the team

All four tuned versions handled the load with no failed requests. For a service that already exists, which one we recommend depends more on the team and the build pipeline than on these numbers:

  • Quarkus native was fastest and smallest. It means porting the Spring parts of the code to Quarkus, and running native builds.
  • Spring Boot native keeps the code in Spring. It needs CI with room for a large native build, and libraries that use reflection may need hints. None of ours did.
  • Spring Boot on SnapStart, tuned is the smallest change to the code and the pipeline, and the answer when a library will not compile native. It also has the most settings to get right.
  • Quarkus on SnapStart, tuned suits a team already on Quarkus that cannot run native builds.

New code: Quarkus native

For a new service the choice is different, because the main reason to stay on Spring is gone: there is no code to port and no Spring setup to preserve. When we built Duty27, our EU VAT API, from scratch, we chose Quarkus native as the fastest and most reliable option (why Java at all), and this run backs that up:

  • It was fastest and smallest. The fastest cold start and warm requests here, and about half the memory of Spring Boot native.
  • It needed none of the snapshot or batching tuning. No snapshot to prime, and batch fetching on by default.
  • Its native build is lighter than Spring's. 7 minutes against 11 on our machine, in about 6 GB of memory against 11.
  • Its core APIs are standards. Quarkus's REST endpoints and dependency injection are Jakarta REST and Jakarta CDI.

The cost is that a team that works in Spring has to learn Quarkus. On new code that is a learning cost, not a migration, and we would make the same trade again.

What this does not show

This is one small application, at one memory size, in one region, under light load: 20 users, about 35 requests a second, never more than ten instances of any function at once. We only load-tested reads. Quarkus native's cold start also moved between our two rounds, from 831 ms to 588 ms, all of it in the init Lambda reports, and we do not know why. The medians above combine both rounds.

A production service has more libraries, more connection pools and deeper object graphs than PetClinic, and its own set of defaults. Tracing problems like these to their cause is what we have done for years. If you are weighing a move to Lambda, we run this same measurement against your service first, so you see your own numbers before you commit to a path. Get in touch.