Puppeteer on Vercel: the failures we hit, and the fixes.
Framejet runs Puppeteer with @sparticuz/chromium on Vercel Functions. These are the failures that reached production or staging while building it, with the error you see and the change that fixed it.
The problem
Puppeteer on Vercel works in the demo and then fails on real pages. The Chromium build that fits a function runs as a single process, so one bad page can take down the whole browser, and the errors are often a bare 500 or a timeout with no hint of the cause.
How Framejet handles it
Every section below is a failure seen on Framejet's own deployment, with the symptom, the cause we confirmed and the fix now in production. If you would rather not maintain these fixes, the same capture is one HTTP call to Framejet.
The setup these notes assume
Next.js on Vercel Functions (Node.js runtime), puppeteer-core and @sparticuz/chromium, the stripped Chromium build that fits a function bundle. The launch code is short:
import chromium from "@sparticuz/chromium";
import puppeteer from "puppeteer-core";
chromium.setGraphicsMode = false; // no WebGL, less memory
const browser = await puppeteer.launch({
args: await puppeteer.defaultArgs({ args: chromium.args, headless: "shell" }),
executablePath: await chromium.executablePath(),
});@sparticuz/chromium starts Chromium with --single-process. Most of the failures below follow from that one flag: the renderer and the browser share a process, so anything that crashes a page crashes everything.
1. The function cannot find Chromium
Symptom: the route works locally and fails on Vercel because the Chromium binary is not in the deployed bundle. Cause: Next.js traces imported JavaScript, not the compressed binary that @sparticuz/chromium unpacks at runtime. Fix: keep both packages out of the bundler and trace the bin folder into the route explicitly. Give the function enough time and memory while you are there:
// next.config.ts
serverExternalPackages: ["@sparticuz/chromium", "puppeteer-core"],
outputFileTracingIncludes: {
"/api/screenshot": ["./node_modules/@sparticuz/chromium/bin/**"],
},
// vercel.json
"functions": {
"src/app/api/screenshot/route.ts": { "maxDuration": 60, "memory": 2048 }
}2. Every capture fails with “Target closed”
Symptom: after a deploy, every capture returned an error; Puppeteer reported Target.createTarget: Target closed. Cause: browser.createBrowserContext() crashes Chromium in single-process mode. It is the usual way to isolate one capture from the next, and it works locally, where Chromium runs normally. Fix: on Vercel, open pages in the default context and clear cookies, cache and the target origin's storage after each capture instead.
3. One site crashes the whole browser
Symptom: techcrunch.com timed out, and a capture of another site running at the same moment failed with a detached-frame error. Cause: the Cloudflare Turnstile widget, loaded from challenges.cloudflare.com, killed the single-process browser about four seconds into the load. With the same binary and launch arguments reproduced locally, it crashed 4 times out of 4. Fix: abort requests to that host. After the change, 12 captures out of 12 succeeded on the same pages:
await page.setRequestInterception(true);
page.on("request", (req) => {
const host = new URL(req.url()).hostname;
if (host === "challenges.cloudflare.com") return req.abort();
req.continue();
});The general lesson: with a single process, a crash is never contained to one page, so concurrent captures in the same function instance fail together.
4. Pages that never finish loading
Symptom: timeouts on pages that look fine in a browser. Cause: waitUntil: "networkidle2" never fires on ad-heavy pages, and one site took about ten seconds just to send its HTML. Fix: navigate on domcontentloaded, then wait a bounded time for the network to settle, and capture whatever rendered. Fail only when the body is still empty.
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 20_000 });
// give the page a bounded chance to settle, then capture what rendered
await Promise.race([
page.waitForNetworkIdle({ idleTime: 500 }).catch(() => {}),
new Promise((r) => setTimeout(r, 8_000)),
]);5. Tall pages kill the target
Symptom: a full-page capture of theguardian.com (22,649 px tall) returned a bare 500. Cause: Chromium cannot capture an image taller or wider than 16,384 device pixels; past it, captureScreenshot takes the target down with it. Fix: measure the page first and refuse when height × deviceScaleFactor exceeds 16,384, with a clear error instead of a crash. Remember that deviceScaleFactor: 2 halves the height you can capture.
6. Full page comes back one screen tall
Symptom: the same Guardian page came back 1280×800 with fullPage: true. Cause: its consent platform pins <body> with position: fixed while the dialog is open, so the document measures one viewport. Fix: remove the consent overlay, restore the body's position, then scroll to trigger lazy loading, in that order. The capture became 1280×22,649 with its images loaded, which then ran into failure 5.
7. Memory and time
A heavy page can use hundreds of megabytes inside a function that also holds Node.js and the image buffer. Framejet runs the capture route with 2,048 MB and a 60-second limit, turns off the graphics stack with setGraphicsMode = false, and splits the time budget between navigation, settling and capture so a slow page fails with a timeout error rather than the function being killed.
Or make it one HTTP call
Everything above now runs inside Framejet, along with cookie-banner removal and checks that stop user-supplied URLs from reaching private networks. If you would rather not own these fixes, the capture from your Vercel function becomes:
const res = await fetch(
`https://framejet.dev/v1/take?url=${encodeURIComponent(url)}&full_page=true`,
{ headers: { "X-Api-Key": process.env.FRAMEJET_API_KEY } },
);
const png = Buffer.from(await res.arrayBuffer());Failed captures return a JSON error and cost no credit. The Puppeteer or an API page maps each Puppeteer option to its parameter, and says when your own browser is still the better choice.
Seen in production
Each failure comes from Framejet's own Vercel deployment, with the page that triggered it.
Fix included
Every section ends with the configuration or code change that resolved it.
Or skip it
Framejet runs this setup for you: one GET request returns the image, and failed captures cost nothing.