I Tried Lambda SnapStart's Container Image Support with Next.js + Lambda Web Adapter
This page has been translated by machine translation. View original
Introduction
On September 2, 2026, AWS Lambda SnapStart added support for container image functions.
Until now, SnapStart was available only for functions packaged as .zip file archives. For Node.js, Ruby, and custom base images, you enable it with a Dockerfile label or by implementing a lifecycle hook.
I enabled SnapStart on a setup that runs Next.js as a container image with Lambda Web Adapter, and compared it with a function without SnapStart using the same image. What I measured was the cold start response time when scale-out requires a new execution environment.
What I Verified
I used the same container image and function settings, and changed only whether SnapStart was enabled.
Test Environment
To measure performance, I aligned the runtime version, memory, and architecture.
| Item | Value |
|---|---|
| Region | us-east-1 |
| Architecture | arm64 |
| Memory | 512 MB |
| Timeout | 30 seconds |
| Node.js | 22.14.0 |
| Next.js | 14.2.35 |
| Lambda Web Adapter | 1.0.1 |
| Image digest | sha256:7fa4665f72fd30a518a16be56b3f79b4e812924b37f8828ee0403a83ded7564d |
| Image size | 300,656,702 bytes (about 287 MiB) |
Baseline and SnapStart use the same image digest, and I specified ApplyOn=PublishedVersions only on the SnapStart side. Both are invoked through published version 1 and an alias.
Creating the Image
The measured target is an image that runs Next.js on a Node.js base image. Lambda Web Adapter is bundled as an extension. /api/ping, which is used for measurement, returns fixed JSON. All other paths are passed to the Next.js request handler.
server.js
const http = require('http');
const { performance } = require('perf_hooks');
const next = require('next');
const port = Number(process.env.PORT || 8080);
const hostname = process.env.HOSTNAME || '0.0.0.0';
const buildId = process.env.NEXT_PUBLIC_BUILD_ID || 'unknown';
const initStartedAt = performance.now();
function log(event, extra = {}) {
console.log(JSON.stringify({
event,
buildId,
utc: new Date().toISOString(),
...extra,
}));
}
log('app_init_start');
const app = next({ dev: false, hostname, port });
const handle = app.getRequestHandler();
app.prepare().then(() => {
const server = http.createServer((req, res) => {
const requestUrl = new URL(req.url || '/', `http://${hostname}:${port}`);
if (requestUrl.pathname === '/api/ping') {
const body = JSON.stringify({
ok: true,
buildId,
service: 'nextjs-lambda-web-adapter',
});
res.statusCode = 200;
res.setHeader('content-type', 'application/json; charset=utf-8');
res.setHeader('content-length', Buffer.byteLength(body));
res.end(body);
return;
}
handle(req, res);
});
server.listen(port, hostname, () => {
log('app_ready', {
port,
initMs: Number((performance.now() - initStartedAt).toFixed(3)),
});
});
}).catch((error) => {
log('app_init_error', { message: error.message });
process.exitCode = 1;
});
package.json
{
"name": "nextjs-lambda-web-adapter-snapstart-verification",
"private": true,
"version": "1.0.0",
"scripts": {
"build": "next build"
},
"dependencies": {
"next": "14.2.35",
"react": "18.3.1",
"react-dom": "18.3.1"
}
}
pages/index.js
export default function Home() {
return (
<main>
<h1>Next.js Lambda Web Adapter SnapStart verification</h1>
<p>Build: nextjs-snapstart-arm64-v1</p>
</main>
);
}
In the Dockerfile, I set the com.amazonaws.lambda.feature.snapstart label to Allow to permit SnapStart.
FROM public.ecr.aws/docker/library/node:22.14.0-bookworm-slim
LABEL com.amazonaws.lambda.feature.snapstart="Allow"
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:1.0.1 /lambda-adapter /opt/extensions/lambda-adapter
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
NEXT_PUBLIC_BUILD_ID=nextjs-snapstart-arm64-v1 \
AWS_LWA_PORT=8080 \
AWS_LWA_READINESS_CHECK_PATH=/api/ping \
AWS_LWA_READINESS_CHECK_HEALTHY_STATUS=200 \
PORT=8080
WORKDIR /var/task
COPY next-app/package.json next-app/package-lock.json ./
RUN npm ci --omit=dev
COPY next-app/ ./
RUN npm run build
CMD ["node", "server.js"]
An image that has neither the label nor an implementation of /restore/next fails to publish a version (documentation on SnapStart hooks for container images).
Dependency resolution and next build are completed inside the Docker build, so no package manager runs at Lambda runtime.
I ran the build with the following commands.
docker run --rm --platform linux/arm64 \
-v "$ROOT/artifacts/next-app:/app" -w /app \
public.ecr.aws/docker/library/node:22.14.0-bookworm-slim \
npm install --package-lock-only --ignore-scripts
docker build --platform linux/arm64 --provenance=false -f "$ROOT/artifacts/Dockerfile" \
-t "$IMAGE_TAG" "$ROOT/artifacts"
I also generated the lockfile inside an ARM64 container and did not run npm on the host.
Creating Functions and Enabling SnapStart
First, I created a Lambda execution role and attached the managed policy for basic execution.
Trust policy
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "lambda.amazonaws.com" },
"Action": "sts:AssumeRole"
}
]
}
aws iam create-role \
--role-name "$ROLE_NAME" \
--assume-role-policy-document "file://$ROOT/artifacts/lambda-trust-policy.json" \
--description "Temporary role for Next.js Lambda Web Adapter SnapStart verification"
aws iam attach-role-policy \
--role-name "$ROLE_NAME" --policy-arn "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
To wait for the role to propagate, I waited 10 seconds here before creating the functions.
Next, I created an ECR repository and pushed the built image.
aws ecr create-repository \
--region "$REGION" \
--repository-name "$REPOSITORY" \
--image-scanning-configuration scanOnPush=false \
--image-tag-mutability IMMUTABLE
aws ecr get-login-password --region "$REGION" | docker login --username AWS --password-stdin "$REGISTRY"
docker tag "$LOCAL_IMAGE" "$REGISTRY/$REPOSITORY:$IMAGE_TAG"
docker push "$REGISTRY/$REPOSITORY:$IMAGE_TAG"
Specifying the digest of the pushed image, I created two functions: Baseline and SnapStart. The only difference is the presence of --snap-start ApplyOn=PublishedVersions.
aws lambda create-function \
--region "$REGION" --function-name "$BASELINE_FUNCTION" --package-type Image \
--code "ImageUri=$IMAGE_URI_DIGEST" --role "$ROLE_ARN" --architectures arm64 \
--memory-size 512 --timeout 30 --environment "$ENV_VARS"
aws lambda create-function \
--region "$REGION" --function-name "$SNAPSTART_FUNCTION" --package-type Image \
--code "ImageUri=$IMAGE_URI_DIGEST" --role "$ROLE_ARN" --architectures arm64 \
--memory-size 512 --timeout 30 --snap-start ApplyOn=PublishedVersions \
--environment "$ENV_VARS"
I passed the same environment variable values to both functions.
Variables={AWS_LWA_PORT=8080,AWS_LWA_READINESS_CHECK_PATH=/api/ping,AWS_LWA_READINESS_CHECK_HEALTHY_STATUS=200,NEXT_TELEMETRY_DISABLED=1,NEXT_PUBLIC_BUILD_ID=nextjs-snapstart-arm64-v1}
SnapStart can be used only with published versions and aliases that point to those versions, and it cannot be used with $LATEST (SnapStart documentation). For that reason, I also created a published version and an alias for the Baseline side to align the invocation path. On the SnapStart side, I waited until snapshot creation finished before creating the alias.
aws lambda publish-version --region "$REGION" --function-name "$SNAPSTART_FUNCTION"
aws lambda get-function-configuration \
--region "$REGION" --function-name "$SNAPSTART_FUNCTION" --qualifier "$SNAPSTART_VERSION"
# Poll every 5 seconds until State is Active and SnapStart.OptimizationStatus is On
aws lambda create-alias \
--region "$REGION" --function-name "$SNAPSTART_FUNCTION" \
--name "$SNAPSTART_ALIAS" --function-version "$SNAPSTART_VERSION"
There were three intervals I had to wait for during setup.
| Interval | Measured |
|---|---|
| Pushing the image to ECR | 18.6 seconds |
publish-version response |
1.2 seconds |
Until the published version reached OptimizationStatus: On |
55.6 seconds |
The last interval runs from 2026-09-02T23:07:39.292Z, when publish-version returned its response, to 2026-09-02T23:08:34.905Z, when I confirmed On.
As an entry point for measurement, I created a Function URL for each alias.
aws lambda create-function-url-config \
--region "$AWS_REGION" --function-name "$function_name" --qualifier "$alias" \
--auth-type NONE
aws lambda add-permission \
--region "$AWS_REGION" --function-name "$function_name" --qualifier "$alias" \
--statement-id "${RESOURCE_PREFIX}-${label}-url" --action lambda:InvokeFunctionUrl \
--principal '*' --function-url-auth-type NONE
These Function URLs have no authentication. Because the Next.js pages are exposed in addition to /api/ping, which returns a fixed body, I put only verification content there and deleted them after measurement.
Before starting the measurement, I confirmed connectivity to both aliases. /api/ping returned the same body from both.
{"ok":true,"buildId":"nextjs-snapstart-arm64-v1","service":"nextjs-lambda-web-adapter"}
The SHA-256 of the response for / was also the same for Baseline and SnapStart. The value is 54872fb3f8dfdc2aa3ae7db6be14d295f5daf2bd737cd1e2364d72c6ca906721.
You can delete the resources created for this verification with the following commands.
Cleanup commands
aws lambda delete-function-url-config --region "$AWS_REGION" --function-name "$FUNCTION" --qualifier "$ALIAS"
aws lambda delete-alias --region "$AWS_REGION" --function-name "$FUNCTION" --name "$ALIAS"
aws lambda delete-function --region "$AWS_REGION" --function-name "$FUNCTION"
aws logs delete-log-group --region "$AWS_REGION" --log-group-name "/aws/lambda/$FUNCTION"
aws ecr delete-repository --region "$AWS_REGION" --repository-name "$ECR_REPOSITORY" --force
aws iam detach-role-policy --role-name "$ROLE_NAME" --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam delete-role --role-name "$ROLE_NAME"
Cold Start Measurement
I sent two rounds of 40-concurrent bursts to each condition, with a 120-second gap between rounds. That is 80 requests per condition and 160 in total. Round 1 is the first burst after the smoke test.
For each request, I saved the x-amzn-Requestid response header and matched it against the REPORT lines in CloudWatch Logs by request ID. For both Baseline and SnapStart, 80 out of 80 requests were matched. This lets me separate, without guesswork, the requests that waited for initialization or restore from those handled by an existing execution environment.
The response time retrieval and parallel execution are done by the following part of the measurement script.
curl -sS --max-time 120 -o "$body" -D "$headers" \
-w '%{http_code} %{time_namelookup} %{time_connect} %{time_appconnect} %{time_starttransfer} %{time_total}' \
"${url%/}/api/ping"
seq 1 "$size" | xargs -P "$size" -I{} bash -c \
'"$0" --one "$1" "$2" "$3" "$4"' "$ROOT/artifacts/measure-burst.sh" "$label" "$url" "$round" "{}"
The response times of the requests that waited for initialization or restore in round 1 are as follows.
| Condition | Count | Min | Median | Max |
|---|---|---|---|---|
| Baseline | 12 | 1.1923 s | 1.4646 s | 1.6241 s |
| SnapStart | 34 | 0.9799 s | 1.0754 s | 1.3015 s |
In the minimum, median, and maximum alike, SnapStart was shorter. Both were 40-concurrent bursts, but the number of requests that needed a new execution environment differed: 12 for Baseline and 34 for SnapStart. The remaining requests were handled by existing execution environments. This response time serves as the baseline for comparison.
| Condition | Count | Min | Median | Max |
|---|---|---|---|---|
| Baseline | 28 | 0.5332 s | 0.6402 s | 0.7530 s |
| SnapStart | 6 | 0.6130 s | 0.6556 s | 0.6940 s |
Taking the response time in existing environments as the baseline, the added cold start overhead at the median was 0.8244 seconds for Baseline and 0.4198 seconds for SnapStart.
You can check where the difference in the external measurement came from using the internal values recorded by Lambda.
| Condition | Recorded value | Count | Min | Median | Max |
|---|---|---|---|---|---|
| Baseline | Init Duration | 12 | 468.63 ms | 739.085 ms | 899.08 ms |
| SnapStart | Restore Duration | 34 | 343.13 ms | 415.22 ms | 649.13 ms |
At the median, restoring from a snapshot took less time than initialization.
Here is the Baseline REPORT line.
REPORT RequestId: 95af3325-b0d2-464e-9cce-70fe1d930fdd Duration: 3.82 ms Billed Duration: 755 ms Memory Size: 512 MB Max Memory Used: 113 MB Init Duration: 750.93 ms
For SnapStart, two lines were recorded: RESTORE_REPORT and REPORT.
RESTORE_REPORT Restore Duration: 410.92 ms
REPORT RequestId: 9c6d29cd-0866-4502-9fed-dfc1e9267d6a Duration: 26.22 ms Billed Duration: 27 ms Memory Size: 512 MB Max Memory Used: 113 MB Restore Duration: 410.92 ms Billed Restore Duration: 0 ms
Here is one actually matched record for each condition.
Matched measurement records
One Baseline record.
{
"round": 1,
"lambdaRequestId": "95af3325-b0d2-464e-9cce-70fe1d930fdd",
"startedAtUtc": "2026-09-02T23:08:48.081Z",
"httpStatus": 200,
"timeToFirstByteSeconds": 1.475638,
"timeTotalSeconds": 1.475952,
"requestKind": "init",
"initDurationMs": 750.93,
"restoreDurationMs": null,
"handlerDurationMs": 3.82
}
One SnapStart record.
{
"round": 1,
"lambdaRequestId": "9c6d29cd-0866-4502-9fed-dfc1e9267d6a",
"startedAtUtc": "2026-09-02T23:08:49.793Z",
"httpStatus": 200,
"timeToFirstByteSeconds": 1.075861,
"timeTotalSeconds": 1.076103,
"requestKind": "restore",
"initDurationMs": null,
"restoreDurationMs": 410.92,
"handlerDurationMs": 26.22
}
In round 2, for each of the 13 Baseline requests, the externally measured response time was below the Init Duration of the same request. The response times of these 13 requests were a minimum of 0.6483 seconds, a median of 0.7627 seconds, and a maximum of 0.8278 seconds, and the median Init Duration in the same round was 769.61 ms (about 0.77 seconds). Because the response time is shorter than the initialization time, this initialization had finished before the request arrived.
In the same round, there were 0 cases on the SnapStart side where the restore finished before the request arrived.
If you measure with only single requests after an idle period, Init Duration and Restore Duration are recorded in the logs but do not appear in the externally measured response time. This is why I measured with 40-concurrent bursts.
All 80 /api/ping requests each for Baseline and SnapStart returned HTTP 200. The SHA-256 of the response body was also a single value for both conditions: 46f03818aa4d0f40cb4997c41864ce3adbe125d89ec20de01442ab2ea0aaff38. On the SnapStart side, 40 restore logs were recorded, and 37 of them had the same request ID as the REPORT of a measured request.
The app this time does not create unique values (IDs, secrets, random seeds) at initialization, so the body does not change even when a snapshot is resumed in multiple execution environments. An app that creates unique values during initialization needs to regenerate them on the handler side (see the compatibility considerations in the SnapStart documentation).
Summary
I confirmed that SnapStart can be used with a container image that runs Next.js on Lambda Web Adapter. The image this time did not reach the scale that AWS describes as taking several seconds to start. Even so, restoring took less time than initialization, so for larger images or workloads with slow initialization, the reduction is likely to be even greater.
If you put the label in the Dockerfile, invoke through a published version and alias, and your app does not create unique values at initialization, you can expect a shorter cold start time with the code and dependencies unchanged. If you are calling $LATEST directly, you will need to move to publishing versions and invoking through an alias.
If Lambda cold starts during scale-out are a problem for you, give this update a try.
