DocuHook

Guides · Puppeteer serverless HTML to PDF timeout fix

Puppeteer Serverless HTML to PDF Timeout Fix

2026-10-19 · 6 min read

If you searched for Puppeteer serverless HTML to PDF timeout fix, you have probably already hit one of these:

  • TimeoutError: Navigation timeout of 30000 ms exceeded
  • Lambda / Cloud Functions killed at 10s or 15s
  • Chromium failed to launch (libnss3, missing fonts, read-only /tmp)
  • Cold starts that eat the whole budget before page.pdf() runs

This guide explains why those failures are structural — and a copy-paste way to stop running Chrome inside the function for the merge step.

Why serverless Puppeteer times out

A typical HTML→PDF function does three expensive things in one invocation:

  1. Download / unpack Chromium (cold start)
  2. Launch a browser + new page
  3. setContent(html) + page.pdf()

On a warm container that can finish in a few seconds. On a cold one, steps 1–2 alone exceed many hobby/serverless limits. Adding more memory helps a bit; it does not remove the binary tax.

Fix pattern: split merge from print

Most “HTML to PDF” jobs are really two jobs:

  1. Merge — inject order/invoice JSON into an HTML template
  2. Print — turn that HTML into bytes (PDF)

Keep print where Chrome is already warm (a dedicated worker, a queue with a long timeout, or the user’s browser). Move merge to a fast HTTP API so your serverless handler only does JSON I/O.

DocuHook is built for that merge: data + html in, PDF-ready HTML out — typically around 200ms, no Chromium.

Before (fragile)

// Often dies on cold start / 10s limit
const browser = await puppeteer.launch({ /* chromium args… */ });
const page = await browser.newPage();
await page.setContent(mustache.render(template, data), { waitUntil: "networkidle0" });
const pdf = await page.pdf({ format: "A4" });
await browser.close();

After (thin handler)

import { DocuHookClient } from "@docuhook/node";

const dh = new DocuHookClient({ apiKey: process.env.DOCUHOOK_API_KEY });

export async function handler(event) {
  const data = JSON.parse(event.body);
  const result = await dh.render(data, INVOICE_HTML, { filename: "invoice.pdf" });

  // result.html is merged — ship to S3, email, or a print worker
  return {
    statusCode: 200,
    headers: { "Content-Type": "text/html; charset=utf-8" },
    body: "html" in result ? result.html : JSON.stringify(result),
  };
}

Install:

npm install @docuhook/node

Async when the webhook cannot wait

Outbound SaaS webhooks often demand a fast 2xx. Do not launch Puppeteer in that request. Enqueue:

const job = await dh.render(data, INVOICE_HTML, { mode: "async" });
// 202 — poll dh.jobStatus(job.jobId) from a cron/worker

Or POST directly to https://docuhook.com/api/v1/hooks/render with Authorization: Bearer dh_….

If you still need a PDF binary

Options that stay timeout-safe:

  • Return merged HTML and let the client print / use html2pdf.js
  • Push result.html to a warm print pool (one long-lived Chromium, not per-request launch)
  • Store HTML + generate PDF overnight in a batch job with a 5-minute timeout

The serverless edge function no longer carries Chromium.

Checklist

  • Remove @sparticuz/chromium from the hot path if merge was the only reason you needed it
  • Keep templates under your size limits; send data + html each request (stateless)
  • Use sync for interactive APIs; async for bursts
  • Watch plan PDF quotas at docuhook.com/plans

The 3-line fix

Replace the Puppeteer launch/merge block with:

const dh = new DocuHookClient({ apiKey: process.env.DOCUHOOK_API_KEY });
const { html } = await dh.render(payload, templateHtml);
// ~200ms — no Chromium cold start, stays under serverless timeouts

Sandbox (no signup): docuhook.com · API key: register.