Golang Headless Browser: A Practical Guide to chromedp, go-rod, and Playwright-Go

By Nicholas St. Germain —

Modern websites have very little patience for naive HTTP scrapers. Single-page apps render their content from JavaScript, anti-bot vendors fingerprint TLS handshakes and JS execution environments, and important data often lives behind clicks, scrolls, and conditional waits. To collect that data reliably, you need a real browser - and increasingly, you want to drive it from Go.

Go is a natural fit for browser automation work. Its concurrency model handles fleets of parallel browser sessions cleanly, its static binaries deploy easily to scraping workers and serverless containers, and its memory footprint is far smaller than the equivalent Node.js or Python processes when you're spinning up dozens of Chrome instances at once. This guide walks through the three Go libraries that actually matter - chromedp, go-rod, and playwright-go - and shows you how to use them with proxies for production-grade scraping.

What is a Headless Browser, and Why Use Go?

A headless browser is a real web browser (usually Chromium, Firefox, or WebKit) running without a graphical interface. It loads pages, executes JavaScript, manages cookies, and renders the DOM exactly as a human-facing browser would - but it's controlled programmatically through a protocol like the Chrome DevTools Protocol (CDP) instead of by a mouse and keyboard.

Most tutorials reach for Python (Selenium, Playwright) or Node.js (Puppeteer, Playwright). Go is less common in this space, but it has real advantages once you scale past one or two scrapers:

  • Goroutines beat threads for parallel scraping. Spinning up a hundred browser tabs costs you a hundred goroutines (each ~2 KB of stack) instead of a hundred OS threads.
  • Single binary deploys. No pip install, no node_modules shipped to your worker. Build once, drop it on a server.
  • Strong typing for long-lived scrapers. Selectors, response shapes, and pipeline stages stay honest as your codebase grows.
  • First-class context support. Every operation accepts a context.Context, so cancellation and per-request timeouts are idiomatic instead of bolted on.

The trade-off is ecosystem size. Python and Node have more StackOverflow answers, more example scrapers, and more community-maintained anti-detect plugins. Go's libraries are mature and well-documented, but you'll occasionally need to translate a Puppeteer recipe into the Go equivalent yourself.

The Three Go Libraries Worth Knowing

Library Driver Browsers Best for
chromedp Chrome DevTools Protocol (native) Chromium only Lightweight, fast, pure-Go scraping pipelines
go-rod Chrome DevTools Protocol (native) Chromium only Ergonomic API, built-in stealth helpers, great error messages
playwright-go Playwright Node driver (subprocess) Chromium, Firefox, WebKit Cross-browser parity, mature waiting/locator API

chromedp and go-rod both speak CDP directly from Go - there's no Node.js dependency, and your binary launches Chrome itself. playwright-go is a Go binding to Microsoft's Playwright, which means you ship a Node.js runtime alongside your Go binary, but in exchange you get Firefox and WebKit support and the same waiting semantics that Playwright users in other languages already know.

If you're deciding cold: start with go-rod for general scraping, reach for chromedp if you want the smallest dependency footprint, and use playwright-go only if you genuinely need Firefox or WebKit.

Getting Started with chromedp

chromedp is the longest-running Go automation library and the closest thing to a "standard" choice. It's built on top of CDP and produces zero-dependency Go binaries (Chrome itself is launched as a separate process).

Installation

You'll need Go 1.21+ and a working Chrome or Chromium install on the same machine.

go mod init scraper
go get -u github.com/chromedp/chromedp

Your First Scrape

This loads a product page, waits for the price element to render, and extracts the visible text:

package main

import (
    "context"
    "fmt"
    "log"
    "time"

    "github.com/chromedp/chromedp"
)

func main() {
    ctx, cancel := chromedp.NewContext(context.Background())
    defer cancel()

    ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
    defer cancel()

    var title, price string
    err := chromedp.Run(ctx,
        chromedp.Navigate("https://example.com/product/42"),
        chromedp.WaitVisible(`#product-price`, chromedp.ByID),
        chromedp.Text(`h1.product-title`, &title, chromedp.ByQuery),
        chromedp.Text(`#product-price`, &price, chromedp.ByID),
    )
    if err != nil {
        log.Fatal(err)
    }

    fmt.Printf("Title: %s\nPrice: %s\n", title, price)
}

A few details that trip up newcomers:

  • chromedp.NewContext lazily launches a Chrome instance the first time you call Run. There's no separate "launch" step.
  • Always wrap the context in context.WithTimeout. A misconfigured selector will otherwise hang forever.
  • The selector engine flags (chromedp.ByID, chromedp.ByQuery) tell chromedp how to interpret the selector. Use ByQuery for anything CSS - it's the most flexible.

Headful Mode for Debugging

By default chromedp runs headless. To watch a page in a real window while you debug a flaky selector:

opts := append(chromedp.DefaultExecAllocatorOptions[:],
    chromedp.Flag("headless", false),
    chromedp.Flag("disable-gpu", false),
)
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
defer cancel()

ctx, cancel := chromedp.NewContext(allocCtx)
defer cancel()

This is invaluable when the headless run extracts an empty string and you can't tell whether the selector is wrong or the page hasn't finished rendering.

Going Faster with go-rod

go-rod is a younger library with a deliberately ergonomic API. Where chromedp is a sequence of Action values fed into Run, go-rod reads more like Puppeteer: you call methods directly on a Page object.

Installation

go get -u github.com/go-rod/rod

go-rod will download a matching Chromium build automatically the first time you launch a browser, which removes one source of "works on my machine" surprises.

Equivalent Scrape

package main

import (
    "fmt"
    "log"
    "time"

    "github.com/go-rod/rod"
)

func main() {
    browser := rod.New().MustConnect()
    defer browser.MustClose()

    page := browser.MustPage("https://example.com/product/42").
        MustWaitLoad().
        Timeout(30 * time.Second)

    title, err := page.MustElement("h1.product-title").Text()
    if err != nil {
        log.Fatal(err)
    }

    price, err := page.MustElement("#product-price").Text()
    if err != nil {
        log.Fatal(err)
    }

    fmt.Printf("Title: %s\nPrice: %s\n", title, price)
}

The MustX family panics on error. In a real scraper you'll want the non-Must variants (Element, Text, WaitLoad) so you can retry instead of crash. But MustX is great for prototyping.

Auto-Waiting

go-rod automatically waits for elements to be visible before interacting with them. This eliminates a lot of the WaitVisible boilerplate that chromedp requires:

page.MustElement("button.add-to-cart").MustClick()
page.MustElement(".cart-count").MustText() // already waits

If the cart counter isn't in the DOM yet when you call MustText, go-rod polls until it appears (subject to the page-level timeout you configured).

When You Need Firefox or WebKit: playwright-go

If your target site behaves differently in Firefox, or you specifically want WebKit's smaller fingerprint surface, neither chromedp nor go-rod will help - they're Chromium-only. playwright-go is the answer.

go get -u github.com/playwright-community/playwright-go
go run github.com/playwright-community/playwright-go/cmd/playwright install --with-deps

That second command downloads Chromium, Firefox, and WebKit binaries plus their OS-level dependencies.

package main

import (
    "fmt"
    "log"

    "github.com/playwright-community/playwright-go"
)

func main() {
    pw, err := playwright.Run()
    if err != nil {
        log.Fatal(err)
    }
    defer pw.Stop()

    browser, err := pw.Firefox.Launch(playwright.BrowserTypeLaunchOptions{
        Headless: playwright.Bool(true),
    })
    if err != nil {
        log.Fatal(err)
    }
    defer browser.Close()

    page, err := browser.NewPage()
    if err != nil {
        log.Fatal(err)
    }

    if _, err := page.Goto("https://example.com/product/42"); err != nil {
        log.Fatal(err)
    }

    title, err := page.Locator("h1.product-title").TextContent()
    if err != nil {
        log.Fatal(err)
    }

    fmt.Printf("Title: %s\n", title)
}

The Locator API is the same one Playwright users in TypeScript and Python already know, so cross-team recipes translate cleanly.

Driving Interactive Pages

Static product pages are the easy case. The interesting workloads - dashboards behind logins, infinite-scroll listings, multi-step search filters - need clicks, typing, and waits. Here's the pattern in each library.

Filling a Login Form

chromedp:

err := chromedp.Run(ctx,
    chromedp.Navigate("https://app.example.com/login"),
    chromedp.WaitVisible(`#email`, chromedp.ByID),
    chromedp.SendKeys(`#email`, "user@example.com", chromedp.ByID),
    chromedp.SendKeys(`#password`, "hunter2", chromedp.ByID),
    chromedp.Click(`button[type=submit]`, chromedp.ByQuery),
    chromedp.WaitVisible(`.dashboard`, chromedp.ByQuery),
)

go-rod:

page.MustElement("#email").MustInput("user@example.com")
page.MustElement("#password").MustInput("hunter2")
page.MustElement("button[type=submit]").MustClick()
page.MustElement(".dashboard").MustWaitVisible()

Infinite Scroll

A common requirement: scroll until a list stops growing. With go-rod:

prevCount := -1
for {
    cards, err := page.Elements(".listing-card")
    if err != nil {
        log.Fatal(err)
    }
    if len(cards) == prevCount {
        break // no new cards loaded after the last scroll
    }
    prevCount = len(cards)

    page.Mouse.MustScroll(0, 4000)
    time.Sleep(1500 * time.Millisecond)
}

The fixed 1.5-second wait is a placeholder. In production you'd use a network-idle wait or watch a specific request to fire instead.

Routing Through ISP Proxies

A headless browser by itself doesn't solve the problem of getting blocked. The IP your scraper connects from matters as much as the browser fingerprint - possibly more. Datacenter IPs from cloud providers are the most aggressively flagged. Residential and ISP IPs (the latter are datacenter-hosted but assigned ASNs that belong to consumer ISPs) blend in with normal user traffic.

Stat Proxies' ISP endpoints are issued as standard http://username:password@host:port URLs, which all three Go libraries can consume natively.

chromedp with a Proxy

opts := append(chromedp.DefaultExecAllocatorOptions[:],
    chromedp.ProxyServer("http://proxy1.statproxies.com:3128"),
    chromedp.Flag("ignore-certificate-errors", true),
)
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
defer cancel()

ctx, cancel := chromedp.NewContext(allocCtx)
defer cancel()

Chromium itself doesn't accept credentials in the --proxy-server flag, so you have to handle the basic-auth challenge yourself. The cleanest way is the fetch.Enable CDP domain:

import (
    "encoding/base64"
    "github.com/chromedp/cdproto/fetch"
)

username := "your-user"
password := "your-pass"

err := chromedp.Run(ctx,
    fetch.Enable().WithHandleAuthRequests(true),
    chromedp.ActionFunc(func(ctx context.Context) error {
        chromedp.ListenTarget(ctx, func(ev interface{}) {
            switch e := ev.(type) {
            case *fetch.EventAuthRequired:
                go func() {
                    _ = fetch.ContinueWithAuth(e.RequestID,
                        &fetch.AuthChallengeResponse{
                            Response: fetch.AuthChallengeResponseResponseProvideCredentials,
                            Username: username,
                            Password: password,
                        }).Do(ctx)
                }()
            case *fetch.EventRequestPaused:
                go func() {
                    _ = fetch.ContinueRequest(e.RequestID).Do(ctx)
                }()
            }
        })
        return nil
    }),
    chromedp.Navigate("https://httpbin.org/ip"),
)
_ = base64.StdEncoding // silence unused-import if you trim the snippet

It's verbose, but it only has to be written once.

go-rod with a Proxy

go-rod exposes proxy auth as a first-class browser option, so the equivalent is much shorter:

import (
    "github.com/go-rod/rod"
    "github.com/go-rod/rod/lib/launcher"
)

l := launcher.New().
    Proxy("proxy1.statproxies.com:3128").
    MustLaunch()

browser := rod.New().ControlURL(l).MustConnect()
defer browser.MustClose()

browser.MustHandleAuth("your-user", "your-pass")
page := browser.MustPage("https://httpbin.org/ip")
fmt.Println(page.MustElement("body").MustText())

MustHandleAuth registers a CDP listener exactly once and answers any subsequent proxy auth challenge automatically.

playwright-go with a Proxy

Playwright handles proxy auth at launch time:

browser, err := pw.Chromium.Launch(playwright.BrowserTypeLaunchOptions{
    Headless: playwright.Bool(true),
    Proxy: &playwright.Proxy{
        Server:   playwright.String("http://proxy1.statproxies.com:3128"),
        Username: playwright.String("your-user"),
        Password: playwright.String("your-pass"),
    },
})

This is the most ergonomic of the three.

Rotating Across an ISP Pool

For real scraping volume you don't want one IP - you want a pool, and you want to rotate them per session. Stat Proxies issues each ISP IP as a separate endpoint. A typical Go pattern:

proxies := []string{
    "proxy1.statproxies.com:3128",
    "proxy2.statproxies.com:3128",
    "proxy3.statproxies.com:3128",
    // ...
}

var wg sync.WaitGroup
sem := make(chan struct{}, 5) // cap concurrent browsers

for _, target := range targets {
    wg.Add(1)
    sem <- struct{}{}
    go func(url string) {
        defer wg.Done()
        defer func() { <-sem }()

        proxy := proxies[rand.Intn(len(proxies))]
        scrapeWithProxy(url, proxy)
    }(target)
}
wg.Wait()

Each goroutine launches its own browser instance with a different ISP IP, scrapes the URL, and tears the browser down. The sem channel caps the number of concurrent browsers so you don't exhaust the host's RAM - five Chromium processes already use a couple of GB.

Avoiding Detection

Routing through ISP IPs handles the network-layer fingerprint. The browser fingerprint is a separate problem. Out of the box, chromedp, go-rod, and playwright-go all advertise themselves as automation tools - navigator.webdriver is true, the user agent contains HeadlessChrome, and a handful of JS-detectable properties don't match a real browser.

Practical mitigations, roughly in order of impact:

  1. Use a non-headless user agent - even when running headless, override the UA to a current Chrome stable version.
  2. Set a real viewport and language - 1920x1080 with Accept-Language: en-US,en;q=0.9 is uncontroversial.
  3. Patch navigator.webdriver - inject a small script via page.AddInitScript that defines it as undefined.
  4. Don't reuse browser profiles across IPs - a single fingerprint hopping across dozens of ISP IPs is a stronger signal than the IPs themselves.
  5. Throttle. A real user doesn't scrape ten product pages a second from one session.

The go-rod ecosystem includes stealth helpers that bundle items 1–3. For chromedp and playwright-go you'll typically port a few snippets from the equivalent Puppeteer/Playwright stealth plugins.

When Not to Use a Headless Browser

A headless browser is the right tool when the page genuinely depends on JavaScript, when you need to interact with the DOM, or when you're trying to look like a real browser to evade fingerprinting. It's the wrong tool when:

  • The site exposes a clean JSON API in its network tab - call it directly with net/http.
  • The data is in the initial HTML response - parse it with goquery or colly. You'll scrape 100x faster.
  • You're hitting a single endpoint at high volume - even go-rod adds tens of milliseconds of overhead per page versus a raw HTTP request.

A pragmatic scraping pipeline often combines both: a fast HTTP layer for endpoints that allow it, and a headless-browser layer reserved for the JS-heavy minority of targets.

Conclusion

Go's three main headless browser libraries each occupy a different sweet spot. chromedp is the lightweight, dependency-free CDP client that produces tiny binaries. go-rod gives you a Puppeteer-like API with built-in waits and excellent ergonomics for scraping work. playwright-go is the bridge to Firefox and WebKit, at the cost of a Node.js runtime.

Pick one based on your actual constraints - browser support, deployment surface, team familiarity - rather than benchmarks. The bigger lever for production reliability isn't the library; it's the IPs you route through and the fingerprint you present. Pair any of these libraries with a pool of Stat Proxies ISP IPs and a sensible request cadence, and you'll have a Go scraping setup that holds up against the same anti-bot defenses that defeat naive HTTP scrapers.