If you’ve ever tried running Puppeteer on Vercel’s serverless platform, you’ve probably hit a wall. Deploying a full headless Chrome in a serverless function is tricky — from cold start delays to strict bundle size limits. In this guide, you’ll build a screenshot API on Vercel Functions using Puppeteer (puppeteer-core) with a slim Chromium binary so you stay within Vercel’s limits and keep startup times in check. We’ll cover why serverless Chrome is hard, how to wire up puppeteer-core
with a serverless-friendly Chromium, production settings, and practical ways to benchmark and reduce cold starts.
Why headless Chrome is hard on Vercel
Two main hurdles:
1) Bundle size limits. Vercel caps the compressed serverless function bundle at ~50 MB (≈250 MB uncompressed). The standard puppeteer
package ships Chromium (hundreds of MB) which will blow past the limit. Fix: use puppeteer-core
(no bundled browser) plus a serverless Chromium binary at runtime.
2) Cold starts. When a function is idle and then invoked, Vercel must provision a container, boot Node, and launch Chromium. That first request can be seconds slower than subsequent “warm” requests. You’ll measure and reduce this overhead (memory, dependency slimming, Fluid compute, and optional warming).
Approach overview
We’ll implement two options and pick the best for your runtime:
-
Recommended (Node 18+):
puppeteer-core
+@sparticuz/chromium
— actively maintained serverless Chromium that works on modern runtimes. -
Alternative (Node 16):
puppeteer-core
+chrome-aws-lambda
— older but still widely used. Good fallback if you’re pinned to Node 16.
Both expose an executablePath()
and args
suitable for serverless Linux. You pass those to puppeteer.launch(...)
so Chromium isn’t bundled with your code but resolved at runtime.
Project setup
Create a fresh project (or add to an existing Vercel repo):
mkdir vercel-screenshot-api && cd $_
npm init -y
# Choose one Chromium provider (recommended Sparticuz for Node 18+)
npm install puppeteer-core @sparticuz/chromium
# Optional (for local dev only): full puppeteer so you can run locally without serverless Chromium
npm install -D puppeteer
Directory layout:
.
├─ api/
│ └─ screenshot.ts # or .js — your serverless function
├─ package.json
└─ vercel.json # function memory/timeout config
Serverless function (TypeScript)
If you prefer plain JS, a
.js
variant is below. The logic is identical.
Create api/screenshot.ts
:
// api/screenshot.ts
import type { VercelRequest, VercelResponse } from '@vercel/node'
import chromium from '@sparticuz/chromium'
import puppeteer from 'puppeteer-core'
// Optional: keep a browser reference to reuse on warm invocations (advanced)
let _browser: puppeteer.Browser | null = null
export default async function handler(req: VercelRequest, res: VercelResponse) {
const started = Date.now()
const { url, fullpage, format, quality, width, height, delay } = req.query as Record<string, string | undefined>
if (!url || !/^https?:\/\//i.test(url)) {
return res.status(400).json({ error: 'Provide a valid ?url=https://example.com' })
}
// Configure launch flags from serverless Chromium
const executablePath = await chromium.executablePath()
const launchArgs = chromium.args
const headless = true
let browser = _browser
try {
// (Re)launch if none (or if you prefer strict per-request lifecycle, always launch a new one)
if (!browser) {
browser = await puppeteer.launch({
args: launchArgs,
executablePath,
headless,
defaultViewport: null
})
_browser = browser
}
const page = await browser.newPage()
// Optional deterministic viewport for consistent images
const w = Math.max(320, parseInt(width || '1280', 10))
const h = Math.max(320, parseInt(height || '800', 10))
await page.setViewport({ width: w, height: h, deviceScaleFactor: 2 })
// Navigate and wait for a stable render
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60_000 })
// Optional extra delay for late-loading content
const extraDelay = Math.max(0, parseInt(delay || '0', 10))
if (extraDelay) await page.waitForTimeout(extraDelay)
// Choose image format
const fmt = (format || 'png').toLowerCase()
const isJpeg = fmt === 'jpg' || fmt === 'jpeg'
const qp = Math.min(100, Math.max(0, parseInt(quality || (isJpeg ? '80' : '0'), 10)))
const buffer = await page.screenshot({
fullPage: fullpage === 'true',
type: isJpeg ? 'jpeg' : 'png',
...(isJpeg ? { quality: qp } : {})
})
await page.close()
// Add basic benchmarking headers
const took = Date.now() - started
res.setHeader('x-duration-ms', String(took))
res.setHeader('content-type', isJpeg ? 'image/jpeg' : 'image/png')
return res.status(200).send(buffer)
} catch (err: any) {
console.error('screenshot error', err)
// If you reuse the browser and it crashes, reset so the next call relaunches
_browser = null
return res.status(500).json({ error: 'Failed to capture screenshot' })
} finally {
// If you want strict per-request isolation (more stable, slower),
// close the browser here and set _browser = null.
// await browser?.close()
// _browser = null
}
}
Plain JavaScript variant
// api/screenshot.js
import chromium from '@sparticuz/chromium'
import puppeteer from 'puppeteer-core'
let _browser = null
export default async function handler(req, res) {
const started = Date.now()
const { url, fullpage, format, quality, width, height, delay } = req.query
if (!url || !/^https?:\/\//i.test(url)) {
return res.status(400).json({ error: 'Provide a valid ?url=https://example.com' })
}
const executablePath = await chromium.executablePath()
const launchArgs = chromium.args
const headless = true
let browser = _browser
try {
if (!browser) {
browser = await puppeteer.launch({
args: launchArgs,
executablePath,
headless,
defaultViewport: null
})
_browser = browser
}
const page = await browser.newPage()
const w = Math.max(320, parseInt(width || '1280', 10))
const h = Math.max(320, parseInt(height || '800', 10))
await page.setViewport({ width: w, height: h, deviceScaleFactor: 2 })
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60_000 })
const extraDelay = Math.max(0, parseInt(delay || '0', 10))
if (extraDelay) await page.waitForTimeout(extraDelay)
const fmt = (format || 'png').toLowerCase()
const isJpeg = fmt === 'jpg' || fmt === 'jpeg'
const qp = Math.min(100, Math.max(0, parseInt(quality || (isJpeg ? '80' : '0'), 10)))
const buffer = await page.screenshot({
fullPage: fullpage === 'true',
type: isJpeg ? 'jpeg' : 'png',
...(isJpeg ? { quality: qp } : {})
})
await page.close()
const took = Date.now() - started
res.setHeader('x-duration-ms', String(took))
res.setHeader('content-type', isJpeg ? 'image/jpeg' : 'image/png')
return res.status(200).send(buffer)
} catch (err) {
console.error('screenshot error', err)
_browser = null
return res.status(500).json({ error: 'Failed to capture screenshot' })
} finally {
// await browser?.close(); _browser = null
}
}
Reusing the browser (
_browser
) speeds up warm requests by avoiding a new Chromium launch, but each Vercel instance handles requests sequentially. If you expect parallel invocations or want stricter isolation, remove the reuse and close the browser every request.
Function configuration (memory & timeout)
Create vercel.json
with per‑function settings:
{
"functions": {
"api/screenshot.(js|ts)": {
"memory": 1024,
"maxDuration": 10
}
}
}
- memory: Give Chrome room (1024 MB is a good starting point).
- maxDuration: 10s usually suffices; increase on Pro plans if needed.
- If you’re pinned to Node 16 and want
chrome-aws-lambda
, you can set the runtime via project settings orengines
inpackage.json
.
package.json (example)
{
"name": "vercel-screenshot-api",
"private": true,
"type": "module",
"scripts": {
"dev": "vercel dev",
"build": "echo \"Vercel builds serverless functions automatically\"",
"start": "vercel dev"
},
"dependencies": {
"@sparticuz/chromium": "^117.0.0",
"puppeteer-core": "^23.0.0"
},
"devDependencies": {
"puppeteer": "^23.0.0"
}
}
Optional: Node 16 alternative with chrome-aws-lambda
If you’re targeting Node 16, swap imports and launch parameters:
// api/screenshot.js
import chromium from 'chrome-aws-lambda'
import puppeteer from 'puppeteer-core'
export default async function handler(req, res) {
const browser = await puppeteer.launch({
args: chromium.args,
executablePath: await chromium.executablePath,
headless: chromium.headless
})
// ...same logic as above
}
On modern Node (18+), prefer
@sparticuz/chromium
which stays up‑to‑date and avoids missing‑library issues.
Testing locally
- Run
vercel dev
(ornpm run dev
). For local dev, if@sparticuz/chromium
can’t find a local Chrome, your dev dependencypuppeteer
provides one you can use by conditionally switching:- If
process.env.VERCEL
is set, use serverless Chromium. - Else (local), use
puppeteer
withexecutablePath: (await puppeteer.executablePath())
.
- If
- Hit
http://localhost:3000/api/screenshot?url=https://example.com&fullpage=true
and verify an image is returned.
Benchmarking cold starts
Add simple timing headers (already in the example) and test:
1) Cold test: Deploy, wait 5–10 minutes, then call:
curl -s -D - "https://<your-app>.vercel.app/api/screenshot?url=https://example.com" -o /dev/null
Check the x-duration-ms
response header.
2) Warm test: Call again immediately — the header should drop significantly.
Reduce cold starts
-
Slim dependencies:
puppeteer-core
only; avoid heavy libs in the same function. - More memory: 1024 MB+ shortens Chromium startup and JS init.
- Browser reuse: Keep a global browser (as shown) to skip relaunch on warm calls.
- Periodic warming: Ping the endpoint every ~10 min (e.g., cron). Use sparingly.
- Fluid Compute: Enable Vercel’s Fluid compute so instances stay warm most of the time, cutting perceived cold starts dramatically.
Feature extensions
You can evolve this endpoint into a robust API:
-
Device emulation:
page.emulate()
or set mobile viewport & UA. -
Output formats: support
format=jpeg&quality=80
andformat=png
(default). -
PDF: route
format=pdf
topage.pdf({ format: 'A4', printBackground: true })
. -
Element-only shots:
const el = await page.$(selector); el.screenshot({ path })
. - Auth pages: pass cookies/headers via query or secure storage. Beware of secrets.
- Rate limiting: prevent abuse; add API keys if you’re exposing this publicly.
When not to use Vercel Functions for screenshots
Consider alternatives if you need:
- Consistently sub‑second latency for user‑facing flows (cold starts still happen).
- High concurrency / heavy pages (lots of simultaneous Chromes can be costly).
- Long‑running sessions (login flows, multi‑step navigation per request).
Alternatives: run a long‑lived microservice (Fly.io, Railway, EC2) with a browser pool, or use a managed browserless/screenshot service. For many products, that’s simpler and more predictable at scale.
Summary
You built a Vercel Function that runs Puppeteer (puppeteer‑core) with a serverless‑friendly Chromium to capture screenshots on demand — without busting Vercel’s bundle limits. You learned how to measure cold starts and apply practical mitigations (memory, dependency slimming, instance reuse, Fluid compute). This pattern is perfect for on‑demand thumbnails, QA captures, or PDF export — and it’s easy to extend with device emulation, full‑page shots, and more.
Happy shipping — and may your cold starts be rare and short! 🚀
Top comments (0)