Retry browser sessions with exponential backoff
When Browserless is over capacity it returns a 429, and individual browser sessions can fail mid-run due to transient network or browser errors. Exponential backoff handles both by waiting baseDelay * 2^attempt ms between retries so you don't hammer a busy service and you don't lose work to one-off failures. For details on capacity and queue behavior, see session management.
- A Browserless API token from your account dashboard
Steps
- REST API
- Frameworks
- BQL
Browserless queues REST requests automatically when below MAX_QUEUE_LENGTH, so most capacity issues resolve without client-side retry. Network failures and 429s above the queue limit still need handling. Wrap your fetch in a retry loop that checks the response status before parsing.
- cURL
- JavaScript
- Python
- Java
- C#
1. Send the request with retry
The loop doubles DELAY after each failed attempt. A 200 exits immediately; anything else retries up to MAX_RETRIES times:
#!/bin/bash
MAX_RETRIES=3
DELAY=1
for i in $(seq 1 $MAX_RETRIES); do
STATUS=$(curl -s -o response.png -w "%{http_code}" -X POST \
"https://production-sfo.browserless.io/screenshot?token=YOUR_API_TOKEN_HERE" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com"}')
if [ "$STATUS" = "200" ]; then
echo "Success"
exit 0
fi
echo "Attempt $i failed with status $STATUS, retrying in ${DELAY}s..."
sleep $DELAY
DELAY=$((DELAY * 2))
done
echo "Failed after $MAX_RETRIES attempts" && exit 1
2. Check the output
Each failed attempt prints Attempt N failed with status 429, retrying in Ns.... On success, response.png contains the screenshot and the script exits 0. If all retries are exhausted, the script exits 1.
1. Send the request with retry
The helper checks response.status === 429 explicitly. That means Browserless is over capacity and worth retrying. A 400 or 422 means bad input; retrying won't help, so those fall through to return result and you handle them in the caller:
async function withRetry(fn, { retries = 3, baseDelay = 500 } = {}) {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const result = await fn();
if (result.status === 429) {
if (attempt === retries) throw new Error('Browserless over capacity after retries');
const delay = baseDelay * 2 ** attempt;
console.warn(`Attempt ${attempt + 1} failed: 429. Retrying in ${delay}ms...`);
await new Promise(r => setTimeout(r, delay));
continue;
}
return result;
} catch (err) {
if (attempt === retries) throw err;
const delay = baseDelay * 2 ** attempt;
console.warn(`Attempt ${attempt + 1} failed: ${err.message}. Retrying in ${delay}ms...`);
await new Promise(r => setTimeout(r, delay));
}
}
}
const response = await withRetry(() =>
fetch('https://production-sfo.browserless.io/screenshot?token=YOUR_API_TOKEN_HERE', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com' }),
})
);
const buffer = await response.arrayBuffer();
2. Check the output
Run with node retry-backoff.mjs. On a 429, you'll see Attempt 1 failed: 429. Retrying in 500ms... before the next attempt. On success, the response buffer is returned to the caller. If all retries are exhausted, the last error is thrown with the HTTP status.
1. Install dependencies
pip install requests
2. Send the request with retry
The loop retries on both 429 and any other non-OK status. On the final attempt it calls raise_for_status() to surface the error with the HTTP status code:
import time
import requests
def with_retry(fn, retries=3, base_delay=0.5):
for attempt in range(retries + 1):
response = fn()
if response.status_code == 429 or not response.ok:
if attempt == retries:
response.raise_for_status()
time.sleep(base_delay * (2 ** attempt))
continue
return response
response = with_retry(lambda: requests.post(
'https://production-sfo.browserless.io/screenshot?token=YOUR_API_TOKEN_HERE',
headers={'Content-Type': 'application/json'},
json={'url': 'https://example.com'},
))
with open('screenshot.png', 'wb') as f:
f.write(response.content)
3. Check the output
Run with python retry_backoff.py. Each failed attempt sleeps before the next try. On success, screenshot.png is written to disk. If all retries are exhausted, raise_for_status() raises an HTTPError with the final status code.
1. Send the request with retry
The loop doubles the base delay with a left shift (1L << attempt) to avoid floating-point math. A 429 is retried; anything else is returned to the caller for inspection:
import java.net.URI;
import java.net.http.*;
String token = "YOUR_API_TOKEN_HERE";
String endpoint = "https://production-sfo.browserless.io/screenshot?token=" + token;
String body = "{\"url\": \"https://example.com\"}";
HttpClient client = HttpClient.newHttpClient();
int retries = 3;
long baseDelay = 500;
HttpResponse<byte[]> response = null;
for (int attempt = 0; attempt <= retries; attempt++) {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(endpoint))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
response = client.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() == 429) {
if (attempt == retries) throw new RuntimeException("Browserless over capacity after retries");
Thread.sleep(baseDelay * (1L << attempt));
continue;
}
break;
}
System.out.println("Status: " + response.statusCode());
System.out.println("Body length: " + response.body().length + " bytes");
2. Check the output
Run the class. Each 429 sleeps and retries. On success, you'll see Status: 200, Body length: NNNNN bytes. Confirm the byte count is non-zero before treating it as a valid screenshot.
1. Send the request with retry
Task.Delay with Math.Pow(2, attempt) gives the same exponential curve as the other languages. Non-429 failures are returned to the caller. Don't retry a 400:
using System.Net.Http;
using System.Text;
using System.Text.Json;
string token = "YOUR_API_TOKEN_HERE";
string endpoint = $"https://production-sfo.browserless.io/screenshot?token={token}";
var payload = JsonSerializer.Serialize(new { url = "https://example.com" });
int retries = 3;
int baseDelay = 500;
using HttpClient httpClient = new HttpClient();
HttpResponseMessage response = null;
for (int attempt = 0; attempt <= retries; attempt++)
{
var content = new StringContent(payload, Encoding.UTF8, "application/json");
response = await httpClient.PostAsync(endpoint, content);
if ((int)response.StatusCode == 429)
{
if (attempt == retries) throw new Exception("Browserless over capacity after retries");
await Task.Delay(baseDelay * (int)Math.Pow(2, attempt));
continue;
}
break;
}
byte[] bytes = await response.Content.ReadAsByteArrayAsync();
Console.WriteLine($"Status: {(int)response.StatusCode}, Body: {bytes.Length} bytes");
2. Check the output
Run the program. Each 429 waits and retries. On success, you'll see Status: 200, Body: NNNNN bytes. Confirm the byte count is non-zero before treating it as a valid screenshot.
Framework connections go over WebSocket directly to Browserless, so a 429 or connection drop means the connect() call throws. Retry matters more here than in REST because a mid-session crash drops all in-progress work.
The retry wraps the entire session (connect() through browser.close() in the finally block), not just the connect() call. If the browser crashes after connecting, wrapping only connect() would skip re-running the page logic and return a partial or empty result. Wrapping the full session means each retry starts clean.
Not everything is worth retrying. If your callback throws because a selector wasn't found or a page returned a 404, retrying won't help. Inspect the error message to distinguish transient failures (connection refused, browser crashed, WebSocket closed) from logic errors, and re-throw logic errors immediately rather than burning through retries.
- Puppeteer
- Playwright
1. Install dependencies
npm install puppeteer-core
2. Connect and run with retry
import puppeteer from 'puppeteer-core';
const TRANSIENT = ['ECONNREFUSED', 'WebSocket', 'Protocol error', 'Target closed', 'net::'];
async function withRetry(fn, { retries = 3, baseDelay = 1000 } = {}) {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await fn();
} catch (err) {
const isTransient = TRANSIENT.some(s => err.message?.includes(s));
if (attempt === retries || !isTransient) throw err;
const delay = baseDelay * 2 ** attempt;
console.warn(`Attempt ${attempt + 1} failed: ${err.message}. Retrying in ${delay}ms...`);
await new Promise(r => setTimeout(r, delay));
}
}
}
const result = await withRetry(async () => {
const browser = await puppeteer.connect({
browserWSEndpoint: 'wss://production-sfo.browserless.io?token=YOUR_API_TOKEN_HERE',
});
try {
const page = await browser.newPage();
await page.goto('https://example.com');
return await page.title();
} finally {
// Always close to release the session even on error.
await browser.close();
}
});
console.log(result);
3. Check the output
Run with node retry-backoff.mjs. Each failed connection logs Attempt N failed: <error>. Retrying in NNNNms.... On success, the page title prints: Example Domain for example.com. If all retries are exhausted, the last error is thrown with its full stack.
1. Install dependencies
npm install playwright-core
2. Connect and run with retry
The withRetry helper is identical to Puppeteer's. Playwright uses connectOverCDP instead of connect:
import { chromium } from 'playwright-core';
const TRANSIENT = ['ECONNREFUSED', 'WebSocket', 'Protocol error', 'Target closed', 'net::'];
async function withRetry(fn, { retries = 3, baseDelay = 1000 } = {}) {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await fn();
} catch (err) {
const isTransient = TRANSIENT.some(s => err.message?.includes(s));
if (attempt === retries || !isTransient) throw err;
const delay = baseDelay * 2 ** attempt;
console.warn(`Attempt ${attempt + 1} failed: ${err.message}. Retrying in ${delay}ms...`);
await new Promise(r => setTimeout(r, delay));
}
}
}
const result = await withRetry(async () => {
const browser = await chromium.connectOverCDP(
'wss://production-sfo.browserless.io/chromium/playwright?token=YOUR_API_TOKEN_HERE'
);
try {
const context = browser.contexts()[0] ?? await browser.newContext();
const page = await context.newPage();
await page.goto('https://example.com');
return await page.title();
} finally {
// Always close to release the session even on error.
await browser.close();
}
});
console.log(result);
3. Check the output
Run with node retry-backoff.mjs. Each failed connection logs Attempt N failed: <error>. Retrying in NNNNms.... On success, the page title prints: Example Domain for example.com. If all retries are exhausted, the last error is thrown with its full stack.
BQL requests are HTTP, so the REST API retry pattern applies directly. Wrap your BQL POST in the same withRetry helper. Read-only operations like goto + text are safe to retry because they don't change server state. Mutations that write state (form submission, button clicks) need extra care: if Browserless executes the action and the response is dropped in transit, a retry will submit the form or fire the click a second time.
1. Write the mutation
Define a read-only mutation that navigates to a page and extracts a heading. Because neither operation changes state, it's safe to retry on any failure:
mutation RetryExample {
goto(url: "https://example.com", waitUntil: domContentLoaded) {
status
}
heading: text(selector: "h1")
}
2. Run it
Paste into the BQL IDE and click Run.
3. Check the output
A successful run returns the BQL response body. Confirm goto.status is 200 and heading is non-null. If the retry loop fired, you'll see retry log lines before this output appears:
{
"data": {
"goto": { "status": 200 },
"heading": "Example Domain"
}
}