PDF Generation in Serverless Environments
Serverless functions are a natural fit for on-demand tasks — until you need to generate a PDF. Headless browsers like Puppeteer require system binaries, persistent processes, and hundreds of megabytes of memory: everything serverless environments are designed to avoid. This guide explains why PDF generation is uniquely difficult in serverless, and shows you a practical solution that works on Vercel, AWS Lambda, and Netlify without any of these constraints.
#Why serverless makes PDF generation hard
Serverless functions excel at short-lived, stateless work. But PDF generation — at least when done with a headless browser — has properties that conflict with nearly every serverless constraint:
- Binary dependencies: Puppeteer ships a 300MB+ Chromium binary. Most serverless platforms have strict deployment size limits (50MB on AWS Lambda, 250MB on Vercel).
- Memory: A single Chrome instance consumes 150–300MB of RAM. Lambda defaults to 128MB.
- Execution time: Rendering complex pages can take 5–15 seconds. Many serverless functions time out at 10–30 seconds, leaving no room for error.
- Ephemeral filesystem: You can't rely on a writable
/tmpdirectory across invocations, and any cached browser instance is discarded when the function goes cold. - Cold starts: Launching Chrome from scratch on every cold start adds 2–5 seconds before your function even begins rendering.
The result: teams either avoid PDF generation entirely, run a dedicated server just for it, or fight with fragile Puppeteer-on-Lambda configurations that break on every dependency update.
#The hard way: Puppeteer on Lambda
It is possible to run Puppeteer on AWS Lambda using a custom layer with a stripped-down Chromium build. Projects like chrome-aws-lambda package a smaller binary specifically for this purpose.
npm install puppeteer-core chrome-aws-lambda
const chromium = require('chrome-aws-lambda');
exports.handler = async (event) => {
let browser = null;
try {
browser = await chromium.puppeteer.launch({
args: chromium.args,
defaultViewport: chromium.defaultViewport,
executablePath: await chromium.executablePath,
headless: chromium.headless,
});
const page = await browser.newPage();
await page.setContent(event.html, { waitUntil: 'networkidle0' });
const pdf = await page.pdf({ format: 'A4', printBackground: true });
return {
statusCode: 200,
headers: { 'Content-Type': 'application/pdf' },
body: pdf.toString('base64'),
isBase64Encoded: true,
};
} finally {
if (browser) {
await browser.close();
}
}
};
This works — sometimes. In practice you'll encounter:
- Deployment failures: The combined size of your function and the Chromium layer often exceeds Lambda's 250MB unzipped limit.
- Timeout errors: Complex pages push against function time limits with no headroom for retries.
- Font rendering issues: Lambda's minimal Linux environment lacks system fonts, producing garbled output.
- Maintenance burden:
chrome-aws-lambdaupdates lag behind Chromium, and arm64 support is inconsistent.
Vercel and Netlify are stricter
Vercel's 250MB deployment limit and Netlify's 50MB function bundle limit make it practically impossible to bundle Chromium at all. If your stack runs on these platforms, the Puppeteer approach is a dead end.
#The better way: use a PDF API
A PDF API moves the headless browser off your infrastructure entirely. You send an HTTP request with your HTML content; the API renders it on dedicated browser infrastructure and returns a pre-signed URL to the generated PDF. Your serverless function stays tiny, stateless, and fast.
This is exactly what PDFLoom provides. The entire PDF generation logic in your function is a single fetch call:
const response = await fetch('https://api.pdfloom.com/v1/convert/html', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.PDFLOOM_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: html,
options: { format: 'A4', printBackground: true },
}),
});
const data = await response.json();
// data.response is a pre-signed S3 URL valid for 30 minutes
No binaries, no browser processes, no memory spikes. The function stays well within any platform's size and memory limits.
Get your API key
Sign up at pdfloom.com/register and create an API key from the dashboard. You get 50 free credits on email verification.
#Vercel serverless function
Vercel functions run in Node.js and support the Web Fetch API natively (Node 18+). Here's a complete API route in a Next.js app that generates a PDF from a posted HTML string:
export const runtime = 'nodejs';
export const maxDuration = 30;
export async function POST(request) {
const { html } = await request.json();
if (!html) {
return Response.json({ error: 'html is required' }, { status: 400 });
}
const apiResponse = await fetch('https://api.pdfloom.com/v1/convert/html', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.PDFLOOM_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: html,
options: {
format: 'A4',
printBackground: true,
margin: { top: '15mm', right: '15mm', bottom: '15mm', left: '15mm' },
},
}),
});
const data = await apiResponse.json();
if (!data.success) {
return Response.json(
{ error: data.message || 'PDF generation failed' },
{ status: apiResponse.status },
);
}
// Download the PDF from the pre-signed URL and stream it to the client
const pdfResponse = await fetch(data.response);
const pdfBuffer = await pdfResponse.arrayBuffer();
return new Response(pdfBuffer, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename="document.pdf"',
'Content-Length': String(pdfBuffer.byteLength),
},
});
}
Set PDFLOOM_API_KEY in your Vercel project's environment variables. The function adds up to a few kilobytes to your deployment — not 300MB.
Edge runtime
PDFLoom works from the Edge runtime too. Swap runtime = 'nodejs' for runtime = 'edge' and the code is identical, since both support the Fetch API. The Edge runtime has a 25MB response limit, so redirect the client to data.response directly if your PDFs are large.
#AWS Lambda function
Lambda functions support Node.js 18+ with the Fetch API built in. The pattern is the same as Vercel — call the PDFLoom API, download the PDF, return it to the caller.
export const handler = async (event) => {
const body = JSON.parse(event.body ?? '{}');
const { html } = body;
if (!html) {
return {
statusCode: 400,
body: JSON.stringify({ error: 'html is required' }),
};
}
const apiResponse = await fetch('https://api.pdfloom.com/v1/convert/html', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.PDFLOOM_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: html,
options: {
format: 'A4',
printBackground: true,
},
}),
});
const data = await apiResponse.json();
if (!data.success) {
return {
statusCode: apiResponse.status,
body: JSON.stringify({ error: data.message }),
};
}
// Download the PDF
const pdfResponse = await fetch(data.response);
const pdfBytes = Buffer.from(await pdfResponse.arrayBuffer());
return {
statusCode: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename="document.pdf"',
},
body: pdfBytes.toString('base64'),
isBase64Encoded: true,
};
};
Configure the function with at least 256MB of memory (for the Node.js runtime itself) and a 30-second timeout. That's a fraction of what a Puppeteer Lambda layer requires.
#Returning a redirect instead of the PDF binary
If your Lambda sits behind API Gateway and you want to avoid base64 overhead, redirect the client to the pre-signed URL instead:
if (data.success) {
return {
statusCode: 302,
headers: { Location: data.response },
body: '',
};
}
The pre-signed URL is valid for 30 minutes. If the client needs to store the file permanently, download it and upload it to your own S3 bucket before returning a response.
#Netlify function
Netlify Functions run on AWS Lambda under the hood, so the code is almost identical. Using the @netlify/functions package for the handler wrapper:
export default async (request, context) => {
const { html } = await request.json();
if (!html) {
return Response.json({ error: 'html is required' }, { status: 400 });
}
const apiResponse = await fetch('https://api.pdfloom.com/v1/convert/html', {
method: 'POST',
headers: {
'Authorization': `Bearer ${Netlify.env.get('PDFLOOM_API_KEY')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: html,
options: { format: 'A4', printBackground: true },
}),
});
const data = await apiResponse.json();
if (!data.success) {
return Response.json(
{ error: data.message || 'PDF generation failed' },
{ status: apiResponse.status },
);
}
const pdfResponse = await fetch(data.response);
const pdfBuffer = await pdfResponse.arrayBuffer();
return new Response(pdfBuffer, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename="document.pdf"',
},
});
};
export const config = { path: '/api/generate-pdf' };
Set PDFLOOM_API_KEY in Site settings → Environment variables in the Netlify dashboard.
#Generating PDFs from URLs
The same pattern works for converting live URLs to PDF. Swap the endpoint and body:
const apiResponse = await fetch('https://api.pdfloom.com/v1/convert/url', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.PDFLOOM_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: 'https://example.com/report',
options: { format: 'A4', printBackground: true },
}),
});
This is useful when your content is already rendered as a public URL — dashboards, admin pages with short-lived access tokens, or any page you want to snapshot.
#Handling errors and retries
Serverless functions are invoked over HTTP, so network errors and transient API failures happen. Add basic retry logic and proper error propagation:
async function generatePdf(html, retries = 2) {
for (let attempt = 0; attempt <= retries; attempt++) {
const apiResponse = await fetch('https://api.pdfloom.com/v1/convert/html', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.PDFLOOM_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: html,
options: { format: 'A4', printBackground: true },
}),
});
// Don't retry client errors (4xx)
if (apiResponse.status >= 400 && apiResponse.status < 500) {
const data = await apiResponse.json();
throw new Error(data.message || 'Client error');
}
if (apiResponse.ok) {
return apiResponse.json();
}
if (attempt < retries) {
await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1)));
}
}
throw new Error('PDF generation failed after retries');
}
Common error codes from the PDFLoom API:
- 402: You've run out of credits — top up your account
- 422: Validation error — check your
contentoroptionsfields - 503: Temporary generator outage — safe to retry
Use idempotency keys
Add an Idempotency-Key header (e.g., a UUID tied to a request ID) to prevent duplicate charges if your function retries a request that actually succeeded. The API will return the cached result for the same key.
#Comparison: Puppeteer on Lambda vs. PDF API
| Puppeteer on Lambda | PDF API | |
|---|---|---|
| Deployment size | 150–300MB | <1MB |
| Memory required | 1024MB+ recommended | 128–256MB |
| Cold start | 3–8 seconds | <1 second |
| Vercel/Netlify compatible | No | Yes |
| Maintenance | Ongoing | None |
| Font/CSS support | Depends on Lambda env | Full Chrome fidelity |
For most serverless workloads, the PDF API wins on every dimension except cost model — you pay per conversion rather than per compute-second. At typical volumes (hundreds to thousands of PDFs per month), the cost is comparable or lower once you factor in the Lambda memory allocation needed to run Chrome.
#Next steps
- Create a free account: Get 50 credits on email verification to start testing
- API documentation: Full reference for HTML, URL, and document conversion
- Convert URLs to PDF: The same API, but for live web pages instead of raw HTML
- Generate PDFs from HTML in Node.js: A deeper comparison of Puppeteer, wkhtmltopdf, and PDF APIs
Related posts
Convert Any URL to PDF with a Single API Call
Learn how to convert any public URL to a PDF or screenshot using the PDFLoom API. Includes code examples in cURL, Node.js, and a full Express.js integration.
Puppeteer PDF: Common Problems and How to Fix Them
Troubleshoot Puppeteer PDF issues: timeouts, memory leaks, Docker problems, and font rendering. Working code examples and solutions.
Generate Invoice PDFs from HTML Templates
Learn how to create professional invoice PDFs using HTML templates and a PDF API. Includes a complete invoice template and Node.js code examples.