Automating Browser Profiles in Go With chromedp and the REST Endpoint

10 min read

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:

  1. Launches a JustBrowser profile via the REST API
  2. Connects chromedp to the profile's CDP endpoint
  3. Navigates to a page you are permitted to automate, inside the profile
  4. 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

Tags
  • chromedp
  • golang-automation
  • multi-account-api
  • cdp-integration
  • browser-scraping
  • buildinpublic
  • saasstudio
  • aiworkforce
  • buildwithclaude

Ready to manage multiple accounts?

Seven days free, then $9.99/month — one plan, everything included.

We'd like to use Google Analytics, a Google service, to understand how our website is used. It sets two cookies in your browser and runs only if you click Accept. You can change your choice at any time with Cookie settings. Cookie Policy

Sign-in cookies and the cookie that remembers this choice are always on; the website needs them to work.

Google Analytics, a Google service, helps us understand how our website is used. It sets two cookies, _ga and _ga_TVZHQ99TZW. It is now onoff in this browser. If your browser sends a Global Privacy Control or Do Not Track signal, it stays off. Cookie Policy