AWS Lambda SnapStart for Java: What Cold Starts It Actually Fixes
Java on Lambda has a well-earned reputation for slow cold starts. The reason is no secret: before a Java function can handle its first invocation in a new execution environment, the JVM has to boot, the classloader has to resolve and load every class the code touches, any framework that builds a full context on startup -Spring being the best-known example, far from the only one- has to finish assembling that context, and the application's own static initializers have to run start to finish. All of that happens before the function sees its first event, and all of it repeats every time Lambda spins up a new execution environment.
SnapStart targets exactly that phase. What doesn't always come through, especially when it's pitched as "cold starts solved," is which part of startup it actually removes and which part still depends, just as before, on the code the team writes.
What SnapStart actually does
When a function version is published with SnapStart enabled, Lambda doesn't just store the code: it runs the full initialization phase -JVM boot, class loading, framework initialization, static initializers- once, in a controlled execution environment, and takes a snapshot of that environment's state once initialization finishes: the memory and disk state of the Firecracker microVM the function runs on, encrypted and cached for reuse.
When an invocation arrives that needs a new execution environment, Lambda doesn't run the initialization phase from scratch again. Instead, it restores that environment from the cached snapshot, which resumes its frozen process at the exact point where initialization had finished, with the JVM already booted, classes already loaded, and frameworks already initialized. From there, Lambda goes straight to the handler with the invocation's event.
That means SnapStart doesn't make the JVM boot faster: it makes that boot stop happening on every new environment. The initialization phase runs once per published version, not once per execution environment, which is exactly the multiplier that makes a Java cold start painful under bursty traffic.
What SnapStart doesn't touch
The point most often missed is that the snapshot captures a state, and that state gets reused as-is on every restore. Anything in the initialization code that assumes it runs exactly once per real environment -not once and then cloned for every future environment- is a direct candidate to misbehave.
The example AWS itself cites most is cryptographic material or values meant to be unique per execution: a random number generated during initialization and stored in a static field, intended as a seed or an identifier, stops being unique the moment the same snapshot gets restored to serve dozens of distinct execution environments. All of those environments boot from the same frozen point, so they all share the exact same value that was generated once, before the snapshot was taken.
The same applies to network connections opened during initialization. If the code opens a connection to a database or to any external service as part of startup -common practice to have a connection pool ready before the first invocation- that connection ends up in a socket state, and if applicable a TLS session state, that was valid at the exact moment the snapshot was taken, but has no reason to still be valid when that snapshot gets restored later, possibly minutes or hours afterward, in a different execution environment. Restoring a frozen TCP connection doesn't revive the connection on the other end.
The fix: runtime hooks
AWS doesn't leave this problem without a way out: it exposes a hooks API, built on the JVM's CRaC project (Coordinated Restore at Checkpoint), with two extension points the code can register explicitly.
import org.crac.Core;
import org.crac.Resource;
public class DatabaseConnectionManager implements Resource {
private Connection connection;
public DatabaseConnectionManager() {
Core.getGlobalContext().register(this);
}
public Connection getConnection() {
return connection;
}
@Override
public void beforeCheckpoint(org.crac.Context<? extends Resource> context) throws Exception {
if (connection != null) {
connection.close();
connection = null;
}
}
@Override
public void afterRestore(org.crac.Context<? extends Resource> context) throws Exception {
connection = DriverManager.getConnection(url, username, password);
}
}
beforeCheckpoint runs right before Lambda takes the snapshot, and it's the place to close any resource that shouldn't be frozen in a half-open state: the connection gets closed cleanly before the freeze, not after. afterRestore runs immediately after each new execution environment is restored from the snapshot, and it's the place to reopen that connection, this time independently in every environment. The same pattern applies to any value that needs to be regenerated per environment: a unique identifier, a derived key, a short-lived token fetched from an external service during initialization.
The cost of this mechanism is that it stops being automatic. SnapStart doesn't guess which part of the initialization state is safe to share across environments and which isn't: that analysis falls on the team writing the code, reviewing every static initializer and every resource opened during startup, and deciding case by case whether it needs a hook or can genuinely be frozen without consequence -a lookup table loaded from a config file, for instance, needs no hook at all, because that state is identical no matter how many times it gets restored.
What stays the same after enabling SnapStart
SnapStart removes the cost of repeating initialization on every new environment. It doesn't touch anything that happens after the handler receives the event: how long the business logic takes to run, the calls to external services each invocation makes, the database queries, the response serialization. A handler that does expensive work on every invocation keeps doing that same expensive work, snapshot or not.
It also doesn't eliminate the first snapshot itself: the initialization phase still has to run in full, at least once, when the version is published, because that run is where the state to be frozen comes from. And a poorly designed initialization -one that loads more than it needs, opens resources it never uses, does unnecessary work before the first event- is still a poorly designed initialization: SnapStart reduces how many times that cost gets paid, not how expensive it is each time it runs.
What we check before enabling SnapStart on a Java function
Before considering SnapStart fully enabled on a function, we review every static initializer and every resource opened during startup -database connections, HTTP clients holding session state, thread pools, any value generated from a source of randomness- to decide whether it needs a beforeCheckpoint/afterRestore hook or is safe to freeze into the snapshot as-is; we confirm that no identifier or cryptographic material meant to be unique per execution gets generated only during initialization without a hook to regenerate it after restore; and in any latency measurement we take afterward, we separate how much of the improvement comes from not repeating the boot from how much still depends on the handler's own code, because conflating the two leads to assuming SnapStart solved a performance problem that actually still lives inside the business logic.