Skip to content

Instantly share code, notes, and snippets.

@kettanaito
Last active November 15, 2024 15:46
Show Gist options
  • Save kettanaito/56861aff96e6debc575d522dd03e5725 to your computer and use it in GitHub Desktop.
Save kettanaito/56861aff96e6debc575d522dd03e5725 to your computer and use it in GitHub Desktop.
Chromium on Vercel (serveless)

Chromium on Vercel (serverless)

This is an up-to-date guide on running Chromium in Vercel serverless functions in 2022. What you will read below is the result of two days of research, debugging, 100+ failed deployments, and a little bit of stress.

Getting started

Step 1: Install dependencies

Use chrome-aws-lambda that comes with Chromium pre-configured to run in serverless, and puppeteer-core due to the smaller size of Chromium distributive.

Turns out, choosing the right versions of dependencies is crucial. Newer versions of puppeteer-core ship larger Chromium distributive, which will exceed the 50MB function size limit on Vercel.

{
  "chrome-aws-lambda": "10.1.0",
  // Install v10 to have a smaller Chromium distributive.
  "puppeteer-core": "10.1.0"
}

If you feel adventerous and wish to update dependencies, start from updating chrome-aws-lambda. Its peer dependency on puppeteer-core will tell you the maximum supported version to use.

Why not Playwright?

Playwright comes with a larger Chromimum instance that would exceed the maximum allowed serverless function size limit of 50MB on Vercel (transitively, AWS).

Step 2: Write a function

The way you write your function does not matter: it may be a Node.js function, a part of your Next.js /api routes, or a Remix application. What matters on this stage is to launch Puppeteer with correct options.

// api/run.js
import edgeChromium from 'chrome-aws-lambda'

// Importing Puppeteer core as default otherwise
// it won't function correctly with "launch()"
import puppeteer from 'puppeteer-core'

// You may want to change this if you're developing
// on a platform different from macOS.
// See https://github.com/vercel/og-image for a more resilient
// system-agnostic options for Puppeteeer.
const LOCAL_CHROME_EXECUTABLE = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'

export default async function (req, res) {
  // Edge executable will return an empty string locally.
  const executablePath = await edgeChromium.executablePath || LOCAL_CHROME_EXECUTABLE
  
  const browser = await puppeteer.launch({
    executablePath,
    args: edgeChromium.args,
    headless: false,
  })
  
  const page = await browser.newPage()
  await page.goto('https://github.com')
  
  res.send('hello')
}

Step 3: Configure Vercel deployment

Choose Node.js 14.x

puppeteer-core@10 doesn't work well with newer versions of Node.js. The pinned version should be Node.js 14.

  1. Go to your Vercel project.
  2. Go to "Settings", then "General".
  3. Locate the "Node.js version" section.
  4. Select "Node 14".

Screen Shot 2022-10-22 at 13 11 56

In addition to configuring this on Vercel, make sure that your project's package.json doesn't have the engines.node property that would contradict your choise:

{
  "engines": {
    // If you have a newer version of Node in your package.json
    // Vercel will respect that and disregard what you've set
    // in your project's settings.
    "node": "14.x"
  }
}

Basically, you should not see this warning in your deployment logs:

Warning: Detected "engines": { "node": ">=14" } in your `package.json` that will automatically upgrade when a new major Node.js Version is released. Learn More: http://vercel.link/node-version

If you do, this means your Node.js version is not configured correctly and you should repeat this section.


References


Troubleshooting

Serverless function exceeds the maximum size limit of 50mb

Error: The Serverless Function "XYZ" is XX.YYmb which exceeds the maximum size limit of 50mb. Learn More: https://vercel.link/serverless-function-size

This means you're deploying a Chromium executable that's too large. Solve this by Installing the right dependency versions.

libnss3.so

Error while loading shared libraries: libnss3.so: cannot open shared object file: No such file or directory

This error means you're running an older/newer version of Node.js rather than Node.js 14. Solve this by Using the right Node.js version.

People on the web will suggest all sorts of things to tackle this, like installing libnss3.so manually, but fret not: it's solved by using the right version of Node.js.


If you found this helpful, consider Buying me a cup of coffee.

@kietn20
Copy link

kietn20 commented Jan 20, 2024

I do not use yarn 🤷
the main is deployed on Vercel I am using node v18

@vikiival Have you ever gotten this error message?

The input directory "/var/task/node_modules/@sparticuz/chromium-min/bin" does not exist.

I am in a similar situation.
👇 When I try the URL to chromium tar file, I get gateway timeout on vercel logs.
executablePath: await chromium.executablePath("https://github.com/Sparticuz/chromium/releases/download/v119.0.2/chromium-v119.0.2-pack.tar")

When I use await chromium.executablePath(), I get The input directory "/var/task/node_modules/@sparticuz/chromium-min/bin" does not exist.
Anyone know a solution?

@tomsoderlund
Copy link

@kietn20 You might want to check this conversation with @vikiival: vikiival/vercelgl#8

@tomsoderlund
Copy link

tomsoderlund commented Jan 22, 2024

So if you are looking for a working version in 2024

TL;DR

  "dependencies": {
    "@sparticuz/chromium": "^119.0.2",
    "puppeteer-core": "^21.5.1"
  },
  "engines": {
    "node": "18"
  }

I got @vikiival’s example to run eventually on Vercel.

Using pnpm as package manager seemed to make a difference.

And you need to increase maxDuration for the serverless functions in vercel.json:

"functions": {
  "api/**/*": {
    "maxDuration": 60
  }
}

@vikiival
Copy link

I got @vikiival’s example to run eventually on Vercel.

Glad that it works. Can I ask for for one star on the repo? 🥺

@Vansh1190
Copy link

Vansh1190 commented Feb 4, 2024

I got @vikiival’s example to run eventually on Vercel.

Glad that it works. Can I ask for for one star on the repo? 🥺

i am getting this, 504 Gateway Timeout
how i solve this ? also it is coming in your own vercel deployment

@vikiival
Copy link

vikiival commented Feb 4, 2024

i am getting this, 504 Gateway Timeout
how i solve this ?

Buy vercel premium :)

@tomsoderlund
Copy link

tomsoderlund commented Feb 4, 2024

i am getting this, 504 Gateway Timeout how i solve this ? also it is coming in your own vercel deployment

You need to increase the max duration for the serverless functions, see above https://gist.github.com/kettanaito/56861aff96e6debc575d522dd03e5725?permalink_comment_id=4843842#gistcomment-4843842

@vikiival
Copy link

vikiival commented Feb 4, 2024

i am getting this, 504 Gateway Timeout how i solve this ? also it is coming in your own vercel deployment

You need to increase the max duration for the serverless functions, see above https://gist.github.com/kettanaito/56861aff96e6debc575d522dd03e5725?permalink_comment_id=4843842#gistcomment-4843842

Added to the readme, thanks ❤️

vikiival/vercelgl@1ed08b4

@exchai93
Copy link

So if you are looking for a working version in 2024
TL;DR

  "dependencies": {
    "@sparticuz/chromium": "^119.0.2",
    "puppeteer-core": "^21.5.1"
  },
  "engines": {
    "node": "18"
  }

I got @vikiival’s example to run eventually on Vercel.

Using pnpm as package manager seemed to make a difference.

And you need to increase maxDuration for the serverless functions in vercel.json:

"functions": {
  "api/**/*": {
    "maxDuration": 60
  }
}

I've done all of the above, except specify the api route more specifically where I'm using puppeteer and chromium. I get an error to do with loaders:

./node_modules/.pnpm/[email protected]/node_modules/puppeteer-core/lib/esm/puppeteer/cdp/EmulationManager.js
--
19:15:50.724 | Module parse failed: Unexpected token (174:32)
19:15:50.724 | File was processed with these loaders:
19:15:50.724 | * ./node_modules/.pnpm/[email protected]_@[email protected][email protected][email protected]/node_modules/next/dist/build/webpack/loaders/next-flight-loader/index.js
19:15:50.724 | * ./node_modules/.pnpm/[email protected]_@[email protected][email protected][email protected]/node_modules/next/dist/build/webpack/loaders/next-swc-loader.js
19:15:50.724 | You may need an additional loader to handle the result of these loaders.
19:15:50.724 | \|                 private: true,
19:15:50.724 | \|                 access: {
19:15:50.725 | >                     has: (obj)=>#applyViewport in obj,
19:15:50.725 | \|                     get: (obj)=>obj.#applyViewport
19:15:50.725 | \|                 },
19:15:50.725 |  
19:15:50.725 | Import trace for requested module:
19:15:50.725 | ./node_modules/.pnpm/[email protected]/node_modules/puppeteer-core/lib/esm/puppeteer/cdp/EmulationManager.js
19:15:50.725 | ./node_modules/.pnpm/[email protected]/node_modules/puppeteer-core/lib/esm/puppeteer/cdp/cdp.js
19:15:50.725 | ./node_modules/.pnpm/[email protected]/node_modules/puppeteer-core/lib/esm/puppeteer/puppeteer-core.js
19:15:50.725 | ./src/app/api/download-pdf/route.ts

Any ideas on what's going on here?

@jferrettiboke
Copy link

@exchai93 Are you using Next.js? If so, add this to your next.config.js, and restart your server.

/** @type {import('next').NextConfig} */
const config = {
  // ...
  
  experimental: {
    serverComponentsExternalPackages: [
      'puppeteer-core',
      '@sparticuz/chromium-min',
    ],
  },
};

export default config;

@gruckion
Copy link

gruckion commented Apr 3, 2024

Okay I've managed to get it to work. I am on an M1 Mac!

https://github.com/gruckion/puppeteer-running-in-vercel <- Repo

You will need Chromium on your machine.

brew install --cask chromium
If you run into issues use this guide here

TLDR?

  1. Bundle size needs to be 50mb or less so use puppeteer-core for production.
  2. Use @sparticuz/chromium for the chromium instance in Serverless environment
  3. If you need less than 50mb then use @sparticuz/chromium-min and host the tz yourself.
// src/app/api/route.ts

import { NextRequest, NextResponse } from "next/server";
import puppeteerCore from "puppeteer-core";
import puppeteer from "puppeteer";
import chromium from "@sparticuz/chromium";

export const dynamic = "force-dynamic";

async function getBrowser() {
  if (process.env.VERCEL_ENV === "production") {
    const executablePath = await chromium.executablePath();

    const browser = await puppeteerCore.launch({
      args: chromium.args,
      defaultViewport: chromium.defaultViewport,
      executablePath,
      headless: chromium.headless,
    });
    return browser;
  } else {
    const browser = await puppeteer.launch();
    return browser;
  }
}

export async function GET(request: NextRequest) {
  const browser = await getBrowser();

  const page = await browser.newPage();
  await page.goto("https://example.com");
  const pdf = await page.pdf();
  await browser.close();
  return new NextResponse(pdf, {
    headers: {
      "Content-Type": "application/pdf",
    },
  });
}
// next.config.mjs

/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    serverComponentsExternalPackages: ['puppeteer-core', '@sparticuz/chromium'],
  }
};

export default nextConfig;
// package.json

{
  "name": "test-pdf-generate",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@sparticuz/chromium": "^123.0.0",
    "next": "14.1.4",
    "puppeteer": "^22.6.2",
    "puppeteer-core": "^22.6.2",
    "react": "^18",
    "react-dom": "^18"
  },
  "devDependencies": {
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "autoprefixer": "^10.0.1",
    "eslint": "^8",
    "eslint-config-next": "14.1.4",
    "postcss": "^8",
    "tailwindcss": "^3.3.0",
    "typescript": "^5"
  }
}

Credits

Unexpected token in EmulationManager.js when used with Next.js

puppeteer/puppeteer#11052
Sparticuz/chromium#147 (comment)
https://nextjs.org/docs/app/api-reference/next-config-js/serverComponentsExternalPackages

Cannot spawn Chromium with custom executablePath on Apple M1

puppeteer/puppeteer#6634
Sparticuz/chromium#63 (comment)

@aymanechaaba1
Copy link

@gruckion Do you have to add VERCEL_ENV to your .env.local and your vercel environment variables settings?

@Christopher-Hayes
Copy link

Christopher-Hayes commented May 6, 2024

Anyone coming to this gist in 2024 - keep in mind this gist requires that you use Node v14 (2018) and Puppeteer v10 (2021). Right now, stable versions of those packages are Node v20 (2024), and Puppeteer v22 (2024). You probably do not want to use versions that old. Vercel doesn't even let you use Node v14 on functions anymore.

The comments do have good alternatives though. @sparticuz/chromium-min worked for me (I did the same thing as this code). Don't re-use the .tar link in that example, host it yourself. The supabase file download is a bit slow.

However, be ready for puppeteer to run slow on serverless functions though, longer function timeouts are probably needed. Vercel for example will be ~4x-8x slower than your dev machine. Unless you use more powerful Vercel function CPUs. For example, my script on the "Basic" CPU took ~50s, and the "Performance" CPU took ~10s.

@andywillekens
Copy link

Anyone coming to this gist in 2024 - keep in mind this gist requires that you use Node v14 (2018) and Puppeteer v10 (2021). Right now, stable versions of those packages are Node v20 (2024), and Puppeteer v22 (2024). You probably do not want to use versions that old. Vercel doesn't even let you use Node v14 on functions anymore.

The comments do have good alternatives though. @sparticuz/chromium-min worked for me (I did the same thing as this code). Don't re-use the .tar link in that example, host it yourself. The supabase file download is a bit slow.

However, be ready for puppeteer to run slow on serverless functions though, longer function timeouts are probably needed. Vercel for example will be ~4x-8x slower than your dev machine. Unless you use more powerful Vercel function CPUs. For example, my script on the "Basic" CPU took ~50s, and the "Performance" CPU took ~10s.

Thanks in a bunch! I got this to work with Nuxt. This also fixed it not working locally 💯

@alexrmsouza
Copy link

Okay I've managed to get it to work. I am on an M1 Mac!

https://github.com/gruckion/puppeteer-running-in-vercel <- Repo

You will need Chromium on your machine.

brew install --cask chromium If you run into issues use this guide here

TLDR?

  1. Bundle size needs to be 50mb or less so use puppeteer-core for production.
  2. Use @sparticuz/chromium for the chromium instance in Serverless environment
  3. If you need less than 50mb then use @sparticuz/chromium-min and host the tz yourself.
// src/app/api/route.ts

import { NextRequest, NextResponse } from "next/server";
import puppeteerCore from "puppeteer-core";
import puppeteer from "puppeteer";
import chromium from "@sparticuz/chromium";

export const dynamic = "force-dynamic";

async function getBrowser() {
  if (process.env.VERCEL_ENV === "production") {
    const executablePath = await chromium.executablePath();

    const browser = await puppeteerCore.launch({
      args: chromium.args,
      defaultViewport: chromium.defaultViewport,
      executablePath,
      headless: chromium.headless,
    });
    return browser;
  } else {
    const browser = await puppeteer.launch();
    return browser;
  }
}

export async function GET(request: NextRequest) {
  const browser = await getBrowser();

  const page = await browser.newPage();
  await page.goto("https://example.com");
  const pdf = await page.pdf();
  await browser.close();
  return new NextResponse(pdf, {
    headers: {
      "Content-Type": "application/pdf",
    },
  });
}
// next.config.mjs

/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    serverComponentsExternalPackages: ['puppeteer-core', '@sparticuz/chromium'],
  }
};

export default nextConfig;
// package.json

{
  "name": "test-pdf-generate",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@sparticuz/chromium": "^123.0.0",
    "next": "14.1.4",
    "puppeteer": "^22.6.2",
    "puppeteer-core": "^22.6.2",
    "react": "^18",
    "react-dom": "^18"
  },
  "devDependencies": {
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "autoprefixer": "^10.0.1",
    "eslint": "^8",
    "eslint-config-next": "14.1.4",
    "postcss": "^8",
    "tailwindcss": "^3.3.0",
    "typescript": "^5"
  }
}

Credits

Unexpected token in EmulationManager.js when used with Next.js

puppeteer/puppeteer#11052 Sparticuz/chromium#147 (comment) https://nextjs.org/docs/app/api-reference/next-config-js/serverComponentsExternalPackages

Cannot spawn Chromium with custom executablePath on Apple M1

puppeteer/puppeteer#6634 Sparticuz/chromium#63 (comment)

Thanks for the help, this is exactly what I need!

@Himture
Copy link

Himture commented May 28, 2024

Okay I've managed to get it to work. I am on an M1 Mac!

https://github.com/gruckion/puppeteer-running-in-vercel <- Repo

You will need Chromium on your machine.

brew install --cask chromium If you run into issues use this guide here

TLDR?

  1. Bundle size needs to be 50mb or less so use puppeteer-core for production.
  2. Use @sparticuz/chromium for the chromium instance in Serverless environment
  3. If you need less than 50mb then use @sparticuz/chromium-min and host the tz yourself.
// src/app/api/route.ts

import { NextRequest, NextResponse } from "next/server";
import puppeteerCore from "puppeteer-core";
import puppeteer from "puppeteer";
import chromium from "@sparticuz/chromium";

export const dynamic = "force-dynamic";

async function getBrowser() {
  if (process.env.VERCEL_ENV === "production") {
    const executablePath = await chromium.executablePath();

    const browser = await puppeteerCore.launch({
      args: chromium.args,
      defaultViewport: chromium.defaultViewport,
      executablePath,
      headless: chromium.headless,
    });
    return browser;
  } else {
    const browser = await puppeteer.launch();
    return browser;
  }
}

export async function GET(request: NextRequest) {
  const browser = await getBrowser();

  const page = await browser.newPage();
  await page.goto("https://example.com");
  const pdf = await page.pdf();
  await browser.close();
  return new NextResponse(pdf, {
    headers: {
      "Content-Type": "application/pdf",
    },
  });
}
// next.config.mjs

/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    serverComponentsExternalPackages: ['puppeteer-core', '@sparticuz/chromium'],
  }
};

export default nextConfig;
// package.json

{
  "name": "test-pdf-generate",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@sparticuz/chromium": "^123.0.0",
    "next": "14.1.4",
    "puppeteer": "^22.6.2",
    "puppeteer-core": "^22.6.2",
    "react": "^18",
    "react-dom": "^18"
  },
  "devDependencies": {
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "autoprefixer": "^10.0.1",
    "eslint": "^8",
    "eslint-config-next": "14.1.4",
    "postcss": "^8",
    "tailwindcss": "^3.3.0",
    "typescript": "^5"
  }
}

Credits

Unexpected token in EmulationManager.js when used with Next.js

puppeteer/puppeteer#11052 Sparticuz/chromium#147 (comment) https://nextjs.org/docs/app/api-reference/next-config-js/serverComponentsExternalPackages

Cannot spawn Chromium with custom executablePath on Apple M1

puppeteer/puppeteer#6634 Sparticuz/chromium#63 (comment)

This works perfectly 👍. Thank you for sharing this approach.

@ham-evans
Copy link

this worked for me: Sparticuz/chromium#147

@sekedus
Copy link

sekedus commented Jul 27, 2024

This repo worked for me, use Nextjs with maxDuration 60 seconds:

=> hehehai/headless-try

@ryancwalsh
Copy link

@Himture Thanks for your example.

Mine looks nearly identical to that, and it works locally with vercel dev, but unfortunately when deployed to production on Vercel's free plan, I get this error:

Error: Failed to load page
    at c (/var/task/.next/server/pages/api/ig.js:1:906)
    at async K (/var/task/node_modules/next/dist/compiled/next-server/pages-api.runtime.prod.js:20:16545)
    at async U.render (/var/task/node_modules/next/dist/compiled/next-server/pages-api.runtime.prod.js:20:16981)
    at async r2.runApi (/var/task/node_modules/next/dist/compiled/next-server/server.runtime.prod.js:17:42119)
    at async r2.handleCatchallRenderRequest (/var/task/node_modules/next/dist/compiled/next-server/server.runtime.prod.js:17:36819)
    at async r2.runImpl (/var/task/node_modules/next/dist/compiled/next-server/server.runtime.prod.js:16:17074)
    at async r2.handleRequestImpl (/var/task/node_modules/next/dist/compiled/next-server/server.runtime.prod.js:16:16167)
    at async Server.<anonymous> (/var/task/___next_launcher.cjs:26:5)
    at async Server.<anonymous> (/opt/rust/nodejs.js:8:3933)

I still use Pages Router instead of App Router, though, since I prefer it. I wonder if that matters.

I see from @sekedus that https://github.com/hehehai/headless-try/blob/66cfd6294ac93bb1e1d563955582e0af62add48e/src/app/try/route.js uses App Router too.

Maybe I'll try forking that repo (and converting to TypeScript).

Thanks.

@khobiziilyes
Copy link

This repo worked for me, use Nextjs with maxDuration 60 seconds:

=> hehehai/headless-try

Works, thanks!

@Decole92
Copy link

Okay I've managed to get it to work. I am on an M1 Mac!
https://github.com/gruckion/puppeteer-running-in-vercel <- Repo
You will need Chromium on your machine.
brew install --cask chromium If you run into issues use this guide here
TLDR?

  1. Bundle size needs to be 50mb or less so use puppeteer-core for production.
  2. Use @sparticuz/chromium for the chromium instance in Serverless environment
  3. If you need less than 50mb then use @sparticuz/chromium-min and host the tz yourself.
// src/app/api/route.ts

import { NextRequest, NextResponse } from "next/server";
import puppeteerCore from "puppeteer-core";
import puppeteer from "puppeteer";
import chromium from "@sparticuz/chromium";

export const dynamic = "force-dynamic";

async function getBrowser() {
  if (process.env.VERCEL_ENV === "production") {
    const executablePath = await chromium.executablePath();

    const browser = await puppeteerCore.launch({
      args: chromium.args,
      defaultViewport: chromium.defaultViewport,
      executablePath,
      headless: chromium.headless,
    });
    return browser;
  } else {
    const browser = await puppeteer.launch();
    return browser;
  }
}

export async function GET(request: NextRequest) {
  const browser = await getBrowser();

  const page = await browser.newPage();
  await page.goto("https://example.com");
  const pdf = await page.pdf();
  await browser.close();
  return new NextResponse(pdf, {
    headers: {
      "Content-Type": "application/pdf",
    },
  });
}
// next.config.mjs

/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    serverComponentsExternalPackages: ['puppeteer-core', '@sparticuz/chromium'],
  }
};

export default nextConfig;
// package.json

{
  "name": "test-pdf-generate",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@sparticuz/chromium": "^123.0.0",
    "next": "14.1.4",
    "puppeteer": "^22.6.2",
    "puppeteer-core": "^22.6.2",
    "react": "^18",
    "react-dom": "^18"
  },
  "devDependencies": {
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "autoprefixer": "^10.0.1",
    "eslint": "^8",
    "eslint-config-next": "14.1.4",
    "postcss": "^8",
    "tailwindcss": "^3.3.0",
    "typescript": "^5"
  }
}

Credits

Unexpected token in EmulationManager.js when used with Next.js

puppeteer/puppeteer#11052 Sparticuz/chromium#147 (comment) https://nextjs.org/docs/app/api-reference/next-config-js/serverComponentsExternalPackages

Cannot spawn Chromium with custom executablePath on Apple M1

puppeteer/puppeteer#6634 Sparticuz/chromium#63 (comment)

This works perfectly 👍. Thank you for sharing this approach.
thank you

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment