Java SnapStart on AWS Lambda: plain JVM vs snapshot-restore cold start
How SnapStart and priming work
SnapStart restores a pre-initialized snapshot instead of running JVM init on every cold start, replacing Init Duration with a Restore Duration. That restore marker's size depends on the handler: usually smaller than init on a heavy handler, though on a light one it can exceed init outright, so the chart's total for such a handler lands to the right of plain Java's. The chart below is the direct within-Java A/B: plain JVM vs SnapStart cold-start P50 across scenarios, architectures, and memory tiers. Plain Java and Java SnapStart are treated as two separate runtimes here, not a tuning knob, because snapshot restore and JVM init are different execution models.
Where priming is meaningful, the SnapStart variant is primed (oneclient, threeclient, smithyfull, authz, batch; the others are left unprimed), which is what makes the comparison fair. SnapStart snapshots the JVM after init, but it cannot snapshot work that only happens on the first invocation: for an SDK-heavy handler that is chiefly the AWS SDK v2's lazy class loading, the JIT of that code, and the first marshaller construction. Left alone, an SDK-heavy handler pays all of that on the measured invoke, so its unprimed SnapStart would still carry the lazy SDK work on the first restored invoke; this run does not include that unprimed SDK-heavy A/B. The fix an operator ships is to force that work to run before the checkpoint: a CRaC beforeCheckpoint hook runs one representative invocation during init, baking the warm-up into the snapshot. Priming only reaches first-invoke cost on a code path the benchmark-owned handler can exercise before the checkpoint: the SDK-heavy handlers (oneclient, threeclient, smithyfull), plus authz (no SDK, but it warms its RS256-verify path) and batch (its first parse).
Where priming reaches the dominant lazy first-invoke work (the SDK-heavy handlers), the expected ordering is primed < plain < unprimed. That ordering is not universal: on handlers where the init being skipped is small or a framework path survives priming, primed SnapStart can still trail plain Java (see which scenarios win and lose). Only primed and plain are benchmarked here, so the chart below is primed vs plain; the unprimed leg is inferred, not measured. The dashboard measures the primed config (the same jar runs the hook as a no-op when SnapStart is off). The handlers with nothing for priming to reach are left unprimed: the probes with no first-invoke SDK cost for priming to hoist (hello, lettercount, cache) and the framework-only smithy, whose first-request cost sits behind a smithy-java path no public hook can drive.
A residual first-request cost survives priming on every scenario: restore is lazy, so the first invocation likely still pages the snapshot's memory back in and JITs any path not exercised before the checkpoint. SnapStart's first request is therefore never as fast as a warm one.
After a crash or timeout, SnapStart runs a full init
SnapStart's whole premise is that a cold start restores a snapshot instead of running JVM init. That
holds for any healthy new environment: one brought up to meet load (scaling), and one Lambda brings
up after periodically recycling
a long-lived environment for maintenance (it terminates them every few hours, even under continuous
traffic) both resume from the snapshot. It does not hold for the environment Lambda brings up
after a process-ending failure. When an invocation kills the sandbox (an OutOfMemoryError, a
function timeout, or a process exit, the process-ending failures that trigger a
suppressed init
on any runtime, plus a StackOverflowError, which ends the process on the JVM, each verified
here on a SnapStart function), the replacement environment runs a
full from-scratch JVM init, not a snapshot restore.
Direct test confirms it on two independent signals. A probe whose static initializer records an id
baked into the snapshot at publish time keeps that id across every genuine restore (and across warm
reuse); after a crash the id changed, proving the static init ran again rather than being restored
from the snapshot. CloudWatch shows the same thing from the platform side: a healthy SnapStart cold
start reports a Restore Duration
(a restore, no Init Duration, since SnapStart runs init at version-create), whereas each post-crash
recovery emitted an INIT_REPORT (the full-init marker) instead, with an Init duration in the
range of plain JVM init, not the smaller restore marker. A re-test in 2026-10 (Java 21, a static
initializer that slept 8 s in the recovery, after a timeout and after a process exit) did not see
that marker for a recovery that succeeded: no INIT_START, no INIT_REPORT and no RESTORE_REPORT, only a START
line delayed by the init and a changed id. Only a recovery that ran out of time wrote one
(INIT_REPORT … Phase: invoke Status: timeout). The full init happened either way, but
in the re-test CloudWatch marked it only when it failed.
That INIT_REPORT is also where SnapStart's recovery is more visible than a plain function's: a
plain suppressed init emits no INIT_REPORT and hides its cost inside the next invocation's
Duration (see the lifecycle page),
whereas SnapStart wrote an explicit INIT_REPORT for its full init in the first probes (not in the
2026-10 re-test above). As with the
general suppressed-init case, an ordinary handler exception did not trigger this: the process
survived and the next invocation was a normal warm one. It is specifically the process-ending
failures that drop the function onto the full-init recovery path.
That full init also runs under the same time limit as any suppressed init, not the 15 minutes SnapStart allows at publish time: it was cut off at 13 s with a 3-second timeout and at 30 s with a 30-second one (see how much time a re-init gets). Since the publish-time init may run far longer, a SnapStart function whose init does not fit that window can serve normally from its snapshot and then fail every invoke after its first crash (inferred from the two limits; a long publish-time init was not tested).
The consequence is worse than in the general suppressed-init case. On a plain function a crash re-runs the init a cold start was already paying. On SnapStart a crash replaces a restore with a full init, so that recovery abandons the fast path rather than merely re-paying it, and the from-scratch JVM init it runs instead is the larger of the two costs (on an SDK-heavy handler, exactly the init SnapStart+priming was configured to skip).
When SnapStart wins, and when it loses
SnapStart is not universally faster, and on light handlers it is slower. Restoring a snapshot is itself work: on a lightweight handler the restore marker can exceed plain Java's entire JVM init (above). On top of it sits whatever the snapshot could not carry onto the first restored request: the residual restore cost every scenario pays, any lazy first-invoke work priming did not hoist, and, for a network handler, likely some client or connection state to re-establish. Plain Java pays JVM init plus that same lazy work in one summed cold-start total, so SnapStart is faster only when the init plus first-request work it removes is larger than the restore cost plus its heavier first request.
That trade-off splits the scenarios cleanly in the chart below. SnapStart wins where there is a
large init to skip: the SDK-heavy handlers that call an AWS client per invoke (oneclient,
threeclient), where priming hoists the full lazy SDK loading cost into the snapshot and the
restored first request comes out faster than plain Java's; and the two that do a heavy one-time
load at init (lettercount unprimed, batch primed), where plain Java's init-time S3 fetch plus
JVM/SDK class loading dwarfs the restore cost. It loses on hello, cache, smithy,
smithyfull, and (bar a stray near-tie tier) authz. These cold cells rest on only a handful of
cycles each, so read a single close tier off the chart rather than this prose. For most of these
there is little to skip: the small init plus a restored first request that costs more than plain
Java's never outweighs the restore. smithyfull is the exception to that reasoning; it has a large
SDK init that priming does hoist, but the framework request path stays on the first restored invoke
(see the details under the chart), and that residue is enough to keep it behind plain Java net.
Cold start P50: plain JVM vs SnapStart
batch and smithyfull, so a row appears only where both variants ran that tier.The extra penalty on the two Smithy scenarios: unprimable framework marshalling
The Smithy scenarios carry the restore-overhead cost above plus a second penalty that priming cannot remove, which is why they sit among the worst SnapStart results. Priming works by driving representative work through a CRaC beforeCheckpoint hook. For a plain RequestHandler an operator calls the handler's own entrypoint, which warms everything the measured invoke will hit. The Smithy scenarios are different: they are fronted by smithy-java's LambdaEndpoint, and the framework's first-request cost (protocol resolution, request/response (de)serialization, constraint validation, and the JIT of all of it) is reachable only through LambdaEndpoint::handleRequest, which smithy-java exposes no supported hook to drive before a checkpoint. So an operator can warm the SDK clients but not the framework marshalling. The benchmark records only restore/init/first-request timing, not which component runs when, so the likely explanation for the surviving first-request cost is that this framework marshalling stays on the first restored request.
smithyfullprimes its three SDK clients, so the restore marker drops below plain Java's init at every tier, but the unprimable framework marshalling survives into the first restored request. The surviving framework cost is large enough that SnapStart still trails plain Java net on this scenario, unlike the SDK-onlythreeclientwhere priming reaches everything and SnapStart wins.smithyhas no SDK and no AWS call, so there is nothing to hoist; it is left unprimed by design. Its SnapStart cold start carries the full framework first-request cost on top of restore overhead, which is why it trails plain Java. This is the expected result for a framework-only handler on SnapStart with the current smithy-java, not a defect.
The framework residue is a property of the smithy-java API surface used in this run (1.7.0), not of SnapStart itself: a future supported framework-level warmup hook would shrink this second penalty, though the restore-overhead floor above would remain.