Puppeteer with Browserless: Offloading Headless Chrome

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 operations

If 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:

  1. Replace puppeteer with puppeteer-core in package.json (skip the Chromium download)
  2. Replace puppeteer.launch({...}) with puppeteer.connect({ browserWSEndpoint: WS_ENDPOINT })
  3. Remove Chrome installation steps from your Dockerfile or CI config
  4. Add BROWSERLESS_TOKEN to your secrets / environment
  5. Remove --no-sandbox, --disable-setuid-sandbox args (you were passing these because your CI environment couldn't run Chrome with sandboxing — Browserless handles this)
  6. Test that browser.close() is called in all code paths — with launch(), an unclosed browser might still get GC'd; with connect(), 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.

Read more

Start now free