Sitemap
Johannes Geiger

Johannes Geiger

AWS Lambdas with Java, revisited

5 min readSep 5, 2026

--

Ever since the first time I have researched best practices, performance tweaks and tooling for the use of synchronous Java Lambdas in my day job I have kept an eye on new developments that might make developers life easier and improve the experience of using Java in Lambda context. AWS recently published that AWS Lambda now supports SnapStart for container image functions so I wanted to take a look how containerized Java Lambdas with SnapStart perform against Plain Java Lambdas and Quarkus and how the developer experience is looking like.

The Setup

To be able to make informed decisions we first need a way to reproduce comparable runtime behaviour. The following setup doesn't claim to be scientific but it tries to mimic a common use case for Lambdas, reading a small file from an S3 Bucket. The scenarios are going to be:

  1. Plain Java Lambda
  2. Plain Java Lambda with SnapStart enabled
  3. Containerized Java Lambda
  4. Containerized Java Lambda with SnapStart enabled
  5. Native Quarkus Lambda

and to provision the infrastructure and to stay on topic we are going to use CDK with Java.

The Lambdas

First, the Plain Java Lambda that is going to be used for the first four scenarios:

package com.example.myapp;

import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;

import java.io.IOException;

public class App implements RequestHandler<App.Request, App.Response> {

public record Request(String bucket, String key) {
}

public record Response(String content) {
}

private final S3Client s3Client;

public App() {
this.s3Client = S3Client.builder()
.build();
}

@Override
public Response handleRequest(final Request request, final Context context) {
final var getObjectRequest = GetObjectRequest.builder()
.bucket(request.bucket)
.key(request.key)
.build();

try (final var responseInputStream = this.s3Client.getObject(getObjectRequest)) {
final var bytes = responseInputStream.readAllBytes();
final var content = new String(bytes);

return new Response(content);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

and the Dockerfile used to create the image for scenarios 3 and 4:

FROM public.ecr.aws/lambda/java:21

COPY target/classes ${LAMBDA_TASK_ROOT}
COPY target/dependency/* ${LAMBDA_TASK_ROOT}/lib/

CMD [ "com.example.myapp.App::handleRequest" ]

Secondly, the Quarkus Java Lambda used in the fifth scenario:

package com.example.myapp;

import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;

import java.io.IOException;

public class App implements RequestHandler<App.Request, App.Response> {

public record Request(String bucket, String key) {
}

public record Response(String content) {
}

private final S3Client client;

public App() {
this.client = S3Client.builder().build();
}

@Override
public Response handleRequest(final Request request, final Context context) {
final var getObjectRequest = GetObjectRequest.builder()
.bucket(request.bucket)
.key(request.key)
.build();

try (final var responseInputStream = this.client.getObject(getObjectRequest)) {
final var bytes = responseInputStream.readAllBytes();
final var content = new String(bytes);

return new Response(content);
} catch (final IOException e) {
throw new RuntimeException(e);
}
}
}

When building on a Mac (like I did), when creating Docker images or native executables as usual cross-compile for Linux:

docker buildx build --platform linux/amd64 --provenance=false -t myapp:latest .

explicitly stating the platform as linux/amd64, respectively

quarkus build --native --no-tests -Dquarkus.native.container-build=true -Dquarkus.native.builder-image=quay.io/quarkus/ubi9-quarkus-mandrel-builder-image:25.0.4.1-Final-java25-amd64

explicitly stating the Quarkus native builder image as amd64.

The Stack

Now, having built the JAR, the Docker image and the Quarkus native executable, we can create the infrastructure:

final var bucket = Bucket.fromBucketName(this, "Bucket", "<BUCKET-NAME>");

final var lambda = Function.Builder.create(this, "MyAppLambda")
.functionName("MyApp")
.logGroup(LogGroup.Builder.create(this, "MyAppLogGroup")
.removalPolicy(RemovalPolicy.DESTROY)
.build())
.memorySize(1024)
.timeout(Duration.seconds(30))
.code(Code.fromAsset("../myapp/target/myapp.jar"))
.handler("com.example.myapp.App::handleRequest")
.runtime(Runtime.JAVA_21)
.build();
bucket.grantRead(lambda);

final var snapStartLambda = Function.Builder.create(this, "MyAppSnapStartLambda")
.functionName("MyAppSnapStart")
.logGroup(LogGroup.Builder.create(this, "MyAppSnapStartLogGroup")
.removalPolicy(RemovalPolicy.DESTROY)
.build())
.memorySize(1024)
.timeout(Duration.seconds(30))
.snapStart(SnapStartConf.ON_PUBLISHED_VERSIONS)
.code(Code.fromAsset("../myapp/target/myapp.jar"))
.handler("com.example.myapp.App::handleRequest")
.runtime(Runtime.JAVA_21)
.build();
bucket.grantRead(snapStartLambda);
Version.Builder.create(this, "MyAppSnapStartLambdaVersion")
.lambda(snapStartLambda)
.build();

final var repository = Repository.fromRepositoryArn(this, "MyAppRepository", "arn:aws:ecr:<REGION>:<ACCOUNT>:repository/myapp");

final var containerizedLambda = Function.Builder.create(this, "MyContainerizedAppLambda")
.functionName("MyContainerizedApp")
.logGroup(LogGroup.Builder.create(this, "MyContainerizedAppLogGroup")
.removalPolicy(RemovalPolicy.DESTROY)
.build())
.memorySize(1024)
.timeout(Duration.seconds(30))
.code(EcrImageCode.fromEcrImage(repository))
.handler(Handler.FROM_IMAGE)
.runtime(Runtime.FROM_IMAGE)
.build();
bucket.grantRead(containerizedLambda);

final var containerizedSnapStartLambda = Function.Builder.create(this, "MyContainerizedAppSnapStartLambda")
.functionName("MyContainerizedAppSnapStart")
.logGroup(LogGroup.Builder.create(this, "MyContainerizedAppSnapStartLogGroup")
.removalPolicy(RemovalPolicy.DESTROY)
.build())
.memorySize(1024)
.timeout(Duration.seconds(30))
.snapStart(SnapStartConf.ON_PUBLISHED_VERSIONS)
.code(EcrImageCode.fromEcrImage(repository))
.handler(Handler.FROM_IMAGE)
.runtime(Runtime.FROM_IMAGE)
.build();
bucket.grantRead(containerizedSnapStartLambda);
Version.Builder.create(this, "MyContainerizedAppSnapStartLambdaVersion")
.lambda(containerizedSnapStartLambda)
.build();

final var quarkusLambda = Function.Builder.create(this, "MyAppQuarkusLambda")
.functionName("MyAppQuarkus")
.logGroup(LogGroup.Builder.create(this, "MyAppQuarkusLogGroup")
.removalPolicy(RemovalPolicy.DESTROY)
.build())
.memorySize(1024)
.timeout(Duration.seconds(30))
.code(Code.fromAsset("../myapp-quarkus/target/function.zip"))
.handler("not.used.in.provided.runtime.")
.runtime(Runtime.PROVIDED_AL2023)
.build();
bucket.grantRead(quarkusLambda);

Note that all Lambdas run with the same memory size.

The Statistics

I invoked all five Lambdas repeatedly over the course of a day, gathering a series of data points that varied in detail but usually ended up in a similar region per type of Lambda. One detail I learnt about later is that the AWS SDK often requires to be “warmed up” manually as it’s lazily loading resources but as this “workaround” hasn’t been used anywhere I would assume it would only affect the absolute number and not the relative size of the cold start invocation compared between the different types of scenarios. The runtime for warm starts doesn't differ significantly between the Lambdas so the statistics below concentrates on cold starts.

Memory

Memory usage across the first four scenarios averaged at around 160 MB, Quarkus only used 70 MB of memory. As comparison, the same functionality written in Rust uses around 35 MB of memory.

Runtime

Runtime behaviour between the five scenarios is roughly that the Plain Java Lambda (~2.9s) runs twice as long as the Plain Java Lambda with SnapStart enabled (~1.5s), the Containerized Lambda (~2.3s) is around 25% faster than the Plain Java Lambda but significantly slower than the Plain Java Lambda with SnapStart enabled and the Containerized Java Lambda with SnapStart enabled (~1.1s) is roughly 2.5 times as fast as the Plain Java Lambda. The Quarkus Lambda finally returns in 0.5s and is thus again the fastest Lambda version between the five scenarios. Again as comparison, the same functionality written in Rust takes around 300ms.

Get Johannes Geiger’s stories in your inbox

Join Medium for free to get updates from this writer.

In addition to that, for the SnapStart enabled scenarios the Lambdas need to publish a version which adds minor complexity to the infrastructure which might affect the decision to use SnapStart or to simply switch to Quarkus instead.

Summary

The differences between the scenarios are significant, for synchronous Lambdas I don’t see any reason not to use Quarkus if using the Java ecosystem is a mandatory criterium or to move to a compiled language altogether. For asynchronous invocations where cold starts are of less relevance staying with Plain Java Lambdas is the least amount of effort although I personally really like the Quarkus developer experience and tooling so at least personally I would most likely even use Quarkus for asynchronous Lambdas.

Apart from that, if for governance or other reasons the use of containerized Java Lambdas has to be considered, SnapStart looks to be a very promising addition here, although I would like the AWS SDK to support that directly via a dedicated warming up mechanism without the need for users to perform dummy API requests during initialisation as that’s quite clunky and not very transparent at the moment.

--

--