Skip to content
WEB DEVELOPMENT

Puppeteer on Vercel: The Ultimate Guide to Serverless Browser Automation (2026)

Deploy Puppeteer on Vercel without the size-limit and dependency errors. @sparticuz/chromium-min is the fix — here's the complete guide with working code.

10 min readBy Daniel Olawoyinpuppeteer · next.js

The Nightmare Before @sparticuz/chromium-min

Picture this: It's 2 AM, you've been staring at your screen for hours, and Vercel's deployment logs are mocking you with yet another error. Your Puppeteer project works flawlessly on your local machine, screenshots are crisp, automation runs smooth as butter. But the moment you try to deploy it to Vercel? Chaos.

This was my reality for what felt like an eternity.

I had built a beautiful Next.js application that needed to generate screenshots of dynamic HTML content. Simple enough, right? Puppeteer handles this beautifully in development. Click a button, launch Chromium, capture the screenshot, done. It was elegant. It was perfect.

Until it wasn't.

The Serverless Reality Check

Here's what nobody tells you when you're getting started: serverless functions are wonderful until you try to run something that needs an entire browser. Vercel's serverless functions have size limits, execution time constraints, and a filesystem that's about as writable as a stone tablet.

My first deployment attempt was... optimistic. I pushed my code, watched the build process complete successfully (a false hope, in retrospect), and clicked deploy. The build succeeded. The deployment went through. I felt victorious.

Then I tested it.

Error: Failed to launch the browser process!
/var/task/node_modules/puppeteer/.local-chromium/linux-12345/chrome-linux/chrome: error while loading shared libraries: libX11.so.6: cannot open shared object file: No such file or directory

What? Shared libraries? I thought this was JavaScript. I didn't sign up for Linux dependency management at 2 AM.

The Rabbit Hole of Failed Solutions

Like any developer faced with an error message, I turned to Google. The search history tells the story:

  • "puppeteer vercel deployment error"
  • "puppeteer serverless aws lambda"
  • "why does puppeteer hate me"
  • "puppeteer alternatives that actually work"

I tried everything. I installed chrome-aws-lambda. Too large for Vercel's limits. I tried puppeteer-core with various Chromium binaries. Version mismatches everywhere. I attempted to use Docker layers (Vercel doesn't support those). I even considered rewriting my entire application to use a different approach.

Each solution led to a new error. It was like playing whack-a-mole, except every mole was a different Linux dependency or package size limit, and I was losing badly.

The Turning Point

Then, buried in a GitHub issue comment from someone who seemed equally exhausted, I found a mention of @sparticuz/chromium-min. The comment was brief, almost dismissive: "Just use @sparticuz/chromium-min. It works."

I was skeptical. I'd been burned too many times. But desperation breeds courage, and I had nothing left to lose except another few hours of sleep.

Enter @sparticuz/chromium-min: The Game Changer

Here's what makes @sparticuz/chromium-min different: it's a pre-compiled, stripped-down version of Chromium specifically designed for serverless environments. No massive binary sitting in your node_modules. No missing Linux dependencies. No size limit nightmares.

Instead of bundling Chromium with your deployment, it downloads a compressed version at runtime from a GitHub release. Genius.

Let me show you exactly how I got it working.

The Solution: Step by Step

Step 1: Install the Right Packages

First, I cleared out my old failed attempts:

bash
npm uninstall puppeteer chrome-aws-lambda

Then installed the winning combination:

bash
npm install puppeteer-core @sparticuz/chromium-min
npm install --save-dev puppeteer

Notice we're using puppeteer-core for production (it's lightweight, without the bundled Chromium) and regular puppeteer as a dev dependency for local development.

Step 2: Set Up the API Route

Here's the actual code that finally worked. This is from my Next.js API route for capturing screenshots:

typescript
import { NextRequest, NextResponse } from 'next/server';
import chromium from '@sparticuz/chromium-min';
import puppeteer from 'puppeteer-core';
import devPuppeteer from 'puppeteer';

export const dynamic = "force-dynamic";

// This is the magic line - the remote Chromium executable
const remoteExecutablePath = "https://github.com/Sparticuz/chromium/releases/download/v133.0.0/chromium-v133.0.0-pack.tar";

export async function POST(request: NextRequest): Promise<NextResponse> {
    try {
        const { html } = await request.json();

        if (!html) {
            return NextResponse.json(
                { error: 'HTML content is required' },
                { status: 400 }
            );
        }

        let browser: any;

        // Here's the key: different behavior for production vs development
        if (process.env.NODE_ENV === 'production') {
            // Get the executable path for the remote Chromium
            const executablePath = await chromium.executablePath(remoteExecutablePath);

            console.log("Using executable path:", executablePath);

            // Launch with specific args that work in serverless
            browser = await puppeteer.launch({
                executablePath,
                args: [
                    ...chromium.args,
                    '--no-sandbox',
                    '--disable-setuid-sandbox',
                    '--disable-dev-shm-usage',
                    '--disable-gpu',
                    '--single-process'
                ],
                headless: true,
                defaultViewport: chromium.defaultViewport,
                ignoreDefaultArgs: ['--disable-extensions']
            });
        } else {
            // Local development uses regular Puppeteer
            browser = await devPuppeteer.launch({
                headless: true
            });
        }

        const page = await browser.newPage();

        // Your actual Puppeteer logic here
        await page.setContent(html, {
            waitUntil: 'networkidle0',
            timeout: 30000,
        });

        const screenshot = await page.screenshot({
            type: 'jpeg',
            quality: 90,
            omitBackground: true,
        });

        await browser.close();

        return new NextResponse(screenshot, {
            status: 200,
            headers: {
                'Content-Type': 'image/jpeg',
                'Cache-Control': 'no-cache',
            },
        });
    } catch (error: any) {
        console.error('Screenshot error:', error);
        return NextResponse.json(
            { error: 'Failed to capture screenshot', details: error.message },
            { status: 500 }
        );
    }
}

Step 3: Understanding the Critical Parts

Let me break down what makes this work:

The Environment Check

typescript
if (process.env.NODE_ENV === 'production') {
    // Serverless setup
} else {
    // Local development setup
}

This is crucial. You want regular Puppeteer locally (because it's faster and easier to debug), but you need the special setup for production.

The Executable Path

typescript
const executablePath = await chromium.executablePath(remoteExecutablePath);

This line does the heavy lifting. It downloads the Chromium binary from GitHub the first time it runs, then caches it. That remote URL points to a specific version of the stripped-down Chromium build.

The Launch Arguments

typescript
args: [
    ...chromium.args,          // Pre-configured args for serverless
    '--no-sandbox',            // Required for containerized environments
    '--disable-setuid-sandbox', // Security requirement for serverless
    '--disable-dev-shm-usage', // Prevents shared memory issues
    '--disable-gpu',           // No GPU in serverless
    '--single-process'         // Keeps memory usage down
]

Each of these flags solves a specific problem I encountered. They're not random—they're battle-tested solutions to common serverless Chromium issues.

Step 4: Deploying to Vercel

With the code in place, deployment became straightforward:

  1. Commit your changes
  2. Push to your repository
  3. Let Vercel build and deploy

But here's a pro tip: the first deployment might take longer than usual because Chromium needs to be downloaded. Be patient. Subsequent invocations will be faster due to caching.

Common Pitfalls I Encountered (So You Don't Have To)

Pitfall 1: Version Mismatches

Make sure your puppeteer-core version is compatible with the Chromium version in the remote executable path. I learned this the hard way when my screenshots started failing with cryptic protocol errors.

Check the @sparticuz/chromium-min releases and match your Puppeteer version accordingly.

Pitfall 2: Timeout Errors

Serverless functions have execution time limits. On Vercel's free tier, you get 10 seconds. If your Puppeteer task takes longer, you'll need to:

  • Optimize your page loading (use waitUntil: 'networkidle0' carefully)
  • Consider upgrading your Vercel plan
  • Break complex tasks into smaller chunks

Pitfall 3: Memory Issues

Chromium is hungry. Really hungry. I initially ran into memory limit errors because I was trying to screenshot very large pages. Solutions:

  • Set a reasonable viewport size
  • Close the browser immediately after use (await browser.close())
  • Consider pagination for large content
  • Use --single-process flag (already in the code above)

The Sweet Taste of Success

After implementing this solution, my deployment went from a nightmare to boring—in the best possible way. The function works. Screenshots generate. Vercel doesn't complain. I sleep at night.

The first time I saw that screenshot successfully generated from my production deployment, I may have done a little victory dance. My cat was unimpressed, but I didn't care.

Performance Considerations

In production, here's what you can expect:

  • Cold starts: 3-5 seconds for the first invocation (Chromium download and initialization)
  • Warm starts: 1-2 seconds (much faster with caching)
  • Memory usage: ~250-400 MB depending on your page complexity
  • Success rate: 99%+ once properly configured

Cost Implications

One thing I worried about: would downloading Chromium on every cold start cost me a fortune in bandwidth or execution time?

Short answer: No. The caching is efficient, and the compressed Chromium binary is about 50 MB. After the first download, it's cached for subsequent invocations. Your main costs will be execution time and invocations, which are the same as any other serverless function.

Alternative Hosting Platforms

While this article focuses on Vercel, @sparticuz/chromium-min works beautifully on other platforms too:

  • AWS Lambda: This was actually the original use case
  • Netlify Functions: Works with similar configuration
  • Google Cloud Functions: Compatible with minor adjustments
  • Railway: Simpler setup due to fewer restrictions

The principles remain the same: use the environment check, configure the correct executable path, and use the proper launch arguments.

When NOT to Use This Approach

Let's be honest: this solution isn't perfect for everything. Consider alternatives if:

  1. You need very frequent, high-volume screenshot generation: A dedicated server might be cheaper and faster
  2. You require very large screenshots or complex rendering: Memory limits might be a problem
  3. You need features that require a full desktop environment: Some Chromium features don't work in headless serverless mode
  4. Your tasks consistently exceed serverless timeout limits: You might need a different architecture

My Current Setup

Today, my production setup looks like this:

json
{
  "dependencies": {
    "@sparticuz/chromium-min": "^133.0.0",
    "puppeteer-core": "^24.8.2"
  },
  "devDependencies": {
    "puppeteer": "^24.8.2"
  }
}

Clean, simple, and it actually works.

Lessons Learned

If I could go back and tell my 2 AM self anything, it would be:

  1. Don't fight the platform: Serverless has constraints. Work with them, not against them.
  2. Trust the community: That random GitHub comment saved me days of frustration.
  3. Version compatibility matters: Keep your Puppeteer and Chromium versions in sync.
  4. Test locally, but verify in production: What works on your machine might not work serverless—and vice versa.
  5. Read the error logs carefully: I wasted hours on red herrings because I didn't read the full error message.

Conclusion: From Frustration to Function

Deploying Puppeteer to Vercel (or any serverless platform) doesn't have to be a nightmare. With @sparticuz/chromium-min, it's actually straightforward, once you know what you're doing.

I went from wanting to throw my laptop out the window to having a production-ready screenshot API that handles thousands of requests without breaking a sweat. The journey was frustrating, but the destination made it worthwhile.

If you're facing the same struggles I did, I hope this article saves you some of the headaches I experienced. Clone the setup, adjust it to your needs, deploy it, and get back to building features instead of fighting deployment errors.

Your 2 AM self will thank you.

Quick Start Checklist

Before you go, here's your action plan:

  • Install puppeteer-core and @sparticuz/chromium-min
  • Install puppeteer as a dev dependency
  • Copy the environment-based browser launch code
  • Set up your API route with proper error handling
  • Test locally first
  • Deploy to Vercel
  • Test in production
  • Celebrate (seriously, you earned it)

Additional Resources

Happy deploying! May your builds be green and your screenshots crisp.

puppeteernext.js
Written by
Daniel Olawoyin

Full-stack & AI engineer based in Lagos. I build production systems with AI in them — voice agents, RAG pipelines, multi-tenant SaaS.