Puppeteer with Browserless: Offloading Headless Chrome
Puppeteer has two ways to get a browser: puppeteer.launch() which spawns a local Chrome process, and puppeteer.connect() which attaches to an existing one. Browserless is that existing one. The swap is a single line change.
connect() vs launch()
// launch() — spawns Chrome locally
const browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
// connect() — attaches to a remote browser
const browser = await puppeteer.connect({
browserWSEndpoint: 'wss://chrome.browserless.io?token=YOUR_TOKEN'
});After either line, browser behaves identically. newPage(), goto(), click(), evaluate() — all the same API.
The difference: launch() requires Chrome installed locally, manages the process lifecycle, and uses local CPU and RAM. connect() outsources all of that to Browserless.
Basic Connection
const puppeteer = require('puppeteer-core');
// Use puppeteer-core to avoid downloading Chromium locally —
// you're connecting to Browserless's Chromium, not launching your own.
const BROWSERLESS_TOKEN = process.env.BROWSERLESS_TOKEN;
const WS_ENDPOINT = `wss://chrome.browserless.io?token=${BROWSERLESS_TOKEN}`;
async function withBrowser(fn) {
const browser = await puppeteer.connect({ browserWSEndpoint: WS_ENDPOINT });
try {
return await fn(browser);
} finally {
await browser.close();
}
}Using puppeteer-core instead of puppeteer skips the 300MB Chromium download at install time. Since you're connecting to Browserless, you don't need a local binary.
For self-hosted Browserless:
const WS_ENDPOINT = process.env.BROWSERLESS_TOKEN
? `ws://localhost:3000?token=${process.env.BROWSERLESS_TOKEN}`
: 'ws://localhost:3000';Screenshots
async function screenshot(url, options = {}) {
return withBrowser(async (browser) => {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(url, { waitUntil: 'networkidle2' });
const buffer = await page.screenshot({
fullPage: options.fullPage ?? false,
type: options.type ?? 'png',
path: options.path, // omit to get buffer back
});
await page.close();
return buffer;
});
}
// Usage:
const buf = await screenshot('https://example.com', { fullPage: true });
require('fs').writeFileSync('output.png', buf);waitUntil: 'networkidle2' waits until there are no more than 2 network connections for 500ms. Useful for SPAs. For static pages, 'load' or 'domcontentloaded' is faster.
PDF Generation
async function generatePDF(url, outputPath) {
return withBrowser(async (browser) => {
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle0' });
// Emulate print media so print stylesheets apply
await page.emulateMediaType('print');
const pdf = await page.pdf({
path: outputPath,
format: 'A4',
printBackground: true,
margin: {
top: '1cm',
bottom: '1cm',
left: '1cm',
right: '1cm',
},
});
await page.close();
return pdf;
});
}Note: page.pdf() only works in headless mode, which is always the case with Browserless.
Scraping
Extracting structured data from a rendered page:
async function scrapeProductPage(url) {
return withBrowser(async (browser) => {
const page = await browser.newPage();
// Block images and fonts to speed up scraping
await page.setRequestInterception(true);
page.on('request', (req) => {
if (['image', 'font', 'stylesheet'].includes(req.resourceType())) {
req.abort();
} else {
req.continue();
}
});
await page.goto(url, { waitUntil: 'domcontentloaded' });
const data = await page.evaluate(() => {
return {
title: document.querySelector('h1')?.textContent?.trim(),
price: document.querySelector('[data-price]')?.dataset?.price,
description: document.querySelector('.product-description')?.textContent?.trim(),
images: Array.from(document.querySelectorAll('.product-image img'))
.map(img => img.src),
};
});
await page.close();
return data;
});
}Blocking unnecessary resource types cuts page load time significantly for scraping workloads — sometimes by 50–70%.
Error Handling
Connection Errors
async function connectWithRetry(wsEndpoint, maxAttempts = 3) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const browser = await puppeteer.connect({ browserWSEndpoint: wsEndpoint });
return browser;
} catch (err) {
if (attempt === maxAttempts) throw err;
console.warn(`Connection attempt ${attempt} failed: ${err.message}. Retrying...`);
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
}
}
}Handling the 429 (Session Limit Reached)
Browserless returns HTTP 429 when CONCURRENT and QUEUED limits are both hit. This surfaces as a WebSocket upgrade failure.
async function connectWithQueueHandling(wsEndpoint) {
try {
return await puppeteer.connect({ browserWSEndpoint: wsEndpoint });
} catch (err) {
if (err.message.includes('429') || err.message.includes('Too Many Requests')) {
throw new Error('Browserless session limit reached. Increase CONCURRENT/QUEUED or wait.');
}
throw err;
}
}Page Timeouts
Set a default navigation timeout. The Browserless server has its own TIMEOUT setting, but your client should have a lower value to get meaningful error messages:
const page = await browser.newPage();
page.setDefaultNavigationTimeout(30000); // 30s
page.setDefaultTimeout(15000); // 15s for other operationsIf Browserless's server timeout fires first, you get a generic WebSocket close. If your client timeout fires first, you get a proper Puppeteer TimeoutError with a stack trace.
Reconnection on Disconnect
The WebSocket connection can drop — network blips, Browserless restart, idle timeout. Handle disconnects:
class BrowserPool {
constructor(wsEndpoint) {
this.wsEndpoint = wsEndpoint;
this.browser = null;
}
async get() {
if (!this.browser || !this.browser.isConnected()) {
this.browser = await puppeteer.connect({
browserWSEndpoint: this.wsEndpoint,
});
this.browser.on('disconnected', () => {
console.log('Browserless disconnected');
this.browser = null;
});
}
return this.browser;
}
async runTask(fn) {
const browser = await this.get();
const page = await browser.newPage();
try {
return await fn(page);
} finally {
await page.close().catch(() => {}); // ignore close errors after disconnect
}
}
}
const pool = new BrowserPool(WS_ENDPOINT);
// Usage:
const title = await pool.runTask(async (page) => {
await page.goto('https://example.com');
return page.title();
});Checking Browserless Capabilities via the REST API
Before running complex workflows, check what the instance supports:
async function getBrowserlessConfig() {
const response = await fetch(
`https://chrome.browserless.io/config?token=${process.env.BROWSERLESS_TOKEN}`
);
return response.json();
}This returns the active configuration: max concurrent sessions, timeout, Chrome version, and which endpoints are enabled.
Migrating an Existing Puppeteer Project
The migration checklist:
- Replace
puppeteerwithpuppeteer-coreinpackage.json(skip the Chromium download) - Replace
puppeteer.launch({...})withpuppeteer.connect({ browserWSEndpoint: WS_ENDPOINT }) - Remove Chrome installation steps from your
Dockerfileor CI config - Add
BROWSERLESS_TOKENto your secrets / environment - Remove
--no-sandbox,--disable-setuid-sandboxargs (you were passing these because your CI environment couldn't run Chrome with sandboxing — Browserless handles this) - Test that
browser.close()is called in all code paths — withlaunch(), an unclosed browser might still get GC'd; withconnect(), the session stays open on the Browserless server until explicitly closed or timed out
Step 5 is the one people miss. All those workaround flags in your launch() args are gone. The call site gets cleaner.