Containerized Puppeteer on AWS Lambda: Architecture, Constraints, and Postmortems
Shipping pdfGenerationan HTML→PDF rendering service, as a container-image Lambda, fronted by API Gateway and an SQS FIFO queue. What we built, what broke, and the platform limits that shaped both.

Service scope: what pdfGeneration does
Think of a small print shop.
Someone hands the shop a design on paper, an invoice layout, a purchase order, a shipping label, a barcode sticker. The shop prints it nicely and drops the finished copy into a shared cupboard where anyone with the right key can pick it up.
Our service, pdfGenerationis that print shop. The "design on paper" is an HTML template. The "printing" is done by a real Chrome browser running with no screen. The "cupboard" is Amazon S3.
It used to live inside one big application (node-backend), and that is where the trouble started. A browser is a heavy, unpredictable guest, and it was sharing a process with everything else the business runs on. When a big export pushed that process past its memory limit, it didn't just fail the export. It took the whole backend down with it.
So we pulled the service into its own repository: deployed on its own, scaled on its own, and, the part that actually mattered - failing on its own.
Workload analysis: two traffic profiles inside one service
Look closely at who walks into the print shop, and you notice two completely different people.
Customer 1 - the walk-in. A user clicks “Download PDF” and stares at a spinner. They want one document, now, in a few seconds. If it takes ~160 seconds, they assume the site is broken.
Customer 2 - the bulk order. Someone at 6 PM triggers “export all 200 box barcodes” or “generate today’s full invoice run.” This takes minutes. Nobody is staring at a spinner. Nobody should be.
If you build only for the walk-in, bulk orders will time out. If you build only for bulk, single downloads feel sluggish. So we built two lanes. Almost every strange-looking decision below exists because of these two lanes.
Compute model: Lambda vs a long-lived EC2 process
The obvious alternative was an always-running machine - an EC2 box running Express, plus a background worker pulling jobs off a queue. (Our node-backend did this already)
1. In the monolith, one PDF job could take down everything else. This is the honest origin story, and it isn’t a cost spreadsheet; it’s a series of production incidents.
Running Puppeteer inside node-backend meant every render spawned a Chromium process, and Chromium is not a polite guest. A single instance comfortably eats hundreds of megabytes, and a bulk export spawned them one after another. On a fixed-size box, the ending is always the same: memory climbs, the OOM killer or Node itself gives up, and the process dies.
Lambda makes that failure mode structurally impossible. Each invocation gets its own isolated environment with its own memory budget. If a render blows its limit now, exactly one PDF fails, one error surfaces, and nothing else on the platform notices. As a bonus, deploying or restarting the main backend no longer kills a half-finished export mid-render, since the export isn’t running there anymore.
2. The work comes in bursts, not in a steady stream. PDF generation clusters around business moments: end-of-day exports, bulk label printing. In between, nothing. Running a server all night at 2% CPU, just so it’s warm at 6 PM, is paying rent on an empty shop.
3. AWS already sells exactly the two doors we needed. The fast lane becomes API Gateway → Lambda. The slow lane becomes SQS queue → Lambda. Retries, scaling, and dead-letter handling come free with those AWS pieces. We didn’t have to hand-write a queue consumer or a job scheduler.
4. Chrome is a “start it, work hard, throw it away” kind of program. It eats CPU and memory, but only for a few seconds at a time. That’s precisely the shape Lambda is built for; every request gets its own isolated box.
And because we ship Lambda as a Docker container image, not a ZIP file, we could bring Chromium along. This matters more than it sounds:
- ZIP upload: 50 MB zipped, or 250 MB unzipped including layers.
- Container image: 10 GB uncompressed.
Chromium alone would blow through the ZIP limit. Container images make it a non-issue.
We also knew the price of admission going in. Lambda has hard rules:
- Maximum 15 minutes per invocation. This one genuinely cannot be raised, not even by a support ticket.
- No background processes. Between requests, your function is frozen or thrown away.
- /tmp is temporary. Default 512 MB, configurable up to 10 GB, and gone when the box is recycled.
Almost everything that broke broke because our code quietly assumed a server that never sleeps.
Architecture: one image, two build targets
HTTP request ──► API Gateway ──► PdfFunction (Docker target: "http")
Express + Lambda Web Adapter
│
SQS message ──────────────────► PdfQueueFunction(Docker target: "sqs")
(FIFO queue) plain handler, no web server
│
▼
shared rendering code → Postgres, Redis, S3
Both functions are built from the same Dockerfile, using two different build targets.
That’s deliberate. It means there is exactly one place that installs Chromium, one place that runsnpm ci, and - most importantly - one piece of code that actually draws the PDF. If they were separate images, the fast lane and the bulk lane could slowly drift apart and start producing invoices that look subtly different. That bug is horrible to find. So we made it impossible.
# "http" target — normal Express app behind API Gateway
FROM base AS http
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:1.0.1 /lambda-adapter /opt/extensions/lambda-adapter
ENV AWS_LWA_PORT=4001
CMD ["node", "index.js"]
# "sqs" target — no web server at all
FROM base AS sqs
ENTRYPOINT ["/usr/local/bin/npx", "aws-lambda-ric"]
CMD ["sqsHandler.handler"]
How the Lambda Web Adapter works
Lambda normally expects you to write a special function shaped like exports.handler = (event) => {...}. Express doesn't look like that at all. Express expects to be a real web server.
The AWS Lambda Web Adapter is a small translator that sits in the container as a Lambda extension. When the box starts up, it boots first, waits for your web app to become ready by pinging it, and then quietly converts every incoming Lambda event into a real HTTP request against localhost and converts the response back.
The payoff is big: zero Lambda-specific code in the app. The same Express routes run on a laptop with npm run dev, under sam local start-api, and in production. Nobody has to remember "oh, this file behaves differently in the cloud."
The SQS target: aws-lambda-ric
QUEUE Functionhas no web server, because it doesn't need one. AWS invokes it directly with the message payload. We use aws-lambda-ric (the AWS Lambda Runtime Interface Client), which is the piece that lets any container image behave like a normal Lambda pointed at a named handler.
Honest footnote: the Web Adapter can actually handle SQS events too, by POSTing them to a path in your app. So a single-target setup was technically possible. Using a plain handler for the queue is still a reasonable choice - it keeps the batch path free of HTTP framework overhead - but it was a preference, not a hard requirement.
Two deliberate installations
No chrome-aws-lambda / @sparticuz/chromium. These are the packages every Lambda + Puppeteer tutorial reaches for. They bundle a shrunken Chromium built for Lambda. We install Chromium through the operating system instead (apt-get install chromium) and point Puppeteer at it with PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium.
It turns out to be the officially recommended one for our setup: the maintainer of @sparticuz/chromium states in the project's own README that the package is designed for a plain ZIP-based Lambda, and that if you are using a Dockerfile, it may be better to install Chromium and its dependencies from the distribution's repositories - exactly what we did. (Also worth knowing: the original chrome-aws-lambda is deprecated; @sparticuz/chromium is its maintained successor.)
No aws-lambda-ric in package.json. It's installed globally at the OS level in the shared base image instead. Why? Because the RIC contains a native C++ module and compiles from source on install via node-gyp. That needs a full build toolchain and takes a meaningful chunk of a minute. Doing it once, in a layer that changes twice a year, means no push ever pays that bill.
Build topology: three Dockerfiles, three cache lifetimes
This is the part that looks over-engineered until you’ve watched a deploy take 12 minutes to ship a one-line change.
Think of it like a kitchen. You don’t rebuild the oven every time you cook dinner.
1. Dockerfile.base → pdf-lambda-base
FROM node:22-slim
RUN apt-get update && apt-get install -y \
qpdf chromium fonts-liberation libnss3 libatk-bridge2.0-0 libgtk-3-0 libgbm1 \
g++ make cmake unzip xz-utils python3 libcurl4-openssl-dev \
--no-install-recommends && rm -rf /var/lib/apt/lists/*
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromiumRUN
npm install -g aws-lambda-ric && npm cache clean --force
Every line here was earned the hard way.
- node:22-slim, not node:22-alpine. Alpine uses musl libc; prebuilt Chromium expects glibc. Slim is Debian-based, so glibc. We learned this by chasing shared-library errors on Alpine first.
- The graphics and font libraries. A Lambda container is a bare room. Desktop Chromium assumes a fully furnished house. Without libnss3, libgbm1, libgtk-3-0, libatk-bridge2.0-0 and fonts-liberation, Chromium didn't render badly; it refused to start.
- qpdf A native tool for merging PDFs without loading them all into RAM. Merging 200 documents in memory inside a memory-capped function is a great way to get killed.
- The build toolchain (g++, make, cmake, python3, …) exists purely to compile aws-lambda-ric once, here.
One small modernization worth noting: PUPPETEER_SKIP_CHROMIUM_DOWNLOAD is the legacy variable name. Puppeteer renamed it to PUPPETEER_SKIP_DOWNLOAD around v19. Setting both is the safe move.
2. Dockerfile → the per-push application image
ARG BASE_IMAGE=<account>.dkr.ecr.ap-south-1.amazonaws.com/pdf-lambda-base:latest
FROM ${BASE_IMAGE} AS base
ARG ENV_FILE_NAME=.envQa
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
COPY ${ENV_FILE_NAME} ./${ENV_FILE_NAME}
ENV ENV_FILE=./${ENV_FILE_NAME}
That’s it. Install dependencies, copy code. Everything expensive already happened in the oven.
3. Dockerfile.ci → the pipeline's own runtime image
FROM node:22
RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends python3-pip unzip curl \
&& pip3 install --quiet --break-system-packages aws-sam-cli \
&& curl -s "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o awscliv2.zip \
&& unzip -q awscliv2.zip && ./aws/install && rm -rf awscliv2.zip aws
This never goes near Lambda. It’s the container Bitbucket runs the pipeline inside, pre-loaded with the AWS CLI and SAM CLI so we don’t reinstall them on every run.
Registry split: public Docker Hub vs. private ECR
A fair question: why is pdf-ci on public Docker Hub while pdf-lambda-base sits in private ECR?
Because of a chicken-and-egg problem. Bitbucket must pull the pipeline’s own image before the pipeline script runs - before any AWS credentials exist. A private ECR image simply cannot be pulled at that moment, and ECR login tokens expire after 12 hours, so you can’t hardcode one either.
pdf-lambda-base is different: it's pulled inside the script during sam build, long after credentials exist. So private works fine there, and there's no reason to expose it publicly.
Build times, before and after
Before the split, every push ran the whole thing from a generic node:22 image:
- Installing AWS CLI + SAM CLI ~110s
- Installing Chromium, qpdf, fonts; compiling aws-lambda-ric ~209s
- The actual deploy ~157s
- Total: roughly 8 minutes
After: a normal push is 3–4 minutes. Nearly all the removed time had nothing to do with the code being changed.
MemorySize is a CPU dial, not a memory dial
Here is the single most useful thing in this whole post, if you take nothing else.
On Lambda, memory is the CPU dial. AWS gives you CPU in proportion to the memory you configure.
So at our original MemorySize: 1024, we were running on roughly 0.58 of a single CPU core.
Puppeteer’s work - launch Chrome, lay out a page, paint it, serialize a PDF - is almost entirely CPU work. A render that took 4–5 seconds on a laptop with real cores was taking 30+ seconds under that throttle. Not because it needed more memory. Because it had been given barely half a core.
Bumping to MemorySize: 2048 - just over one full vCPU was the first fix. Note the counter-intuitive economics: a function that's twice as fast at twice the memory price often costs the same or less, because Lambda bills you for memory × duration.
CI/CD: OIDC → sam build → sam deploy
image: admindemor/pdf-ci:latest
pipelines:
branches:
quality: [ deploy to QA ]
master: [ deploy to PROD ]
Push to quality → QA. Push to master → PROD. No promote button. The branch is the trigger - simple to reason about, but it makes branch discipline load-bearing.
- Get AWS credentials via OIDC, not stored keys. Bitbucket trades a short-lived identity token for temporary AWS credentials (a 30-minute session). Nothing long-lived sits in the CI settings waiting to leak.
- Rebuild the secrets file. .envQa / .envProd hold database and Redis credentials, so they're gitignored and absent from a fresh checkout. A base64-encoded copy lives as a secured Bitbucket variable and is decoded into a real file just before the build. Base64 specifically, because raw multi-line secrets with special characters get mangled by quoting and newline escaping in CI variable fields. Base64 flattens everything into one line of boring characters.
- Log Docker into private ECR using those credentials, so sam build can pull the base image.
- sam build --config-env $SAM_CONFIG_ENV
- sam deploy --no-confirm-changeset --no-fail-on-empty-changeset — pushes the image, updates both functions, reconciles API Gateway and the SQS trigger.
Minutes after a push, it’s live. No manual restart, no manual upload.
Observability: stdout to CloudWatch + a Postgres request tracker
No Winston, no Pino, no X-Ray. Just console.log / console.error, tagged for grep-ability: [PDF:UPLOAD], [PDF:BROWSER], [PDF:RENDER], [PDF:MERGE], [PDF:QUEUE], [PDF:AUTH]. Lambda ships container stdout to CloudWatch automatically, so there's no shipping layer to build.
Alongside it sits something more durable: a Postgres-backed request tracker that writes a row when a request starts (tracking ID, source, method, path, decoded user/org claims, truncated payload, IP) and updates it with the final status at the end.
That answers a question CloudWatch is bad at: “show me everything this one request did, queryable, three days later.” Which matters enormously when a single user action spans an HTTP request, an SQS message, and a Lambda invocation that might happen minutes apart.
This is a starting point, not a finished observability story. Structured JSON logs and distributed tracing across the HTTP → SQS → Lambda hop are the obvious next steps if debugging becomes a recurring pain.
Failure postmortems
This is the part that never appears in an architecture diagram.
1. In-process SQS polling vs. frozen execution environments
The first queue consumer used the sqs-consumer npm package. On a real server it's perfect: connection.start() kicks off a background loop that long-polls SQS forever.
On Lambda, “forever” is a lie. The execution environment is frozen between invocations and can be destroyed at any moment. A loop started at boot has no guarantee it’s still running, or still exists, when the next message arrives.
Fix: delete the consumer entirely. Use Lambda’s native SQS event source mapping and reduce the code to a plain exported function that has no idea how messages arrive. AWS owns polling, batching, retries.
The general lesson: anything that assumes a living process - background intervals, in-memory loops, periodic cleanup - must be rethought. Either run it per invocation, or hand it to an AWS trigger that doesn’t depend on your process being alive.
2. setInterval Cleanup in a non-persistent runtime
PDF chunks get staged in /tmp before qpdf merges them. The original design ran a setInterval sweep to clean up orphaned folders from crashed jobs. Same flaw: a timer only fires if the process is alive to fire it.
3. Connection pool sizing under horizontal scale-out
The Postgres pool was set to max: 100. Perfectly sensible for one long-lived process.
But on Lambda, each concurrent execution gets its own process and its own pool. Twenty concurrent invocations × 100 = a theoretical 2,000 connections at a database that would fall over long before that.
Fix applied: drop to max: 5. That's a tourniquet, not surgery - it caps the per-invocation worst case but doesn't solve unbounded horizontal scaling. The real answer is RDS Proxy, which multiplexes many short-lived Lambda connections onto a small stable pool of real ones. Still on the list.
4. Chromium launch failures: missing shared libraries
Before we found the right library combination, Chromium didn’t render badly - it didn’t launch. The errors were cryptic missing messages that don't map cleanly onto "install package X."
Getting from there to reliable rendering was pure iteration: install a library, redeploy, read the next missing dependency, repeat. Budget real time for this phase. It is not a sign you’re doing something wrong; it’s just what running a browser inside a stripped-down container costs.
5. Build-time config baking vs. runtime injection
Because container images are immutable once built, and because SAM can’t feed a CloudFormation parameter into DockerBuildArgs, the environment-specific config file must be chosen and copied in during docker build.
This is a genuine departure from the familiar “one image, inject env vars at deploy time” pattern you’d use on ECS. It’s why the Dockerfile, samconfig.toml, and the CI pipeline all have to agree on which env file goes into which build.
(A note for anyone starting fresh: if you don’t need the config file on disk, plain Lambda environment variables or SSM Parameter Store / Secrets Manager fetched at cold start would let you keep one image for all environments. The baked-in approach here is a consequence of an existing dotenv-based app, not a recommendation to copy blindly.)
Known gaps and backlog
No pretending this is finished.
- RDS Proxy - the real fix for connection pooling. max: 5 is a stopgap.
- Browser pooling - right now a fresh Chromium is launched and destroyed for every single request. On a normal server that’s wasteful; on Lambda it stacks on top of cold-start cost. Reusing a warm browser across invocations of the same warm environment is the biggest easy win left.
- A concurrency limiter between incoming requests and Chromium launches. A burst on one warm instance can currently cause OOM kills that look identical to unrelated hangs.
- EphemeralStorage tuning. Currently 1024 MB, chosen without measurement. Worth sizing against a real disk-full incident. (Note that Lambda's default is 512 MB, so this is already a deliberate bump, not an untouched default.)