Automating Browser Profiles in Go With chromedp and the REST Endpoint
chromedp is solid for CDP automation in Go, but out of the box it launches a stock Chromium: one browser, one set of cookies, one identity, shared by every job. That is fine for a single script. It gets awkward when a team needs several separate, persistent browser identities for accounts it is allowed to hold, such as client dashboards, storefronts it operates or QA environments in different regions.
The approach here is short: launch a multi-account browser profile via REST, grab the CDP WebSocket endpoint, and connect chromedp to that endpoint instead of spawning a fresh browser. Each profile keeps its own cookies, storage, proxy and identity parameters, and your Go code just drives it.
Here's the complete workflow for Go developers who want to automate their own workflows without abandoning chromedp.
What We're Building
By the end of this, you'll have a Go program that:
- Launches a JustBrowser profile via the REST API
- Connects chromedp to the profile's CDP endpoint
- Navigates to a page you are permitted to automate, inside the profile
- Extracts data and closes the session cleanly
The profile handles canvas, WebGL, audio, fonts, Client Hints, and 40+ identity parameters at the native C++ level, consistently for that profile. Your Go code just drives the browser.
Prerequisites
- Go 1.21+ (earlier versions work, but 1.21's improved context handling is nice)
- JustBrowser ($9.99/mo, one plan, unlimited profiles and API access) — the 7-day trial is full access, REST API included, so you can build this before you pay
- chromedp package:
go get -u github.com/chromedp/chromedp - A configured profile in JustBrowser with a proxy assigned in the region you need
- Basic familiarity with Go's context package
Step 1: Understand the API Surface
JustBrowser's REST API runs locally on 127.0.0.1 on the port you configure in the app (default 36542), under the base path /api/v1, and every call needs an Authorization: Bearer token from the app's API/AI tab. The two endpoints you'll use most:
POST /api/v1/profiles/{profileId}/start
→ Returns: { "data": { "profile_id": "...", "status": "running", "cdp_url": "ws://127.0.0.1:PORT/devtools/browser/UUID", "launched_at": "..." } }
POST /api/v1/profiles/{profileId}/stop
→ Closes the browser instance
The cdp_url is a standard Chrome DevTools Protocol WebSocket. chromedp speaks CDP natively, so no translation layer required.
(If you've used Playwright's connectOverCDP or Puppeteer's connect({ browserWSEndpoint }), this is the exact same thing — just from Go instead of Node.)
Step 2: Launch a Profile from Go
First, a helper function to start a profile and return the WebSocket URL:
package main
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
type LaunchResponse struct {
Data struct {
ProfileID string `json:"profile_id"`
Status string `json:"status"`
CdpURL string `json:"cdp_url"`
} `json:"data"`
}
func launchProfile(apiBase, apiToken, profileID string) (string, error) {
url := fmt.Sprintf("%s/api/v1/profiles/%s/start", apiBase, profileID)
req, _ := http.NewRequest("POST", url, strings.NewReader(`{"headless": false}`))
req.Header.Set("Authorization", "Bearer "+apiToken)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to launch profile: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("launch failed with status %d", resp.StatusCode)
}
var result LaunchResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("failed to parse response: %w", err)
}
return result.Data.CdpURL, nil
}
The headless: false matters. The API starts profiles headless by default, and a headless session has no real window, so its screen and viewport follow headless defaults. Send false whenever you want a visible window. Plan the deployment around that: JustBrowser runs on Windows x64 and macOS (Apple Silicon), so your Go program and the browser both want a host with a real desktop session, not a stripped-down VM.
Step 3: Connect chromedp to the Remote Browser
Here's where chromedp diverges from its usual pattern. Normally you'd use chromedp.NewExecAllocator to spawn Chrome. Instead, use chromedp.NewRemoteAllocator with the WebSocket URL:
import (
"context"
"fmt"
"log"
"os"
"time"
"github.com/chromedp/chromedp"
)
func main() {
apiBase := "http://127.0.0.1:36542"
apiToken := os.Getenv("JUSTBROWSER_API_TOKEN") // from the app's API/AI tab
profileID := "your-profile-id-here"
// Launch the profile
cdpURL, err := launchProfile(apiBase, apiToken, profileID)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Connected to: %s\n", cdpURL)
// Create remote allocator pointing to the profile's CDP endpoint
allocCtx, allocCancel := chromedp.NewRemoteAllocator(
context.Background(),
cdpURL,
)
defer allocCancel()
// Create browser context
ctx, cancel := chromedp.NewContext(allocCtx)
defer cancel()
// Set a timeout
ctx, cancel = context.WithTimeout(ctx, 60*time.Second)
defer cancel()
// Run your automation
var title string
err = chromedp.Run(ctx,
chromedp.Navigate("https://example.com/"), // replace with a page you are allowed to automate
chromedp.Sleep(2*time.Second), // Let the page settle
chromedp.Title(&title),
)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Page title: %s\n", title)
}
That's it. chromedp now controls a profile with its own identity. The profile's canvas, WebGL renderer, audio and the rest of its 40+ identity parameters are set at the C++ level before any JavaScript executes, and stay consistent from launch to launch.
Step 4: Extract Data Like Normal
chromedp works identically once connected — selectors, waits, screenshots, everything:
func extractPage(ctx context.Context, targetURL string) (string, error) {
var content string
err := chromedp.Run(ctx,
chromedp.Navigate(targetURL),
chromedp.WaitVisible(`body`, chromedp.ByQuery),
chromedp.Sleep(2*time.Second), // Realistic page load time
chromedp.OuterHTML(`body`, &content, chromedp.ByQuery),
)
return content, err
}
Add waits and respect rate limits. Pages that render asynchronously need an explicit wait for the element you want, and sites you don't own should only be hit at a pace their terms and robots rules allow. Read a site's terms before automating anything on it.
Step 5: Clean Up Properly
Always stop profiles when you're done. Orphaned browser processes eat RAM:
func stopProfile(apiBase, apiToken, profileID string) error {
url := fmt.Sprintf("%s/api/v1/profiles/%s/stop", apiBase, profileID)
req, _ := http.NewRequest("POST", url, nil)
req.Header.Set("Authorization", "Bearer "+apiToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
// In main(), after automation completes:
defer stopProfile(apiBase, apiToken, profileID)
Step 6: Run Multiple Profiles Concurrently
Go's concurrency model makes parallel profile management straightforward. Here's a pattern for running N profiles simultaneously:
func runParallel(apiBase, apiToken string, profileIDs []string, work func(context.Context) error) error {
var wg sync.WaitGroup
errChan := make(chan error, len(profileIDs))
for _, pid := range profileIDs {
wg.Add(1)
go func(profileID string) {
defer wg.Done()
cdpURL, err := launchProfile(apiBase, apiToken, profileID)
if err != nil {
errChan <- err
return
}
defer stopProfile(apiBase, apiToken, profileID)
allocCtx, allocCancel := chromedp.NewRemoteAllocator(
context.Background(),
cdpURL,
)
defer allocCancel()
ctx, cancel := chromedp.NewContext(allocCtx)
defer cancel()
if err := work(ctx); err != nil {
errChan <- err
}
}(pid)
}
wg.Wait()
close(errChan)
// Return first error if any
for err := range errChan {
if err != nil {
return err
}
}
return nil
}
Each profile is a separate browser process with its own identity configuration and its own cookies and storage. Run 10 profiles and you have 10 separate, consistent browser environments, such as ten client workspaces or ten regional QA setups.
Manage RAM carefully here: each headed profile runs around 300-500MB, so size the machine for the number of concurrent profiles you plan.
Common Errors and How to Fix Them
Connection refused on WebSocket endpoint
websocket: bad handshake
The profile isn't running yet. The launch endpoint returns before the browser is fully ready. Add a brief sleep after launch, or implement a retry loop:
func waitForEndpoint(cdpURL string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
conn, _, err := websocket.DefaultDialer.Dial(cdpURL, nil)
if err == nil {
conn.Close()
return nil
}
time.Sleep(200 * time.Millisecond)
}
return fmt.Errorf("endpoint not ready after %v", timeout)
}
Context deadline exceeded
Your timeout is too short for the page load + processing. Heavy SPAs can take 10-15 seconds to fully render. Bump the timeout or add explicit waits for specific elements rather than relying on page load events.
Profile already running
You're trying to start a profile that's already active. Stop it first, or reuse the session that is already running. A second CDP client can attach to a running profile, but two jobs driving the same profile will fight over its tabs and state, so give each concurrent job its own profile.
Empty response body
The selector you're targeting doesn't exist or isn't visible. Use chromedp.WaitVisible or chromedp.WaitReady before extraction. SPAs render content asynchronously — a common cause of empty results.
Next Steps
You've got the basic workflow — launch via REST, connect chromedp, automate, clean up. From here:
Proxies per profile: Each profile can have a different proxy assigned. For regional QA or ad checks, create profile pools by geographic region. We covered proxy matching in the ISP proxy setup tutorial.
Checking your configuration: Before pointing a job at a production page, confirm each profile reports the timezone, language and location you configured, using a page you control or a diagnostic page such as BrowserLeaks. Settings that disagree with each other produce confusing test results. Our JA4 and TLS walkthrough covers what a server can see at the network layer.
Cross-language teams: If your team has existing Python/Node automation, the same profiles work everywhere. Our Playwright/Puppeteer integration guide covers the Node side, and the setup checklist covers configuring profiles before any of them run. For transactional email from your own apps, JustEmails includes an API.
The Go + chromedp + multi-account browser stack gives you Go's performance benefits (low memory, fast execution, real concurrency) plus separate, persistent browser profiles for each account or environment you work in.
Frequently Asked Questions
Why use chromedp with a multi-account browser instead of regular Chrome?
Regular Chrome gives every automated session the same machine identity: one set of cookies and storage, one proxy, one set of rendering values. A multi-account browser makes each profile its own browser with its own consistent cookies, storage, proxy and identity parameters, set in the Chromium engine at the C++ level. chromedp connects via CDP the same way it would to regular Chrome, so your Go code doesn't change; the profile underneath does.
How do I connect chromedp to a JustBrowser profile?
Launch the profile via the REST API endpoint (POST /api/v1/profiles/{id}/start with a Bearer token), which returns the CDP WebSocket URL as data.cdp_url. Pass that URL to chromedp.NewRemoteAllocator() instead of using chromedp.NewExecAllocator(). From there, chromedp works exactly as it normally would, and the profile supplies its own identity parameters at the engine level.
Can I run multiple profiles concurrently in Go?
Yes. Each profile launches as a separate browser process with its own CDP endpoint. Use goroutines to manage multiple chromedp contexts, each connected to a different profile's WebSocket URL. The main constraint is RAM: expect roughly 300-500MB per headed profile on a desktop configuration.
Do I need to run headed mode or can I use headless?
Either works, but they are not identical. The start endpoint launches headless unless you send {"headless": false}, and a headless session has no real window, so its screen and viewport follow headless defaults. Use headed when a session must have a real window, for example for visual QA. Run headed on a machine with a real desktop session: JustBrowser ships for Windows x64 and macOS on Apple Silicon, so the deployment target is a persistent Windows box or a Mac rather than a headless Linux VM.
Try JustBrowser
Native Chromium multi-account browser — not extension-based. Real C++ engine patches at the canvas / WebGL / audio / font / screen layer, so a profile's 40+ identity parameters are consistent with each other. Local REST API and CDP for Playwright, Puppeteer, Selenium. $9.99/month or $99.99/year. 7-day free trial, card required — cancel any time in the seven days and you are not charged. Unlimited profiles.
Get started → · How it differs from Multilogin / GoLogin / AdsPower
Ready to manage multiple accounts?
Seven days free, then $9.99/month — one plan, everything included.