PDFPipe

Managed containers / Fly.io

Puppeteer on Fly.io: a machine too small to hold a browser

The machine is killed and restarted mid-render, with an out-of-memory entry in the logs and no application error.

What is actually wrong

Machines default to a small memory size that is comfortable for a web process and far too small for a browser. Chromium allocates per tab and per document, so the failure is proportional to the size of the document being rendered: small invoices work, a fifty page report with images does not, and the difference only shows up in production data.

What Fly.io gives you to work with

your image running in a lightweight virtual machine, one per instance, started and stopped on demand.

  • Packaging: no image size limit, but image size is boot time, and boot time is what the scale-to-zero behaviour trades against.
  • Filesystem: fully writable inside the machine, ephemeral unless a volume is attached. /dev/shm inside the virtual machine is sized by the machine, not by a Docker default.
  • Processes: full control. The default machine size is small, and memory is the resource that a browser exhausts first.

The fix, if you are keeping Puppeteer

Size the machine for the largest document rather than the typical one, and add swap so a spike degrades instead of terminating. Both are configuration rather than code.

toml
# fly.toml
[[vm]]
  # Sized for the worst document, not the median one.
  memory = "2gb"
  cpu_kind = "shared"
  cpus = 2

# A swap file turns a spike into slowness rather than a restart.
swap_size_mb = 512

What still hurts after that

The machine is now sized for the worst case and billed for it continuously, which is the same economics as every other always-on browser deployment. Scale to zero returns the saving and hands back a cold start that includes a browser launch. Neither is wrong; the point is that the choice is between paying for idle memory and paying in latency, and there is no configuration that avoids both.

What Puppeteer is genuinely good at

it renders exactly what a browser renders, which means modern CSS, web fonts and client-side JavaScript all behave the way they do in a tab. That is worth keeping in view: the problem on this page is where it runs, not what it produces. Puppeteer is a Node library that drives a real Chromium, and downloads that Chromium into node_modules when it is installed. It needs the browser binary, a long list of shared libraries the browser links against, fonts, and enough memory for a browser process that is not accounted for by your runtime's heap settings.

The other shape of the answer

Every fix above keeps the renderer inside your deployment, which means the constraint stays yours: the package size, the shared libraries, the fonts, the memory, the patching. The alternative is an HTTP call from Fly.io to something that already has a browser in it, which turns all of the above into a request and a response. That is what this API is, and it is the reason these pages exist. Whether that trade is right depends on whether rendering is a thing you want to operate.

Frequently asked

Can Puppeteer run on Fly.io at all?

Size the machine for the largest document rather than the typical one, and add swap so a spike degrades instead of terminating. Both are configuration rather than code. Whether that is worth doing is a separate question from whether it is possible, and the section above on what still hurts is the honest answer to it.

Why does it work locally?

Because a development machine has root, a package manager, several gigabytes of memory, a full font set and no packaging limit. Every one of those is a thing Fly.io constrains. A renderer that works locally and fails on deploy has not regressed: it has met the first environment that does not give it everything.

Related failures

The same platform with a different renderer, and the same renderer on other platforms.

If the render moves off this platform, none of the above is your problem to keep solving. 100 free documents a month, and a playground that needs no signup.