File MN-004-01 · Field noteLocal time · UTC−3 · Status: Active
Matheus Nugas

Field note 01 · AWS

PDFs with headless Chromium on Lambda, without tears

Page breaks, package size, memory, and why the queue in front of the renderer matters more than the renderer.

Filed Reading 4 minLinked Case 01 · Real Estate Management Platform

Every property on the platform could produce a stack of reports: a Homeowner Guidebook, an Asset Report, a Maintenance Report and a few more. Each one pulled its data straight from the database and laid it out as a branded PDF. Generating one is easy. Generating all of them reliably, on demand, inside Lambda, is where the afternoon goes.

These reports were not a side feature. They were a selling point. Homeowners kept them as the record of their home, and realtors used them while selling the property, so a broken page or a missing section showed up in front of exactly the people the product was trying to impress.

The setup

One HTML template per report type, filled with data from the database, handed to headless Chromium and printed to PDF. The whole render path fits on one screen:

import chromium from '@sparticuz/chromium-min';
import puppeteer from 'puppeteer-core';

const browser = await puppeteer.launch({
  args: chromium.args,
  // Chromium pack stored in S3, same region as the function
  executablePath: await chromium.executablePath(process.env.CHROMIUM_PACK_URL),
  headless: true,
});
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
await page.emulateMediaType('print');
const pdf = await page.pdf({ format: 'Letter', printBackground: true });
await browser.close();

The hard parts are everything around those lines.

Paper is not a screen

A web page scrolls forever. A PDF has edges, and every report also needed a printable version that held up on a home printer. Most of the work was teaching the templates where a page ends:

  1. Page breaks. A table row cut in half, or a section title stranded at the bottom of a page, looks broken even when the data is right. Sections start on a fresh page, and anything that must stay together says so.
  2. Repeating headers. Long tables span several pages, and a reader on page four still needs to know what the columns mean.
  3. Print styles. Margins, page size and backgrounds are set for paper, not for the browser window the template was designed in.

Most of it ends up as a few lines of print CSS:

@page {
  size: Letter;
  margin: 0.6in;
}
.report-section {
  break-before: page;
}
.card,
tr {
  break-inside: avoid;
}
thead {
  display: table-header-group;
}
h2,
h3 {
  break-after: avoid;
}

The other rule: everything a template needs travels with it. A Lambda has almost no system fonts, so a template that relies on one falls back to a default face. Fonts, logos and styles are bundled or inlined into the template, so the PDF looks the same in every environment it renders in.

Fitting Chromium into Lambda

Package size. A Chromium build does not fit comfortably next to your code. A Lambda zip has a hard limit of 250 MB unzipped, counting the function and all of its layers, so the usual answers are a layer or a container image. We went another way: the compressed Chromium pack lives in an S3 bucket in the same region as the functions, and @sparticuz/chromium-min downloads it into /tmp when a function starts cold. The function bundle stays tiny, upgrading Chromium means uploading a new pack instead of redeploying every function, and because the bucket sits right next to Lambda, the download barely registers. Warm invocations skip it entirely, since the binary is still in /tmp. Cold starts stayed fast and the whole render path stayed lean.

Memory. Chromium wants far more than a typical handler, and on Lambda memory buys CPU too: CPU is allocated in proportion to memory, reaching the equivalent of one full vCPU at 1,769 MB. A render function with too little memory does not just risk running out, it renders slowly. The @sparticuz/chromium README suggests at least 512 MB and recommends 1,600 MB or more. That is the place to start, and then you measure your heaviest report, not your average one.

Cold starts. Launching a browser is the slow part of a cold invocation. That is fine as long as no person is waiting on it, which brings us to the real feature.

The queue is the real feature

Rendering is slow and bursty, so nothing user-facing calls it directly. The API drops a message on SQS and returns right away; workers render, store the file in S3 and email it.

APISQSChromium workerS3SendGrid
Fig. 01 · From the full diagram in Case 01

The queue is what makes failures boring:

  1. Retries come free. If a render fails or times out, the message becomes visible again and another worker picks it up. The queue’s visibility timeout has to be longer than the function’s timeout, or a slow render gets picked up twice while the first attempt is still running.
  2. Retries must be safe. A job writes to a key derived from the report, not to a random name, so a second attempt overwrites the first instead of creating a duplicate or sending two emails for the same file.
  3. A dead-letter queue catches the rest. After a few failed attempts the message moves to a DLQ instead of looping forever. That one message holds everything needed to understand the failure and to replay it once the bug is fixed.
  4. Spikes flatten out. When many reports are requested at once, they wait in line instead of hitting the database and the renderer all at the same time.

Next time

I would test the PDFs themselves, not just the code that produces them. Render every report type with sample data in CI and compare each page against a known good version. Page breaks are the kind of bug a code review cannot see: the diff looks harmless, and the problem only shows up on page four of a long report.

Filed underAWSLambdaPuppeteerSQS

Matheus Nugas

Senior fullstack engineer building event-driven systems on AWS. More about me.