Advanced hybrid automation patterns
These recipes extend a basic Live URL handoff to multi-tab workflows, mobile viewers, recording, CAPTCHA fallback and multi-stage flows. Each one reuses the same bounded helper, so you can copy the helper once and drop in the recipe you need. For options, defaults, errors and event payloads, use the CDP extensions reference.
- A Browserless API token from your account dashboard
puppeteer-coreorplaywright-coreinstalled locally- A working handoff from the Live URL overview's end-to-end example
Shared handoff helper
Copy this helper into your script once. Use one outstanding handoff per page-level CDP session; await it before minting the next stage. share delivers the link privately. Keep the browser connected and close it in your script's finally block, including when delivery or the wait fails.
async function handoff(cdp, options, share) {
const live = await cdp.send('Browserless.liveURL', {
timeout: 120000,
...options,
});
if (live.error) throw new Error(live.error);
let listener;
let timer;
try {
return await new Promise((resolve, reject) => {
// Arm after minting: replacement can complete the previous mint.
listener = resolve;
// Use on/off: Puppeteer's once wrapper cannot be removed by this callback.
cdp.on('Browserless.liveComplete', listener);
// Bound the application wait even if the transport disappears.
timer = setTimeout(() => reject(new Error('No liveComplete received')),
(live.timeout ?? options.timeout ?? 120000) + 5000);
Promise.resolve().then(() => share(live.liveURL)).catch(reject);
});
} finally {
clearTimeout(timer);
cdp.off('Browserless.liveComplete', listener);
}
}
All recipes below use that helper and an existing connected browser. For Puppeteer, obtain cdp with await page.createCDPSession(); for Playwright, use await page.context().newCDPSession(page). Define share as your application's private delivery callback. Local demonstrations may print the link, but production logs must not contain bearer URLs. Wrap the complete workflow in try/finally and close the browser on every exit.
Bandwidth and quality optimization
Pass quality: 30, type: 'jpeg' to reduce cellular bandwidth. PNG uses more bandwidth; BrowserQL/BAP also require quality: 100 with PNG. See the PNG rule.
Advanced viewport control
Use resizable: false to preserve the viewport set by automation; the viewer scales the stream to fit. showBrowserInterface: true shows navigation and tabs, but does not select the tab-following mode. Choose mode separately.
Multi-tab workflows
Open the tabs before sharing links. Use one page-level CDP session per tab and mode: 'tab' to keep each viewer on its own tab. The following Puppeteer recipe runs inside your browser's try/finally:
const pages = await Promise.all([browser.newPage(), browser.newPage()]);
await Promise.all(pages.map((page) => page.goto('https://example.com')));
const outcomes = await Promise.all(pages.map(async (page, index) => {
const cdp = await page.createCDPSession();
return handoff(cdp, {
mode: 'tab',
instructions: `Review tab ${index + 1}, then choose Done.`,
}, (url) => share(url, index));
}));
if (outcomes.some(({ reason }) => reason !== 'userDone')) {
throw new Error('At least one review did not finish');
}
// Verify the expected application state on each page before proceeding.
For Playwright, create pages through context.newPage() and sessions through context.newCDPSession(page). A rejected handoff should close the browser in the enclosing finally; other waits are bounded by the helper's timer.
An explicit targetId can select another page without changing automation's active tab. mode: 'follow' follows new tabs and popups; mode: 'tab' pins the target. Re-minting the same target replaces its previous handoff and can reuse its ID. Don't mint a new stage until the previous wait settles. See the tab and completion contract for what happens when a pinned tab closes.
Mobile viewers
- Single-finger taps and drags map to remote clicks and scrolling. Multi-touch gestures are not forwarded.
resizable: truelets the site render at the viewer's size;falsepreserves automation's viewport.- Native pinch zoom scales the local viewer, not the remote page.
- Touch-capable viewers get an emulated keyboard for supported fields. See keyboard and dropdown styling.
- Use JPEG with a lower quality for constrained connections.
Clipboard support
For iframe permissions and keyboard focus, use the embedding guide. Clipboard access may still require user permission or a user gesture.
Read-only monitoring sessions
Pass interactable: false to block input on the server. A read-only viewer cannot choose Done; wait for link expiry or close it programmatically when monitoring finishes. Don't treat a disconnect as task success. See programmatic close.
Session recording integration
Screen recording must be enabled at connection time with record=true; starting a recording later does not replace this opt-in. This Puppeteer recipe uses the shared helper:
import puppeteer from 'puppeteer-core';
import { writeFile } from 'node:fs/promises';
const browser = await puppeteer.connect({
browserWSEndpoint: 'wss://production-sfo.browserless.io?token=YOUR_API_TOKEN_HERE&record=true&timeout=180000',
});
try {
const page = await browser.newPage();
await page.goto('https://example.com');
const cdp = await page.createCDPSession();
const started = await cdp.send('Browserless.startRecording');
if (started.error) throw new Error(started.error);
let outcome;
try {
outcome = await handoff(cdp, {
instructions: 'Review the page, then choose Done.',
}, share);
} finally {
const recording = await cdp.send('Browserless.stopRecording');
if (recording.error) throw new Error(recording.error);
await writeFile('review.webm', Buffer.from(recording.value, 'binary'));
}
if (outcome.reason !== 'userDone') throw new Error(`Review ended: ${outcome.reason}`);
} finally {
await browser.close();
}
Recording may capture sensitive information. Obtain appropriate consent and follow your retention policy. Screen recording is different from DOM session replay; see screen recording for connection requirements.
CAPTCHA handling with hybrid fallback
At a known challenge step, attempt the automatic solver, then await the human fallback before continuing. Don't put an unawaited async handler on captchaFound and allow the main script to advance past the challenge.
const { solved, error } = await cdp.send('Browserless.solveCaptcha');
if (error || !solved) {
const outcome = await handoff(cdp, {
instructions: 'Complete the verification challenge, then choose Done.',
}, share);
if (outcome.reason !== 'userDone') {
throw new Error(`Verification handoff ended: ${outcome.reason}`);
}
}
// Replace with your application's verified post-challenge selector.
await page.waitForSelector('#verified-content', { timeout: 5000 });
Configure automatic solving as described in CAPTCHA solving. #verified-content is an application-specific placeholder, not a universal CAPTCHA selector.
Multi-stage workflows
Reuse handoff sequentially; each wait removes its listener, including on failure. This recipe uses Puppeteer after navigating to https://practice.expandtesting.com/inputs:
for (const field of ['number', 'date']) {
const outcome = await handoff(cdp, {
instructions: `Enter a valid ${field}, then choose Done.`,
}, share);
if (outcome.reason !== 'userDone') throw new Error(`Stage ended: ${outcome.reason}`);
const valid = await page.$eval(`input[name="input-${field}"]`,
(input) => input.value !== '' && input.checkValidity());
if (!valid) throw new Error(`No valid ${field} was entered`);
// Perform any automated work between verified stages here.
}
Error recovery and resilience
CDP mint rejection returns { error, liveURL: null }; a transport failure can throw. The helper checks both through normal promise rejection. Don't retry unsupported Live URLs, invalid options, an expired browser or credential-protection refusal unchanged. Fix the request or start an appropriately authorized new session first. Retry transient failures deliberately and with an application limit, never an unbounded loop.
FAQ & troubleshooting
Why does Browserless.startRecording fail?
Add record=true to the connection URL. Without it, the command returns Connect to browser setting a record query-param to "true" in order to use recording. Recording is opt-in at connection time, so you can't enable it later in the same session.
Why can't the viewer choose Done?
The link was minted with interactable: false, which blocks all input, including the completion buttons. Close it programmatically when monitoring finishes, or mint an interactive link with instructions if you need the person's verdict.
Why does each tab's viewer jump to a different tab?
The link is using mode: 'follow', which follows new tabs and popups. Pass mode: 'tab' with a page-level CDP session per tab to pin each viewer.
For network issues and other viewer errors, see the overview's network guidance and FAQ, and the reference's validation errors. For BrowserQL session lifetime, see the BrowserQL and BAP guide.