Taking Screenshots in AWS Lambda Without Fighting Chromium

S
ScreenshotAPIs Team April 28, 2026 · 13 min read

Running Chromium inside AWS Lambda is one of those tasks that looks like a two-hour job on Stack Overflow and turns into a multi-day yak shave. The browser doesn't fit in the deployment package, the version you install doesn't match the library that drives it, the fonts are missing, the cold start eats your latency budget, and the default timeout kills the render halfway through. Every single one of these problems is solvable — people run Puppeteer in Lambda in production every day — but each fix has a sharp edge, and the edges compound.

This guide covers both paths honestly. First, the real pain points with specifics — not "it's hard" but exactly which limits you'll hit and why. Then a complete, working self-hosted setup with @sparticuz/chromium and puppeteer-core, because sometimes that's the right call. Then the cost math, which is more interesting than you'd expect: Lambda compute is cheap; it's everything around it that's expensive. And finally the alternative — a Lambda function that calls a screenshot API and deploys as a few kilobytes.

Why Chromium and Lambda fight each other

Tired of the setup? Try the API free.

100 renders / month, no credit card. Or buy credits one-time and use them whenever — credits never expire.

Start free →

The 250 MB unzipped limit

A Lambda deployment package — function code plus all layers — must be under 250 MB unzipped. A standard Chromium build is around 280 MB on its own, before your code and node_modules. It simply doesn't fit.

The standard workaround is @sparticuz/chromium, the community-maintained successor to the now-archived chrome-aws-lambda. It ships Chromium as a Brotli-compressed archive (~65-80 MB in the package) that decompresses into /tmp on first invocation. That gets you under the limit, but the build is stripped: reduced codec support, almost no bundled fonts, and a flag set tuned for headless rendering rather than fidelity.

The other escape hatch is deploying your Lambda as a container image, which raises the ceiling to 10 GB. You write a Dockerfile, apt-get install a full Chrome plus font packages, and skip the stripped build entirely. It genuinely works and solves the font problem in one line — the trade-offs are slower cold starts while the image pulls, an ECR repository to manage, and a Docker build in your CI pipeline for what used to be a zip file.

Version pinning: the dependency treadmill

This is the failure mode that bites people months after launch. puppeteer-core only speaks to Chromium versions it was built against, and @sparticuz/chromium releases track Chromium's release cycle — its major version is the Chromium major version. Pair @sparticuz/chromium@131 with a puppeteer-core that expects Chromium 133 and you get protocol errors that look nothing like a version mismatch: Protocol error (Target.setAutoAttach), hangs on newPage(), silent crashes.

The practical consequence: you must pin both packages, consult the compatibility table in the @sparticuz/chromium README before every upgrade, and never let Dependabot auto-merge either one. Chromium ships security releases every few weeks, so "pin it and forget it" means accumulating CVEs in the thing you use to load untrusted web pages.

Cold starts: 2-6 seconds

A cold invocation pays for three things before your first screenshot: initializing the Node runtime, decompressing Chromium into /tmp, and launching the browser process. At 1,600 MB of memory that's typically 2-4 seconds; at 1,024 MB, closer to 4-6, because Lambda allocates CPU proportionally to memory — less memory literally means a slower CPU for the single-threaded decompress-and-boot sequence. For a background job this is tolerable. For anything user-facing (link previews, on-demand OG images) it's a visible stall, and the standard fix — provisioned concurrency — means paying for warm Lambdas around the clock.

Memory: 1,024 MB is the floor, not the target

Chromium plus Node plus a real web page needs room. At 512 MB, non-trivial pages OOM-kill the browser mid-render. 1,024 MB works for simple pages but is fragile — one image-heavy marketing site or an infinite-scroll page and you're reading Navigating frame was detached errors that are actually memory exhaustion in disguise. 1,600 MB is the realistic recommendation: enough headroom for heavy pages, and the extra CPU that comes with it cuts both cold start and render time, which partially pays for itself since Lambda bills in GB-seconds.

/tmp: 512 MB by default, and Chromium wants a lot of it

The decompressed browser is ~250-300 MB in /tmp. Chromium then writes its profile, cache, and shared-memory scratch (it's launched with --disable-dev-shm-usage, which redirects /dev/shm traffic to disk) into the same space. On long-lived warm containers rendering large pages, the default 512 MB can fill up and produce baffling crashes. You can raise ephemeral storage to 10 GB — for an extra per-GB-second fee.

Fonts: tofu boxes in production

Amazon Linux ships with almost no fonts. Render a page with Japanese, Chinese, Arabic, or emoji and you get rows of hollow rectangles — the dreaded tofu. Your tests pass because you tested on english-language pages; the bug report arrives when a customer screenshots a page with a ★ or a 中 in it. Fixes: bundle Noto Sans CJK and Noto Color Emoji into a layer and point Fontconfig at them, or use @sparticuz/chromium's chromium.font() helper to pull font files into /tmp/fonts at runtime (more cold-start latency), or go the container-image route and apt-get install fonts-noto-color-emoji fonts-noto-cjk.

ARM64: Graviton is cheaper, and you can't use it

Lambda's ARM64/Graviton runtime is ~20% cheaper per GB-second, so it's tempting to flip the architecture switch. Don't: @sparticuz/chromium publishes x86_64 binaries only — Google doesn't ship official Linux ARM64 Chromium builds, and compiling your own is a genuinely serious undertaking. Deploy to an ARM64 Lambda and the binary fails on the first invoke. Your screenshot function stays on x86_64.

Timeouts and concurrency

Lambda's default timeout is 3 seconds — your function will be killed before Chromium finishes booting. Set it to 30-60 seconds. And remember each render holds a full Lambda (and 1,600 MB) for its entire duration, drawing from your account-wide concurrency pool of 1,000: a screenshot traffic spike can throttle every other function in the account until you set reserved concurrency.

The self-hosted setup that actually works

To be fair to this approach: once configured, it does work. Here's the honest minimum. Pin the pair in package.json (check the compatibility table for current versions):

{
  "dependencies": {
    "@sparticuz/chromium": "131.0.0",
    "puppeteer-core": "23.10.4"
  }
}

The handler:

// handler.mjs
import chromium from "@sparticuz/chromium";
import puppeteer from "puppeteer-core";

export const handler = async (event) => {
  const { url } = JSON.parse(event.body ?? "{}");

  // Optional: load fonts for emoji/CJK before launch (adds latency)
  // await chromium.font("https://your-bucket.s3.amazonaws.com/NotoColorEmoji.ttf");

  const browser = await puppeteer.launch({
    args: chromium.args,
    defaultViewport: { width: 1280, height: 800 },
    executablePath: await chromium.executablePath(),
    headless: "shell",
  });

  try {
    const page = await browser.newPage();
    await page.goto(url, { waitUntil: "networkidle2", timeout: 25000 });
    const png = await page.screenshot({ type: "png", fullPage: true });

    return {
      statusCode: 200,
      headers: { "Content-Type": "image/png" },
      body: Buffer.from(png).toString("base64"),
      isBase64Encoded: true,
    };
  } finally {
    await browser.close();
  }
};

Required configuration, none of it optional: memory 1,600 MB, timeout 60 s, architecture x86_64, and ideally ephemeral storage above the default. Always browser.close() in a finally block — a leaked browser process in a warm container is a memory leak that survives across invocations and eventually OOMs a request that did nothing wrong.

What this snippet does not handle, and production will demand: SSRF protection if URLs come from users (nothing stops this code from screenshotting your VPC-internal endpoints), retry logic for pages that hang, bot-detection walls from Cloudflare and friends, PDF output tuning, and the quarterly ritual of bumping both pinned packages together and re-testing. The Puppeteer vs. API breakdown goes deeper on that maintenance tail.

The cost math (Lambda compute is not the expensive part)

Let's be precise, because the numbers surprise people. Lambda x86 pricing is $0.0000166667 per GB-second. A render at 1,600 MB (1.5625 GB) taking ~4 seconds of wall time costs:

Compare a screenshot API: at $19 for 2,000 renders that's ~$0.0095 per render, dropping to ~$0.004 at the $299/75,000 tier. So per render, self-hosted Lambda compute is roughly 40-90x cheaper. If raw compute were the whole story, this article would end here and recommend self-hosting.

It isn't the whole story. The real line item is engineering time. A realistic accounting:

At a blended $100/hour, the initial build is $800-2,400 and maintenance runs $200-400/month. Against a $19-79/month API bill, the compute savings of $10 per 100k renders never catches up until volume gets serious. The honest crossover: at ~500,000+ renders/month, the per-render delta reaches ~$2,000/month and self-hosting starts genuinely paying for the engineer who tends it. Below that, you're spending dollars of attention to save cents of compute.

The alternative: a 3 KB Lambda that calls an API

The other architecture moves the browser out of your Lambda entirely. Your function becomes a plain HTTP client — no layers, no binaries, no fonts, no version pinning. Here's a complete handler using nothing but built-in fetch:

// handler.mjs — no dependencies at all
export const handler = async (event) => {
  const { url } = JSON.parse(event.body ?? "{}");

  const resp = await fetch("https://screenshotapis.org/v1/screenshot", {
    method: "POST",
    headers: {
      "X-Api-Key": process.env.SCREENSHOT_API_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ url, format: "png", full_page: true }),
  });

  if (!resp.ok) {
    return { statusCode: 502, body: JSON.stringify({ error: "Render failed" }) };
  }

  const buf = Buffer.from(await resp.arrayBuffer());
  return {
    statusCode: 200,
    headers: { "Content-Type": "image/png" },
    body: buf.toString("base64"),
    isBase64Encoded: true,
  };
};

Configuration: 128 MB memory, default architecture (ARM64 is fine now — there's no binary), deployment package a few kilobytes. Cold start is ~150-250 ms because there's nothing to initialize. The render itself happens on the API's Playwright/Chromium fleet — typical render time is 0.9-1.6 seconds — so your Lambda spends its billed time idling at 128 MB, which rounds to fractions of a cent per 10,000 invocations. Fonts, emoji, CJK, ad blocking, SSRF filtering, and PDF output are the API's problem, not yours.

If you prefer an SDK over raw fetch, npm install screenshotapis (also on PyPI) gives you a typed client — the Node.js tutorial walks through it, including rendering raw HTML for OG images and webhook-based async rendering for big batch jobs. Full parameter reference lives in the docs, and you can prototype the exact request in the interactive screenshot generator before writing any code.

The same handler runs unmodified on Vercel Functions, Netlify Functions, and Fly — and on Cloudflare Workers, where this isn't a preference but the only option: Workers cannot run Chromium at all outside Cloudflare's own paid browser-rendering binding.

When self-hosting is the right call

An API is not always the answer, and pretending otherwise would make this article useless. Keep Chromium in your own infrastructure when:

For everyone else — bursty traffic, small teams, link previews, OG images, PDF invoices, visual archives — the API path is a free API key (100 renders/month, no card), five lines of code, and one less binary to babysit. If your volume is spiky rather than steady, one-time credit packs from $9 that never expire fit better than a subscription.

Frequently asked questions

Can I run Puppeteer directly in AWS Lambda?

Not the full puppeteer package — its bundled Chromium exceeds Lambda's 250 MB unzipped limit and isn't built for Amazon Linux. The working combination is puppeteer-core plus @sparticuz/chromium, a stripped, Brotli-compressed Chromium build that decompresses into /tmp at runtime. It works, but you must pin both packages to compatible major versions and re-verify on every upgrade.

Why do emoji and Chinese characters show as boxes in my Lambda screenshots?

Amazon Linux ships with almost no fonts, and the stripped Chromium build bundles none for CJK or emoji, so those glyphs render as tofu boxes. Fix it by bundling Noto fonts into a layer, loading them at runtime with chromium.font(), or switching to a container-image Lambda where you can apt-get install full font packages.

How much memory does a Chromium Lambda need?

1,024 MB is the realistic floor and still fragile on heavy pages; 1,600 MB or more is the sensible default. Lambda allocates CPU proportionally to memory, so higher memory also cuts cold starts and render time — 1,600 MB is often no more expensive per render than a slower 1,024 MB configuration.

Does @sparticuz/chromium work on ARM64 (Graviton) Lambdas?

No. It ships x86_64 binaries only, because Google doesn't publish official Linux ARM64 Chromium builds. Despite Graviton's ~20% lower price, a Chromium-based screenshot function must run on x86_64. A Lambda that calls a screenshot API over HTTP has no such restriction.

What does a screenshot cost on Lambda?

Pure compute is cheap: at 1,600 MB and ~4 seconds per render, about $0.0001 per screenshot, or roughly $10 per 100,000. The real cost is engineering time — 1-3 days of initial setup plus ongoing Chromium security updates, version-pinning maintenance, and font/OOM debugging, which typically dwarfs the compute savings below ~500k renders/month.

Do I need to bundle Chromium at all to take screenshots from Lambda?

No. Your Lambda can POST to a screenshot API and receive back a PNG, JPEG, WebP, or PDF. The function needs no layers, runs in 128 MB, deploys as a few kilobytes, cold-starts in ~200 ms, and the same code works on Vercel, Netlify, Fly, and Cloudflare Workers — where running Chromium yourself isn't even possible.