I tried Lambda SnapStart 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 only available for .zip file archive functions. For Node.js, Ruby, and custom base images, it is enabled by specifying a Dockerfile label or implementing lifecycle hooks.
I enabled SnapStart on a configuration running Next.js with Lambda Web Adapter container images, and compared it against a function using the same image without SnapStart. The target metric was cold start response time when a scale-out event requires a new execution environment.
Verification Details
I measured using the same container image and function configuration, varying only the presence or absence of SnapStart.
Test Environment
To measure performance accurately, the runtime version, memory, and architecture were kept consistent.
| 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 (approx. 287 MiB) |
Both Baseline and SnapStart used the same image digest, with ApplyOn=PublishedVersions specified only for the SnapStart side. Both were invoked via published version 1 and an alias.
Image Creation
The measurement target is an image running Next.js on a Node.js base image. Lambda Web Adapter was bundled as an extension. The /api/ping endpoint used for measurement returns a fixed JSON response. 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, Allow was specified for the com.amazonaws.lambda.feature.snapstart label 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"]
For images that have neither the label nor a /restore/next implementation, version publishing will fail (see SnapStart hooks documentation for container images).
Dependency resolution and next build are completed during the Docker build, so no package manager runs at Lambda execution time.
The build was executed 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"
Lockfile generation was also performed inside an ARM64 container, so npm was never run on the host.
Function Creation and Enabling SnapStart
First, a Lambda execution role was created and the basic execution role managed policy was attached.
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"
A 10-second wait was added here to allow role propagation before creating the functions.
Next, an ECR repository was created and the built image was pushed.
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"
Two functions, Baseline and SnapStart, were created by specifying the digest of the pushed image. The only difference is the presence or absence 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"
The same environment variable values were passed 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 only be used with published versions and aliases pointing to those versions, not with $LATEST (see SnapStart documentation). Therefore, a published version and alias were also created for the Baseline side to keep the invocation path consistent. For the SnapStart side, the alias was created after waiting for snapshot creation to complete.
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 at 5-second intervals 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 wait periods during setup.
| Phase | Actual Time |
|---|---|
| Image push to ECR | 18.6 seconds |
publish-version response |
1.2 seconds |
Until published version reaches OptimizationStatus: On |
55.6 seconds |
The last phase spans from when publish-version returned a response at 2026-09-02T23:07:39.292Z to when On was confirmed at 2026-09-02T23:08:34.905Z.
A Function URL was created for each alias as the entry point for measurements.
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. Since Next.js pages were also exposed in addition to /api/ping, which returns a fixed body, only verification content was placed there, and everything was deleted after measurement.
Before starting measurements, connectivity was confirmed for 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 to / was also identical between Baseline and SnapStart. The value was 54872fb3f8dfdc2aa3ae7db6be14d295f5daf2bd737cd1e2364d72c6ca906721.
The resources created during verification can be deleted with the following commands.
Teardown 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
A burst of 40 concurrent requests was sent to each condition for 2 rounds, with a 120-second gap between rounds. That is 80 requests per condition and 160 total. Round 1 corresponds to the first burst after a smoke test.
For each request, the x-amzn-Requestid response header was saved and matched against the REPORT lines in CloudWatch Logs by request ID. Matching succeeded for all 80 out of 80 requests for both Baseline and SnapStart. This allows requests that waited for initialization or restoration to be separated from those handled by existing execution environments without any guesswork.
The response time retrieval and parallel execution are handled 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 for requests that waited for initialization or restoration in Round 1 are as follows.
| Condition | Count | Min | Median | Max |
|---|---|---|---|---|
| Baseline | 12 | 1.1923 sec | 1.4646 sec | 1.6241 sec |
| SnapStart | 34 | 0.9799 sec | 1.0754 sec | 1.3015 sec |
SnapStart was shorter across minimum, median, and maximum. Both were 40-concurrent bursts, but the number of cases requiring a new execution environment differed: 12 for Baseline and 34 for SnapStart. The remaining requests were handled by existing execution environments. These response times serve as the comparison baseline.
| Condition | Count | Min | Median | Max |
|---|---|---|---|---|
| Baseline | 28 | 0.5332 sec | 0.6402 sec | 0.7530 sec |
| SnapStart | 6 | 0.6130 sec | 0.6556 sec | 0.6940 sec |
Using the response times from existing environments as a baseline, the cold start overhead was 0.8244 seconds at the median for Baseline and 0.4198 seconds for SnapStart.
The source of the external difference can be confirmed 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, restoration from a snapshot was shorter than initialization.
Here is a 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
One matched record is shown below 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 cases, the external response time fell below the Init Duration of the same request. The response times for these 13 cases ranged from a minimum of 0.6483 seconds to a median of 0.7627 seconds and a maximum of 0.8278 seconds, while the median Init Duration in the same round was 769.61 ms (approximately 0.77 seconds). Since the response time was shorter than the initialization time, this means initialization had already completed before the request arrived.
In the same round, there were 0 cases on the SnapStart side where restoration completed before the request arrived.
If measured only with single requests after an idle period, even if Init Duration or Restore Duration is recorded in the logs, it will not appear in the external response time. This is why measurement was done using 40-concurrent bursts.
All 80 /api/ping requests for both Baseline and SnapStart returned HTTP 200. The SHA-256 of the response body was also a single value, 46f03818aa4d0f40cb4997c41864ce3adbe125d89ec20de01442ab2ea0aaff38, for both conditions. On the SnapStart side, restore logs were recorded for 40 requests, of which 37 shared the same request ID as the REPORT for the measured request.
Since this application does not generate unique values (IDs, secrets, random seeds) during initialization, resuming a snapshot across multiple execution environments does not change the response body. For applications that generate unique values during initialization, those values must be regenerated on the handler side (see compatibility considerations in the SnapStart documentation).
Summary
It was confirmed that SnapStart can be used with a container image running Next.js via Lambda Web Adapter. The image used in this verification did not reach the scale that AWS describes as taking several seconds to start. Even so, restoration was shorter than initialization, so the reduction in time is expected to be even greater for larger images or workloads with longer initialization times.
For applications that include the label in the Dockerfile, invoke via published versions and aliases, and do not generate unique values during initialization, a reduction in cold start time can be expected without any changes to the code or dependencies. If $LATEST is being called directly, it will be necessary to publish a version and switch to alias-based invocation.
If Lambda cold starts during scale-out are a concern, please give this update a try.
