Ask how to write an AWS Lambda function and the answer is usually Python, Go or Node. Java gets ruled out early, for one reason: it starts too slowly.
We built Duty27, an EU VAT API, as 17 Lambda functions written in Java. This post covers why, what the cold starts and a load test measured, and what the choice cost us. We did not build the same system in Python or Go, so there is no head-to-head comparison here. What there is: our reasons, and our own numbers.
The objection is the cold start
On a standard JVM, a new Lambda instance has to start the JVM, load and verify classes, and run your framework's startup before it can answer the first request. For a typical Spring Boot application that takes seconds. Lambda users notice seconds.
We take the JVM out of it. Each function is built with Quarkus and compiled ahead of time with GraalVM into a native Linux binary. It runs on Lambda's provided.al2023 runtime with no JVM at all. Most of what a framework normally does at startup, like scanning classes and wiring dependencies, happens at build time instead.
What the cold starts measure
These are Duty27's functions over the 30 days to 11 October 2026, taken from the Init Duration that Lambda writes to CloudWatch on every cold start. Every function is arm64 with 512 MB configured. 15 of the 17 functions had at least one cold start in that window.
| Function | Cold starts | Median init | p95 init | Slowest |
|---|---|---|---|---|
| JWT authorizer | 1,497 | 189 ms | 226 ms | 324 ms |
| VIES VAT number validation | 1,785 | 288 ms | 350 ms | 540 ms |
| VAT rate calculation | 1,680 | 333 ms | 405 ms | 592 ms |
| Public VAT rate lookup | 480 | 331 ms | 403 ms | 428 ms |
| Account | 33 | 339 ms | 350 ms | 411 ms |
| Billing (Stripe) | 14 | 357 ms | 367 ms | 367 ms |
Across all 6,306 cold starts the average init was 279 ms. The fastest was 151 ms and the slowest 592 ms. Peak memory on those invocations averaged between 53 MB and 120 MB per function, well under the 512 MB each one is given.
A different application tells the same story. When we rebuilt Spring PetClinic as Quarkus native functions for our Fargate vs. Lambda benchmark, its 23 cold starts across six functions averaged 684 ms. Once warm, the same functions averaged 12.84 ms of execution across 8,466 invocations.
Python and Go start quickly on Lambda too, and we are not claiming to beat them. The point is narrower: with a native build, the cold start is no longer a reason to leave Java. Native is not the only way there, either. We later measured Spring Boot native and tuned SnapStart against it, in We ran one Spring Boot app on Lambda five ways.
Seven times the load, zero errors
Cold starts are the first request. Everything after runs on warm instances, so we load-tested that too. On 20 September 2026 we ran 350 concurrent virtual users against Duty27's native arm64 functions: one minute ramping up, two minutes at 350, one minute ramping down. Each user sent a request, waited one second, and sent the next. Every request went through the full VAT calculation chain: token check, plan limits, the rate lookup and, for business customers, a VAT number check.
CloudWatch shows how long the functions themselves took:
| Function | Invocations | Median | p95 | p99 | Peak instances |
|---|---|---|---|---|---|
| Token check (JWT authorizer) | 51,451 | 1.34 ms | 1.87 ms | 6.88 ms | 35 |
| VAT calculation | 51,451 | 15.49 ms | 36.57 ms | 42.56 ms | 41 |
| VIES VAT number validation | 15,390 | 4.80 ms | 6.05 ms | 9.56 ms | 11 |
All three reported zero errors and zero throttles. The VAT calculation time includes its call to the validation function when there is one. The 40 invocations beyond the load test's own 51,411 requests are our scheduled warm-up pings.
The load-test client saw the same thing from outside: 51,411 requests, every one answered 200 OK, and a p95 of 246 ms. An earlier run of the same test at 50 users had a p95 of 278 ms, so seven times the load did not slow it down.
Most of that 246 ms is not our code, which accounts for under 50 ms of it. The rest is CloudFront, API Gateway, TLS and the network between the load generator and AWS.
At no point did 350 concurrent users need more than 41 instances of the busiest function, because each request finishes in milliseconds and frees the instance for the next one. This test did not look for a ceiling: the one-second pause per user caps the request rate, so it shows headroom, not a limit.
Why Java, once cold starts were off the table
Money is exact, and the compiler keeps it that way
Duty27 calculates tax on prices, so every amount is a BigDecimal with an explicit rounding rule:
private static BigDecimal round(BigDecimal value) {
return value.setScale(2, RoundingMode.HALF_UP);
}
That type is in Java's standard library. Go's standard library has no decimal type, so you choose a third-party package. Python has a decimal module, but nothing stops a float from slipping into a calculation. In Java, passing a double where the code expects a BigDecimal fails to compile.
Types that make you handle every case
A VAT number check has three outcomes, and treating two of them as one is a real bug. If the EU's VIES service is down, that does not mean the customer's VAT number is invalid. Reject them and you lose a legitimate business customer. Quietly charge VAT instead and the invoice is wrong.
We model the result as a sealed type:
public sealed interface VatValidationResult {
record Valid(...) implements VatValidationResult {}
record Invalid(...) implements VatValidationResult {}
record Unavailable(String reason) implements VatValidationResult {}
}
A switch over it must cover all three, or it does not compile. Nobody can forget the "service is down" case, because the compiler will not let them.
Mature libraries, with the security work already done
Java's ecosystem has had decades of use in banks, insurers and governments, and its core libraries have been tested by many more people than us. We lean on that instead of writing our own:
Input validation is declared on the request type with Bean Validation (Hibernate Validator), so a missing country or a negative amount is rejected before our code runs:
public record CalculationRequest( @NotBlank String countryCode, @NotNull LocalDate date, @NotNull @DecimalMin("0.00") BigDecimal amount, ...Token signatures are checked with the JDK's own
java.security(SHA256withRSA), not a hand-written crypto routine.AWS and Stripe both publish official Java SDKs, and Quarkus has extensions for the AWS services we use: DynamoDB, S3, SES and Cognito.
Go has official AWS and Stripe SDKs too. Less of the rest comes ready-made: there is no decimal type in the standard library and no standard equivalent of Bean Validation, so you pick third-party packages or write that code yourself. Python's ecosystem is large, but it gives up the compile-time checks above.
And a team that already writes Spring Boot has all of this today. Moving to Python or Go means relearning it, in a new language, under deadline.
What it cost us
Native images are not free. These are the problems we actually hit.
A native binary can behave differently from every test you run. Our billing function built a Stripe request with Stripe's typed builder. Locally, and in every JVM test, it produced the right parameters. In the deployed native binary, one nested field came out empty, and the request went to Stripe without it. GraalVM drops reflection metadata it does not know is needed, and the JVM tests could not see that because they run with full reflection. We only found it by logging the request from inside the real Lambda. The fix was to build that request as a plain Map, which needs no reflection.
The architecture has to match, and a mismatch fails silently. A native binary is built for one CPU architecture: by default, the architecture of the machine that built it. Build on an Apple Silicon Mac and you get arm64. A Lambda function with no architecture declared defaults to x86_64. Deploy one onto the other and the deploy succeeds, then every invocation fails. We saw it both ways: timeouts when testing locally, and exit status 126 from a deployed function. Every function now declares its architecture explicitly.
Native builds are heavy. Each one runs a GraalVM compile inside Docker, and it uses a lot of memory. Four running at once on our build machine got one of them killed for running out of memory, so CI now runs at most three in parallel. Each finished binary is cached by a hash of its source, so a function that did not change is not rebuilt.
None of these needed a rewrite. Each fix was small once we knew the cause. But each one was invisible until it happened, which is why we write them down.
When we would choose something else
If your team already writes Python or Go, none of this is a reason to switch. The case for Java on Lambda is strongest where we found it: when the code, the libraries and the people are already Java, and the only thing in the way was the cold start.
If that describes your Spring Boot services, the benchmark has the full measurements, and we are happy to look at your code.