Run concurrent browser sessions
Launch multiple browser sessions in parallel to speed up large-scale scraping, testing, or automation. Each session runs independently on Browserless infrastructure.
- A Browserless API token from your account dashboard
- A Browserless plan that supports the concurrency level you need
Steps
- AI Agent
- REST API
- Frameworks
Use the Browserless MCP server to run concurrent browser sessions from any MCP-compatible AI agent (Claude Desktop, Cursor, Windsurf, ChatGPT, etc.).
1. Connect the MCP server
Send this prompt to your AI agent to install the Browserless MCP server:
Go to https://github.com/browserless/browserless-mcp/blob/main/install.md
and follow the instructions to install the Browserless MCP server
for my client.
2. Scrape multiple URLs
Use browserless_smartscraper. Each URL can be scraped independently in parallel calls.
Use the browserless_smartscraper tool to scrape these 5 URLs
in parallel and return the content of each as markdown:
- https://scraping-sandbox.netlify.app/products
- https://scraping-sandbox.netlify.app/contact-us
- https://scraping-sandbox.netlify.app/receipt
- https://scraping-sandbox.netlify.app/dashboard
- https://scraping-sandbox.netlify.app/helix
Each REST request opens an independent browser session on Browserless. Fire them concurrently using your language's async primitives. No WebSocket connection needed.
- cURL
- Node.js
- Python
- Java
- C#
- PHP
- Ruby
1. Send requests in parallel
Each REST API call opens an independent browser session. Run them concurrently with &:
curl -s -X POST \
"https://production-sfo.browserless.io/screenshot?token=YOUR_API_TOKEN_HERE" \
-H "Content-Type: application/json" \
-d '{ "url": "https://scraping-sandbox.netlify.app/products", "options": { "type": "png", "fullPage": true } }' \
--output page-1.png &
curl -s -X POST \
"https://production-sfo.browserless.io/screenshot?token=YOUR_API_TOKEN_HERE" \
-H "Content-Type: application/json" \
-d '{ "url": "https://scraping-sandbox.netlify.app/contact-us", "options": { "type": "png", "fullPage": true } }' \
--output page-2.png &
curl -s -X POST \
"https://production-sfo.browserless.io/screenshot?token=YOUR_API_TOKEN_HERE" \
-H "Content-Type: application/json" \
-d '{ "url": "https://scraping-sandbox.netlify.app/receipt", "options": { "type": "png", "fullPage": true } }' \
--output page-3.png &
wait
echo "All screenshots saved"
2. Check the output
All three screenshots are captured simultaneously. Total time is roughly the duration of the slowest single request.
All screenshots saved
Three files appear in the current directory: page-1.png, page-2.png, page-3.png.
1. Fire concurrent requests with Promise.all
import fs from 'fs';
const TOKEN = 'YOUR_API_TOKEN_HERE';
const URLS = [
'https://scraping-sandbox.netlify.app/products',
'https://scraping-sandbox.netlify.app/contact-us',
'https://scraping-sandbox.netlify.app/receipt',
'https://scraping-sandbox.netlify.app/dashboard',
'https://scraping-sandbox.netlify.app/helix',
];
await Promise.all(
URLS.map((url, i) =>
fetch(
`https://production-sfo.browserless.io/screenshot?token=${TOKEN}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, options: { type: 'png', fullPage: true } }),
}
).then(async (res) => {
const buf = Buffer.from(await res.arrayBuffer());
fs.writeFileSync(`screenshot-${i + 1}.png`, buf);
console.log(`Saved screenshot-${i + 1}.png`);
})
)
);
console.log('All done');
2. Check the output
Run with node concurrent.mjs. All 5 screenshots are captured in parallel. Total time is the slowest single page, not 5× sequential.
Saved screenshot-1.png
Saved screenshot-2.png
Saved screenshot-3.png
Saved screenshot-4.png
Saved screenshot-5.png
All done
1. Install dependencies
pip install requests
2. Use ThreadPoolExecutor for parallel requests
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
TOKEN = 'YOUR_API_TOKEN_HERE'
URLS = [
'https://scraping-sandbox.netlify.app/products',
'https://scraping-sandbox.netlify.app/contact-us',
'https://scraping-sandbox.netlify.app/receipt',
'https://scraping-sandbox.netlify.app/dashboard',
'https://scraping-sandbox.netlify.app/helix',
]
def capture(i_url):
i, url = i_url
res = requests.post(
f'https://production-sfo.browserless.io/screenshot?token={TOKEN}',
json={'url': url, 'options': {'type': 'png', 'fullPage': True}},
)
filename = f'screenshot-{i + 1}.png'
with open(filename, 'wb') as f:
f.write(res.content)
print(f'Saved {filename}')
with ThreadPoolExecutor(max_workers=5) as executor:
futures = [executor.submit(capture, (i, url)) for i, url in enumerate(URLS)]
for f in as_completed(futures):
f.result()
print('All done')
3. Check the output
Run with python concurrent.py. All 5 screenshots are captured in parallel.
Saved screenshot-1.png
Saved screenshot-2.png
Saved screenshot-3.png
Saved screenshot-4.png
Saved screenshot-5.png
All done
1. Dependencies
java.net.http.HttpClient ships with the JDK (Java 11+). No extra dependencies needed.
2. Fire concurrent requests with CompletableFuture
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.stream.IntStream;
public class ConcurrentScreenshots {
public static void main(String[] args) {
String token = "YOUR_API_TOKEN_HERE";
List<String> urls = List.of(
"https://scraping-sandbox.netlify.app/products",
"https://scraping-sandbox.netlify.app/contact-us",
"https://scraping-sandbox.netlify.app/receipt",
"https://scraping-sandbox.netlify.app/dashboard",
"https://scraping-sandbox.netlify.app/helix"
);
HttpClient client = HttpClient.newHttpClient();
CompletableFuture<?>[] futures = IntStream.range(0, urls.size())
.mapToObj(i -> {
String body = "{\"url\":\"" + urls.get(i) + "\",\"options\":{\"type\":\"png\",\"fullPage\":true}}";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(
"https://production-sfo.browserless.io/screenshot?token=" + token))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
return client
.sendAsync(request, HttpResponse.BodyHandlers.ofByteArray())
.thenAccept(res -> {
try {
Files.write(Path.of("screenshot-" + (i + 1) + ".png"), res.body());
System.out.println("Saved screenshot-" + (i + 1) + ".png");
} catch (Exception e) {
throw new RuntimeException(e);
}
});
})
.toArray(CompletableFuture[]::new);
CompletableFuture.allOf(futures).join();
System.out.println("All done");
}
}
3. Check the output
Compile with javac ConcurrentScreenshots.java and run with java ConcurrentScreenshots. allOf blocks until the slowest request finishes.
Saved screenshot-1.png
Saved screenshot-2.png
Saved screenshot-3.png
Saved screenshot-4.png
Saved screenshot-5.png
All done
This example builds the JSON body via string concatenation. For production use, add a library like Jackson to serialize the body safely. URLs containing quotes or backslashes will break the raw string approach.
1. Dependencies
System.Net.Http.HttpClient is part of the .NET standard library. No packages needed.
2. Fire concurrent requests with Task.WhenAll
using System;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
class ConcurrentScreenshots
{
static async Task Main()
{
const string token = "YOUR_API_TOKEN_HERE";
string[] urls = {
"https://scraping-sandbox.netlify.app/products",
"https://scraping-sandbox.netlify.app/contact-us",
"https://scraping-sandbox.netlify.app/receipt",
"https://scraping-sandbox.netlify.app/dashboard",
"https://scraping-sandbox.netlify.app/helix",
};
using var client = new HttpClient();
await Task.WhenAll(urls.Select(async (url, i) =>
{
var body = new StringContent(
$"{{\"url\":\"{url}\",\"options\":{{\"type\":\"png\",\"fullPage\":true}}}}",
Encoding.UTF8,
"application/json"
);
var response = await client.PostAsync(
$"https://production-sfo.browserless.io/screenshot?token={token}",
body
);
var bytes = await response.Content.ReadAsByteArrayAsync();
await File.WriteAllBytesAsync($"screenshot-{i + 1}.png", bytes);
Console.WriteLine($"Saved screenshot-{i + 1}.png");
}));
Console.WriteLine("All done");
}
}
3. Check the output
Run with dotnet run. Task.WhenAll fires all requests concurrently and waits for the last one to finish.
Saved screenshot-1.png
Saved screenshot-2.png
Saved screenshot-3.png
Saved screenshot-4.png
Saved screenshot-5.png
All done
This example builds the JSON body via string interpolation. For production use, use System.Text.Json.JsonSerializer.Serialize instead. URLs containing quotes or backslashes will break the raw string approach.
1. Install dependencies
composer require react/http
2. Send requests concurrently with ReactPHP
ReactPHP's event loop runs all requests in parallel without spawning threads:
<?php
require 'vendor/autoload.php';
use React\Http\Browser;
use React\EventLoop\Loop;
$token = 'YOUR_API_TOKEN_HERE';
$urls = [
'https://scraping-sandbox.netlify.app/products',
'https://scraping-sandbox.netlify.app/contact-us',
'https://scraping-sandbox.netlify.app/receipt',
'https://scraping-sandbox.netlify.app/dashboard',
'https://scraping-sandbox.netlify.app/helix',
];
$browser = new Browser();
$promises = [];
foreach ($urls as $i => $url) {
$index = $i + 1;
$promises[] = $browser
->post(
"https://production-sfo.browserless.io/screenshot?token={$token}",
['Content-Type' => 'application/json'],
json_encode(['url' => $url, 'options' => ['type' => 'png', 'fullPage' => true]])
)
->then(function ($response) use ($index) {
file_put_contents("screenshot-{$index}.png", (string) $response->getBody());
echo "Saved screenshot-{$index}.png\n";
});
}
\React\Promise\all($promises)->then(function () {
echo "All done\n";
});
Loop::run();
3. Check the output
Run with php concurrent.php. Loop::run() processes all requests through the event loop until the last promise resolves.
Saved screenshot-1.png
Saved screenshot-2.png
Saved screenshot-3.png
Saved screenshot-4.png
Saved screenshot-5.png
All done
1. Dependencies
net/http and json are part of Ruby's standard library. No gems required.
2. Spawn one thread per URL
require 'net/http'
require 'json'
require 'uri'
TOKEN = 'YOUR_API_TOKEN_HERE'
URLS = [
'https://scraping-sandbox.netlify.app/products',
'https://scraping-sandbox.netlify.app/contact-us',
'https://scraping-sandbox.netlify.app/receipt',
'https://scraping-sandbox.netlify.app/dashboard',
'https://scraping-sandbox.netlify.app/helix',
].freeze
threads = URLS.each_with_index.map do |url, i|
Thread.new do
uri = URI("https://production-sfo.browserless.io/screenshot?token=#{TOKEN}")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
request = Net::HTTP::Post.new(uri)
request['Content-Type'] = 'application/json'
request.body = JSON.generate({ url: url, options: { type: 'png', fullPage: true } })
response = http.request(request)
File.binwrite("screenshot-#{i + 1}.png", response.body)
puts "Saved screenshot-#{i + 1}.png"
end
end
threads.each(&:join)
puts 'All done'
3. Check the output
Run with ruby concurrent.rb. join waits for all threads before printing the final line.
Saved screenshot-1.png
Saved screenshot-2.png
Saved screenshot-3.png
Saved screenshot-4.png
Saved screenshot-5.png
All done
Each connect() call opens a separate managed browser on Browserless. Use Promise.all or goroutines to run them in parallel. Sessions are completely isolated from each other.
- Puppeteer
- Playwright
- Go (chromedp)
1. Install dependencies
npm install puppeteer-core
2. Open multiple browser connections in parallel
Each puppeteer.connect() call opens a separate browser session on Browserless:
import puppeteer from 'puppeteer-core';
const TOKEN = 'YOUR_API_TOKEN_HERE';
const WS = `wss://production-sfo.browserless.io?token=${TOKEN}`;
const URLS = [
'https://scraping-sandbox.netlify.app/products',
'https://scraping-sandbox.netlify.app/contact-us',
'https://scraping-sandbox.netlify.app/receipt',
'https://scraping-sandbox.netlify.app/dashboard',
'https://scraping-sandbox.netlify.app/helix',
];
const results = await Promise.all(
URLS.map(async (url, i) => {
const browser = await puppeteer.connect({ browserWSEndpoint: WS });
try {
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle2' });
const title = await page.title();
console.log(`[${i + 1}] ${title}`);
return { url, title };
} finally {
// Always close to release the session even on error.
await browser.close();
}
})
);
console.log('Results:', results);
3. Check the output
Run with node concurrent.mjs. Each session runs in its own browser process on Browserless. There's no shared state between them.
[1] Product Catalog | Scraping Sandbox
[2] Contact Us - Scraping Sandbox | Scraping Sandbox
[3] Scraping Sandbox
[4] Scraping Sandbox
[5] Careers at Helix — Open Positions
Results: [ { url: '...', title: '...' }, ... ]
1. Install dependencies
npm install playwright-core
2. Open multiple connections in parallel
import { chromium } from 'playwright-core';
const TOKEN = 'YOUR_API_TOKEN_HERE';
const WS = `wss://production-sfo.browserless.io?token=${TOKEN}`;
const URLS = [
'https://scraping-sandbox.netlify.app/products',
'https://scraping-sandbox.netlify.app/contact-us',
'https://scraping-sandbox.netlify.app/receipt',
'https://scraping-sandbox.netlify.app/dashboard',
'https://scraping-sandbox.netlify.app/helix',
];
const results = await Promise.all(
URLS.map(async (url, i) => {
const browser = await chromium.connectOverCDP(WS);
try {
// Use the default context — browser.newPage() creates a new context that
// doesn't inherit proxy, profile, or launch settings.
const context = browser.contexts()[0];
const page = await context.newPage();
await page.goto(url, { waitUntil: 'networkidle' });
const title = await page.title();
console.log(`[${i + 1}] ${title}`);
return { url, title };
} finally {
// Always close to release the session even on error.
await browser.close();
}
})
);
console.log('Results:', results);
3. Check the output
Run with node concurrent.mjs. Each session runs in its own browser process on Browserless.
[1] Product Catalog | Scraping Sandbox
[2] Contact Us - Scraping Sandbox | Scraping Sandbox
[3] Scraping Sandbox
[4] Scraping Sandbox
[5] Careers at Helix — Open Positions
Results: [ { url: '...', title: '...' }, ... ]
1. Install dependencies
go get github.com/chromedp/chromedp
2. Spawn one goroutine per URL
Each goroutine gets its own NewRemoteAllocator context, which opens a separate browser session on Browserless:
package main
import (
"context"
"fmt"
"log"
"sync"
"github.com/chromedp/chromedp"
)
func main() {
token := "YOUR_API_TOKEN_HERE"
ws := fmt.Sprintf("wss://production-sfo.browserless.io?token=%s", token)
urls := []string{
"https://scraping-sandbox.netlify.app/products",
"https://scraping-sandbox.netlify.app/contact-us",
"https://scraping-sandbox.netlify.app/receipt",
"https://scraping-sandbox.netlify.app/dashboard",
"https://scraping-sandbox.netlify.app/helix",
}
var wg sync.WaitGroup
for i, url := range urls {
wg.Add(1)
go func(i int, url string) {
defer wg.Done()
// Each goroutine gets its own allocator — a separate browser session per URL.
allocCtx, cancel := chromedp.NewRemoteAllocator(
context.Background(), ws, chromedp.NoModifyURL,
)
defer cancel()
ctx, cancel := chromedp.NewContext(allocCtx)
defer cancel()
var title string
if err := chromedp.Run(ctx,
chromedp.Navigate(url),
chromedp.Title(&title),
); err != nil {
log.Printf("[%d] error: %v", i+1, err)
return
}
fmt.Printf("[%d] %s\n", i+1, title)
}(i, url)
}
wg.Wait()
fmt.Println("All done")
}
3. Check the output
Run with go run main.go. Each goroutine runs independently. Output lines may arrive out of order since sessions finish at different times.
[3] Scraping Sandbox
[1] Product Catalog | Scraping Sandbox
[5] Careers at Helix — Open Positions
[2] Contact Us - Scraping Sandbox | Scraping Sandbox
[4] Scraping Sandbox
All done
Tips
- Concurrency limits — your plan determines how many sessions can run simultaneously. Requests beyond that limit queue automatically.
- Reuse a saved profile — add
&profile=my-profileto the WebSocket URL to start every concurrent session pre-authenticated. See Save Logins to Authenticated Profiles. - Rate limiting — add a concurrency cap in your own code (e.g.
p-limitin Node.js) to avoid overwhelming the target site.
Next steps
- Save Logins to Authenticated Profiles — share a login state across all concurrent sessions
- Scrape Structured Data — extract data at scale from many pages in parallel
- Take a Screenshot — capture screenshots in bulk