You deploy to Vercel. Everything works. Then someone tries to generate a PDF and the function times out.
You bump the timeout to 60 seconds. It still times out. You upgrade to Pro for 300 seconds. Now it works—sometimes.
The problem isn't your code. It's the architecture.
Serverless Limits Are Real
| Platform | Default Timeout | Max Timeout | Max Memory |
|---|---|---|---|
| Vercel (Hobby) | 10s | 10s | 1024MB |
| Vercel (Pro) | 15s | 300s | 3008MB |
| AWS Lambda | 3s | 15min | 10GB |
| Cloudflare Workers | 30ms CPU | 30s | 128MB |
Cloudflare Workers can't even run Chromium. The 128MB memory limit makes it impossible.
What PDF Generation Actually Costs
Let's trace a typical PDF render:
1. Cold start Chromium → 2-4 seconds
2. Parse HTML/CSS → 200-500ms
3. Load external resources → 0-10 seconds (fonts, images)
4. Render to PDF → 500ms-3s
5. Upload/return result → 200ms-1sBest case: 3 seconds. Worst case with external resources: 15+ seconds.
And that's for a single page. Multi-page documents scale linearly.
The Cold Start Problem
Serverless functions spin down when idle. When a request comes in, the runtime has to:
- Allocate a container
- Load your code
- Initialize dependencies
- Launch Chromium
Step 4 is the killer. Chromium isn't a lightweight process. It needs to:
- Decompress binaries
- Initialize V8
- Set up IPC channels
- Allocate rendering buffers
That's 2-4 seconds before your code even runs.
// This import alone triggers a cold start penalty
import puppeteer from 'puppeteer'
export default async function handler(req, res) {
// By the time we get here, 3 seconds are gone
const browser = await puppeteer.launch()
// ...
}Workarounds (And Why They Fail)
1. Keep Functions Warm
You can ping your function every minute to prevent cold starts.
// cron job hits this endpoint
export async function GET() {
// Just keep the function warm
return new Response('ok')
}Problems:
- Costs money (invocations aren't free)
- Doesn't guarantee your specific instance stays warm
- Multiple instances = multiple cold starts anyway
2. Use Provisioned Concurrency (AWS)
AWS lets you pay to keep Lambda instances warm.
Problems:
- Expensive ($0.000004/GB-second × 24/7)
- Still limited by memory/timeout
- You're now paying for idle compute
3. Use a Dedicated Server
Spin up an EC2/Droplet just for PDF generation.
Problems:
- Now you're managing infrastructure
- Scaling is manual
- You've abandoned the serverless model
4. Edge Functions
Some developers try edge functions for lower latency.
Problems:
- Edge runtimes are even more restricted
- No native binaries (no Chromium)
- Limited memory (usually 128MB)
The Real Cost Calculation
Let's do the math for a SaaS generating 10,000 PDFs/month.
Self-hosted on Vercel Pro:
- Pro plan: $20/month
- Function invocations: ~$5/month
- Execution time (5s avg × 10k): ~$15/month
- Total: ~$40/month
- Hidden cost: Your debugging time
Self-hosted on AWS Lambda:
- Lambda costs: ~$10/month
- Cold start latency: User frustration
- Memory tuning: Your weekends
- Total: ~$10/month + sanity
API-based (NeatPDF):
- 10k PDFs: $20/month
- Cold starts: Zero (always warm)
- Maintenance: Zero
- Total: $20/month
The API costs more than raw Lambda. But Lambda doesn't include your engineering time debugging memory leaks at 2am.
When Serverless Works
Serverless PDF generation can work if:
- Documents are simple (no images, system fonts only)
- Volume is low (<100/day)
- Latency doesn't matter (async/background jobs)
- You're okay with occasional timeouts
If that's you, here's a minimal setup:
// pages/api/pdf.ts
import puppeteer from 'puppeteer-core'
import chromium from '@sparticuz/chromium'
export const config = {
maxDuration: 60, // Vercel Pro required
}
export default async function handler(req, res) {
const browser = await puppeteer.launch({
args: chromium.args,
executablePath: await chromium.executablePath(),
headless: chromium.headless,
})
const page = await browser.newPage()
await page.setContent(req.body.html)
const pdf = await page.pdf({ format: 'A4' })
await browser.close()
res.setHeader('Content-Type', 'application/pdf')
res.send(pdf)
}Note: @sparticuz/chromium is a stripped-down Chromium build for Lambda. It's still 50MB compressed.
When to Use an API
If any of these are true, stop fighting serverless:
- Documents have images or custom fonts
- Users wait for the PDF (sync generation)
- Volume exceeds 1000/month
- You've already hit timeout issues
The NeatPDF Approach
We run dedicated Chromium pools. Always warm. No cold starts.
import { NeatPDF } from '@neatpdf/sdk'
const client = new NeatPDF({ apiKey: process.env.NEATPDF_API_KEY })
// This returns in 1-3 seconds, consistently
const pdf = await client.generate({
html: invoiceHtml,
format: 'A4',
})No timeout configuration. No memory tuning. No @sparticuz/chromium hacks.
Your serverless function just makes an HTTP call. That fits in any timeout.