Every Java developer who has tried to put a Spring Boot application on AWS Lambda has met the same wall: the cold start. The first request after a scale-up sits there for two, three, sometimes four seconds while the JVM boots, the classes load, and the Spring context wires itself together. For a batch job nobody notices. For an API a user is waiting on, it is the reason a lot of teams quietly concluded that "Java doesn't belong on Lambda" and either rewrote services in a faster-starting runtime or stayed on containers.
That conclusion is now out of date. Between AWS Lambda SnapStart and the CRaC (Coordinated Restore at Checkpoint) mechanism it builds on, you can run a real Spring Boot service on Lambda with cold starts in the low hundreds of milliseconds instead of multiple seconds — while keeping your existing Spring codebase, build, and team. This article walks through how it works, wires it up with working code, and covers the handful of gotchas that will bite you if you treat it as a magic switch.
The problem, precisely
A JVM cold start is three costs stacked on top of each other:
- Runtime init — the JVM process starts.
- Framework init — Spring Boot builds the application context: component scanning, bean creation, auto-configuration.
- First-request cost — classes not touched during init get loaded and JIT-compiled on the first real request.
For a trivial handler, (1) dominates. For a Spring Boot service, (2) is usually the biggest single cost and the one that makes people give up. Building an application context is genuinely expensive work, and doing it on the critical path of a user request is the mistake.
The insight behind SnapStart is simple: do that expensive work once, snapshot the initialized process, and restore from the snapshot instead of redoing the work.
What SnapStart actually does
When you enable SnapStart on a Java function, AWS runs your initialization once, ahead of time, and takes a Firecracker microVM snapshot of the fully initialized process — JVM warmed up, Spring context built, beans created. Instead of cold-starting from zero on each new execution environment, Lambda restores that snapshot. The expensive framework init has already happened and is frozen into the snapshot, so it doesn't recur on the request path.
For a typical Spring Boot function this turns a multi-second cold start into a restore often in the 200–400 ms range — the difference between "unusable for interactive APIs" and "fine for the great majority of workloads."

Figure 1: API Gateway → SnapStart-enabled Spring Boot function → DynamoDB / EventBridge. Initialization (JVM + Spring context + priming) happens once and is snapshotted; execution environments restore from the snapshot, and CRaC hooks close stale state before checkpoint and refresh it after restore.
Enabling it
Enabling SnapStart is configuration, not a rewrite. In SAM it is a property on the function:
Resources:
OrderApiFunction:
Type: AWS::Serverless::Function
Properties:
Runtime: java21
Handler: com.example.OrderApiHandler::handleRequest
CodeUri: target/order-api.jar
MemorySize: 1024
Timeout: 30
AutoPublishAlias: live # SnapStart requires a version/alias
SnapStart:
ApplyOn: PublishedVersions # valid values: PublishedVersions | None
Two things matter here. ApplyOn: PublishedVersions tells Lambda to snapshot the initialized environment when you publish a version. And SnapStart only applies to published versions and aliases that point to them — the $LATEST version does not use SnapStart at all. The AutoPublishAlias: live line ensures every deployment publishes a new version and moves the live alias to it, so you are always invoking a snapshotted version rather than $LATEST.
Activation is where the interesting part begins, because snapshotting a running process brings two Java-specific problems that you, not AWS, must solve.
Add the CRaC dependency
Everything below uses the CRaC API. Add its dependency to your build. Two coordinates are in common use and both expose the same org.crac interface:
<!-- Common in the Spring / Lambda ecosystem -->
<dependency>
<groupId>io.github.crac</groupId>
<artifactId>org-crac</artifactId>
<version>0.1.3</version>
</dependency>
<!-- Alternatively, the coordinate used in AWS's own Lambda docs -->
<!--
<dependency>
<groupId>org.crac</groupId>
<artifactId>crac</artifactId>
<version>1.4.0</version>
</dependency>
-->
Recent Spring Boot versions integrate with CRaC and will drive lifecycle callbacks for some managed resources automatically, so check what your Spring version already does before hand-rolling everything below. The pattern shown here is the explicit version, which works regardless.
Gotcha 1: state frozen into the snapshot goes stale
A snapshot captures your process as it was at checkpoint time — including things that must not be reused:
- Open connections. A DB or HTTP connection open at checkpoint is dead by the time the snapshot is restored, possibly days later. Reusing it fails on the first request.
-
Uniqueness and randomness. A random seed, generated ID, or cached timestamp captured at checkpoint is identical across every restored environment, because they all restore from the same snapshot. Seed a
Randomat startup and every environment now shares the same sequence.
CRaC solves this with lifecycle hooks that run around the snapshot. beforeCheckpoint() fires just before the snapshot; afterRestore() fires just after each restore. You close what shouldn't be frozen and re-establish what must be fresh:
package com.example;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.crac.Context;
import org.crac.Core;
import org.crac.Resource;
import javax.sql.DataSource;
import java.security.SecureRandom;
/**
* Owns anything unsafe to freeze into a snapshot: the connection pool
* (closed before checkpoint, reopened after restore) and the RNG
* (re-seeded after restore so every restored environment differs).
* Priming of the request path also happens in beforeCheckpoint().
*/
public class SnapshotSafeResources implements Resource {
private volatile HikariDataSource dataSource;
private volatile SecureRandom random;
private final OrderApiHandler handlerForPriming;
public SnapshotSafeResources(OrderApiHandler handlerForPriming) {
this.handlerForPriming = handlerForPriming;
this.dataSource = buildPool();
this.random = new SecureRandom();
Core.getGlobalContext().register(this); // hook into the CRaC lifecycle
}
@Override
public void beforeCheckpoint(Context<? extends Resource> ctx) {
// 1) Prime the hot path so its classes are loaded/JIT-warmed into
// the snapshot (see Gotcha 2). Best-effort; must not fail init.
try {
Priming.warm(handlerForPriming);
} catch (RuntimeException ignored) { /* priming is an optimization */ }
// 2) Close the pool so no dead connection is frozen into the snapshot.
if (dataSource != null && !dataSource.isClosed()) {
dataSource.close();
}
}
@Override
public void afterRestore(Context<? extends Resource> ctx) {
// Every restored environment gets a live pool and a fresh RNG seed.
this.dataSource = buildPool();
this.random = new SecureRandom(); // do NOT reuse a frozen seed
}
public DataSource dataSource() { return dataSource; }
public SecureRandom random() { return random; }
private HikariDataSource buildPool() {
HikariConfig cfg = new HikariConfig();
cfg.setJdbcUrl(System.getenv("JDBC_URL"));
cfg.setUsername(System.getenv("DB_USER"));
cfg.setPassword(System.getenv("DB_PASSWORD"));
cfg.setMaximumPoolSize(5);
return new HikariDataSource(cfg);
}
}
One constraint to respect: beforeCheckpoint() counts toward the initialization time limit, and afterRestore() must finish within 10 seconds or Lambda throws a SnapStartTimeoutException. Keep the hooks lean — prime what matters, don't try to warm everything.
Gotcha 2: a cold snapshot is still a cold-ish start
SnapStart removes framework-init cost, but a class never loaded during init still loads and JIT-compiles the first time a real request touches it. A snapshot of a context that never served a request is "built but never exercised," and the first restored request pays the exercising cost.
The fix is priming: run a representative request during initialization, inside beforeCheckpoint() (as wired above), so the hot classes are loaded and warmed into the snapshot.
package com.example;
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent;
/**
* Priming: exercise the real request path before the snapshot is taken so
* its classes are loaded and JIT-warmed. Best-effort — a priming failure
* must never fail initialization.
*/
public final class Priming {
private Priming() {}
static void warm(OrderApiHandler handler) {
APIGatewayProxyRequestEvent sample = new APIGatewayProxyRequestEvent();
sample.setHttpMethod("POST");
sample.setPath("/orders");
sample.setBody("{\"orderId\":\"PRIMING\",\"amount\":1}");
handler.handleRequest(sample, null); // run the hot path once
}
}
Priming is the single highest-leverage thing you can do to get from "SnapStart works" to "SnapStart is fast." It is also the step most tutorials omit — which is why people sometimes report disappointing numbers. For Spring Boot specifically, driving a full application-context invocation this way (or via an ApplicationRunner at startup) is what warms the framework's request mapping, not just your own code.
Putting it together in the handler
The handler wires the pieces: build the CRaC-managed resources at init (which registers the checkpoint hooks and arranges priming), and keep the request method idempotent because SnapStart does nothing for delivery semantics (more on that next).
package com.example;
import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent;
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent;
public class OrderApiHandler
implements RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {
// Static init runs ONCE, before the snapshot is taken. Constructing the
// resources registers the CRaC hooks and sets up priming for checkpoint.
private static final OrderApiHandler INSTANCE = new OrderApiHandler();
private static final SnapshotSafeResources RESOURCES = new SnapshotSafeResources(INSTANCE);
private static final OrderService ORDERS = new OrderService(RESOURCES);
@Override
public APIGatewayProxyResponseEvent handleRequest(
APIGatewayProxyRequestEvent event, Context context) {
Order order = OrderJson.parse(event.getBody());
// Idempotent completion: safe to call twice for the same order.
String confirmationId = ORDERS.complete(order.orderId(), order);
return new APIGatewayProxyResponseEvent()
.withStatusCode(200)
.withBody("{\"confirmationId\":\"" + confirmationId + "\"}");
}
}
What SnapStart does NOT fix
Being honest about the boundaries is what separates a usable mental model from a fragile one:
- It is not exactly-once anything. SnapStart is about startup latency; it does nothing for the correctness of your side effects. If your handler is triggered by an at-least-once source (EventBridge, SQS) and does something with side effects, you still need idempotency — a separate concern. Here is the idempotent completion referenced above, using a conditional write so a duplicate delivery returns the original result instead of acting twice:
package com.example;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.*;
import java.util.Map;
import java.util.UUID;
public class OrderService {
private final DynamoDbClient dynamo;
private final String table = System.getenv("ORDER_TABLE");
public OrderService(SnapshotSafeResources resources) {
this.dynamo = DynamoDbClient.create();
}
/**
* Complete an order exactly once. If the same orderId is completed twice
* (a retry or duplicate delivery), the conditional write fails and we
* return the ALREADY-stored confirmation rather than creating a new one.
*/
public String complete(String orderId, Order order) {
String confirmationId = "CONF-" + UUID.randomUUID();
try {
dynamo.updateItem(UpdateItemRequest.builder()
.tableName(table)
.key(Map.of("OrderId", AttributeValue.fromS(orderId)))
.updateExpression("SET #s = :done, ConfirmationId = :cid")
.conditionExpression("attribute_not_exists(ConfirmationId)")
.expressionAttributeNames(Map.of("#s", "Status"))
.expressionAttributeValues(Map.of(
":done", AttributeValue.fromS("Completed"),
":cid", AttributeValue.fromS(confirmationId)))
.build());
return confirmationId; // first time
} catch (ConditionalCheckFailedException already) {
GetItemResponse existing = dynamo.getItem(GetItemRequest.builder()
.tableName(table)
.key(Map.of("OrderId", AttributeValue.fromS(orderId)))
.projectionExpression("ConfirmationId")
.build());
return existing.item().get("ConfirmationId").s(); // replay: return stored
}
}
}
(For at-least-once sources where you would rather not add a dedicated table, I wrote about a lighter Parameter-Store approach to exactly-once processing in a previous article; SnapStart and idempotency solve orthogonal problems and you often need both.)
- It does not shrink a genuinely heavy application. If your context builds forty seconds of work, the restore is fast but the checkpoint build still has to happen and counts against init limits. SnapStart rewards lean initialization.
- It has restore cost that scales with snapshot size. Large snapshots restore more slowly; disciplined dependencies help.
-
It requires versioned invocation. You give up
$LATESTfor published versions/aliases — good practice anyway, but a change.
Benchmarks — measure your own
Numbers vary enormously with application size, memory, and priming quality, so measure your own. The honest comparison, on the same memory and payload, is three configurations:
- cold start without SnapStart,
- restore with SnapStart but no priming,
- restore with SnapStart and priming.
In practice the third is where Spring Boot on Lambda becomes genuinely viable for interactive workloads — but the only number that matters for your decision is the one from your own function. Watch the Init Duration and first-request durations in the Lambda logs, run each configuration enough times to see the distribution rather than one lucky sample, and decide on data.
When to reach for something else
SnapStart makes Spring Boot on Lambda viable; it is not always the right answer:
- For latency-critical, high-volume interactive services, provisioned concurrency or a container deployment may edge it out, at higher cost.
- For greenfield functions, a natively-fast runtime (Quarkus/GraalVM native image) starts in tens of milliseconds without a snapshot, at the cost of a more constrained build.
- For event-driven, latency-tolerant work — most background and integration workloads — SnapStart-enabled Spring Boot is often exactly right: you keep your framework, team, and governance perimeter, and pay almost nothing for the JVM's startup reputation.
Takeaways
SnapStart, with CRaC hooks and priming, retires the oldest objection to Java on Lambda. Hold the mental model precisely: snapshot the initialized process, restore instead of rebuild, close what goes stale, refresh what must be unique, and prime the request path so the snapshot is warm — and keep your side effects idempotent, because SnapStart never promised otherwise. Do that, and a Spring Boot service on Lambda starts fast enough for real users, inside the framework and governance perimeter your team already runs.
Written by Ranjith Kumar Ramakrishnan (ORCID: 0009-0007-4924-3264), an enterprise systems architect focused on AI-integrated, serverless architectures for public-safety-critical regulatory systems.
Top comments (0)