There are two ways to take a website screenshot on AWS Lambda. You can bundle a headless Chromium build into the function, or you can call a screenshot API from a small handler and store the result in S3. This page gives you the second option as a tested Node.js 22 handler and a SAM template, and it explains how to compare both options on your own account. It is part of our integrations hub. If you want to run the browser inside Lambda, read Puppeteer on AWS Lambda instead.
Last tested: 2026-10-15 with Node 22.18 and
@aws-sdk/client-s33.1152.0. The handler ran against a local stub of our API (image responses, a 429 withRetry-After, a 422 and a 401) and wrote to a local S3 server (moto 5.2.4).cfn-lint1.57.2 validated the SAM template. We did not deploy it to AWS for this page, so there are no console screenshots or cold-start numbers yet.
What you can build
- Scheduled captures. An EventBridge schedule runs the function every morning and saves a dated screenshot of each page you track to S3.
- Screenshots from an upload event. A new record in DynamoDB or a message in SQS carries a URL. The function captures it and writes the image next to the record.
- An internal screenshot endpoint. A Lambda Function URL accepts
{"url": "..."}from your other services, so they share one key and one retry policy.
Chromium in Lambda vs an API call
| Chromium in the function | API call from the function | |
|---|---|---|
| Package | @sparticuz/chromium 153.0.0 is 70,091,665 bytes unpacked (npm registry), plus puppeteer-core and its dependencies |
The handler: 2,695 bytes, 1,518 bytes zipped |
| Lambda size limit | AWS limits the unzipped package, layers included, to 250 MB | Not a concern |
| Memory | Chromium needs a large memory setting to render reliably | 256 MB in our template |
| Cold start | Includes decompressing and starting Chromium | Includes only Node and the handler |
| Fonts, emoji, CJK | You add and maintain them | Handled by the renderer |
| Cost per capture | Lambda GB-seconds for the full render | Short Lambda run plus one API credit |
| Data leaves your account | No | Yes: the page URL goes to the API |
The last row matters. If the pages are internal and cannot leave your VPC, run Chromium in Lambda. For public pages, the API call is the smaller and simpler function.
Measure the cold start yourself
We do not print latency figures that we did not measure. To compare both options on your own account:
- Deploy both functions in the same region, with the same architecture.
- Change an environment variable to force a new execution environment, then invoke each function once with
aws lambda invoke. - Read the
REPORTline in CloudWatch Logs.Init Durationis the cold start, andDurationis the run time. - Repeat at least 5 times, then invoke 20 more times while warm. Compare the medians, not the best runs.
Count the API's render time separately: the X-Request-Id header identifies the request in your request logs.
Prerequisites
- An AWS account and the AWS SAM CLI.
- A RenderScreenshot API key (
rs_live_...). Create a free account.
The handler
Save this as index.mjs. It has no dependencies to install: fetch is built into Node 22, and the AWS SDK for JavaScript v3 ships with the Lambda Node.js runtime.
// index.mjs - AWS Lambda (Node.js 22) handler. // Captures a URL with the RenderScreenshot API and writes the image to S3. import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; const API = process.env.RS_BASE_URL ?? 'https://api.renderscreenshot.com'; const s3 = new S3Client({}); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function capture(body, maxAttempts = 3) { for (let attempt = 1; ; attempt++) { const res = await fetch(`${API}/v1/screenshot`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.RS_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify(body), signal: AbortSignal.timeout(75_000), // the API's render ceiling is 60 s }); if (res.ok) { return { bytes: Buffer.from(await res.arrayBuffer()), contentType: res.headers.get('content-type') ?? 'image/png', requestId: res.headers.get('x-request-id'), }; } const payload = await res.json().catch(() => null); const retryable = payload?.error?.retryable ?? (res.status === 429 || res.status >= 500); if (!retryable || attempt >= maxAttempts) { const e = new Error(`RenderScreenshot ${res.status} ${payload?.error?.code ?? ''}: ${payload?.error?.message ?? res.statusText}`); e.statusCode = res.status; throw e; } const retryAfter = Number(res.headers.get('retry-after')); await sleep(retryAfter > 0 ? retryAfter * 1000 : 1000 * 2 ** (attempt - 1)); } } // Event: { "url": "https://example.com", "preset": "og_card", "key": "optional/s3/key.png" } // Also accepts a Function URL / API Gateway event whose JSON body has the same fields. export const handler = async (event) => { const input = typeof event?.body === 'string' ? JSON.parse(event.body) : event; if (!input?.url) throw new Error('Missing "url" in the event'); const { bytes, contentType, requestId } = await capture({ url: input.url, preset: input.preset ?? 'og_card', output: { format: 'webp', quality: 85 }, block: { ads: true, cookie_banners: true }, }); const host = new URL(input.url).hostname; const key = input.key ?? `screenshots/${host}/${new Date().toISOString().slice(0, 10)}-${requestId ?? Date.now()}.webp`; await s3.send(new PutObjectCommand({ Bucket: process.env.BUCKET, Key: key, Body: bytes, ContentType: contentType, CacheControl: 'public, max-age=86400', })); return { bucket: process.env.BUCKET, key, bytes: bytes.length, requestId }; };
In the test, a direct event wrote screenshots/example.com/2026-10-15-<request id>.webp. An event whose JSON body hit a 429 waited the 1 second from Retry-After and then succeeded. A 422 failed after three attempts, and a 401 failed at once without a retry.
The SAM template
Save this as template.yaml next to the handler. It creates the bucket, the function with write access to that bucket only, and a daily schedule.
AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: Website screenshots on a schedule, written to S3, rendered by RenderScreenshot. Parameters: RenderScreenshotApiKey: Type: String NoEcho: true Description: Your rs_live_ API key Resources: ScreenshotBucket: Type: AWS::S3::Bucket ScreenshotFunction: Type: AWS::Serverless::Function Properties: Runtime: nodejs22.x Handler: index.handler CodeUri: . Architectures: [arm64] MemorySize: 256 Timeout: 120 Environment: Variables: RS_API_KEY: !Ref RenderScreenshotApiKey BUCKET: !Ref ScreenshotBucket Policies: - S3WritePolicy: BucketName: !Ref ScreenshotBucket Events: Daily: Type: ScheduleV2 Properties: ScheduleExpression: cron(0 6 * * ? *) Input: '{"url": "https://example.com", "preset": "desktop_hd"}' Outputs: BucketName: Value: !Ref ScreenshotBucket FunctionName: Value: !Ref ScreenshotFunction
Deploy it:
sam build sam deploy --guided --parameter-overrides RenderScreenshotApiKey=rs_live_your_key
For production, keep the key in AWS Secrets Manager or SSM Parameter Store and read it at cold start, instead of a plain environment variable.
Parameter mapping
| Event field | API field | Notes |
|---|---|---|
url |
url |
Required. http or https only. |
preset |
preset |
Defaults to og_card (1200x630). desktop_hd is 1920x1080. See presets. |
key |
— | The S3 object key. Defaults to screenshots/<host>/<date>-<request id>.webp. |
| (fixed) | output.format, output.quality |
WebP at quality 85 |
| (fixed) | block.ads, block.cookie_banners |
Both on |
Errors and retries on Lambda
- The handler retries only when the API says
retryable: true(429, 5xx and target errors), up to three attempts. It honoursRetry-Afteron a 429. - When it throws, Lambda's own retries apply. Asynchronous invokes (EventBridge, S3 events) retry twice by default. Add an on-failure destination or a dead-letter queue so that a page that always fails ends somewhere you can see it.
- Failed renders do not use credits, so retries cost Lambda time but no credits. A successful render that Lambda runs again does use a second credit. To avoid that on retries, add
cache: { available: true }to the request body; the API then returns the identical earlier render for 0 credits. See caching.
Limits and costs
- Timeout. Keep the function timeout above the 75-second fetch abort, plus time for a retry.
- Concurrency. Lambda can scale faster than your plan's rate limit. For bulk jobs, put the URLs on SQS and set a maximum concurrency on the event source mapping.
- Credits. One credit per fresh render. See pricing.
Troubleshooting
AccessDeniedonPutObject. The function writes to a bucket other than the one inBUCKET, or you removed theS3WritePolicy.Task timed out. The page is slow. Raisewait.timeoutin the request (maximum 60) and the function timeout together.- The image shows a cookie banner. Some banners need a click. See blocking ads and cookie banners.
To keep the image in S3 without a Lambda at all, let the API copy each render to your bucket; see save screenshots to S3. For the same pattern at the edge, see the Cloudflare Workers integration.