DEV Community

Andreas
Andreas

Posted on

Headless Chrome on Vercel: Build a Screenshot API That Survives Cold Starts

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
Enter fullscreen mode Exit fullscreen mode

Directory layout:

.
├─ api/
│  └─ screenshot.ts        # or .js — your serverless function
├─ package.json
└─ vercel.json             # function memory/timeout config
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
  • 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 or engines in package.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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

On modern Node (18+), prefer @sparticuz/chromium which stays up‑to‑date and avoids missing‑library issues.


Testing locally

  • Run vercel dev (or npm run dev). For local dev, if @sparticuz/chromium can’t find a local Chrome, your dev dependency puppeteer provides one you can use by conditionally switching:
    • If process.env.VERCEL is set, use serverless Chromium.
    • Else (local), use puppeteer with executablePath: (await puppeteer.executablePath()).
  • Hit http://localhost:3000/api/screenshot?url=https://example.com&amp;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
Enter fullscreen mode Exit fullscreen mode

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 and format=png (default).
  • PDF: route format=pdf to page.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)