Geonode logo
Geonode Team

Geonode Team

Updated: September 2, 2026

Published: 2026-09-02

ChromeDP: Getting Started Guide

chromedp drives Chrome from Go using the DevTools Protocol, with no external dependencies — no driver binary, no Java, no separate server process. That design makes it pleasant to deploy and slightly unusual to learn, because everything hangs off Go's `context` package in ways that are not obvious at first. This guide covers the context model, the actions you will actually use, waiting properly, and the deployment and proxy configuration that trip people up.

Our stake: we are Geonode and we sell proxies, and browser automation is the most bandwidth-hungry thing you can do through one. A headless browser fetches every image, font, script and video preload, so running chromedp through metered traffic costs roughly an order of magnitude more than raw HTTP requests for the same pages. There is a section on cutting that, and the technique in it will save you more than choosing a cheaper provider would. The proxy configuration itself is three lines and has one genuine trap, which is also covered.

What chromedp Is

The project describes itself as "a faster, simpler way to drive browsers supporting the Chrome DevTools Protocol in Go without external dependencies".

That last clause is the selling point. Selenium needs a driver binary matched to your browser version; Playwright ships its own runtime. chromedp speaks the DevTools Protocol directly over a websocket, so a Go binary plus a Chrome installation is the whole deployment.

It is MIT licensed and actively maintained — version 0.15.1 was released in April 2026, with commits through July, checked in September 2026.

Installation is unremarkable:

go get -u github.com/chromedp/chromedp

The generated protocol bindings live in a companion package, github.com/chromedp/cdproto, which you reach for when you need something the high-level API does not wrap.

The Context Model

The part to understand first, because everything else follows from it.

chromedp uses context.Context for two jobs at once: cancellation, as Go always does, and carrying the browser and tab handles. That dual purpose is why the setup looks the way it does.

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

var title string
err := chromedp.Run(ctx,
    chromedp.Navigate("https://example.com"),
    chromedp.Text("h1", &title, chromedp.NodeVisible),
)

The first NewContext allocates a browser. Subsequent contexts derived from it create new tabs in the same browser, which is how you run several pages without paying browser start-up costs repeatedly:

browserCtx, cancelBrowser := chromedp.NewContext(context.Background())
defer cancelBrowser()

tabCtx, cancelTab := chromedp.NewContext(browserCtx)
defer cancelTab()

Cancelling closes things. Cancelling a tab context closes the tab; cancelling the browser context closes the browser. The defer cancel() is not optional bookkeeping — omitting it leaks a Chrome process.

Timeouts compose the ordinary Go way:

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

Two errors from the project's own FAQ are worth knowing in advance.

"Executing an action without Run results in 'invalid context'." The FAQ explains that "by default, a chromedp context does not have an executor, however one can be specified manually if necessary". Actions do not execute themselves — they are values that Run performs.

"I'm seeing 'context canceled' errors." The FAQ attributes this to losing the connection: "when the connection to the browser is lost, chromedp cancels the context, and it may result in this error. This occurs, for example, if the browser is closed manually, or if the browser process has been killed or otherwise terminated." So a context canceled error frequently means Chrome died rather than that your timeout fired — worth distinguishing before you raise the timeout.

Actions

Run takes a sequence of actions and performs them in order. The common ones cover most work.

err := chromedp.Run(ctx,
    chromedp.Navigate("https://example.com/search"),
    chromedp.WaitVisible(`input[name="q"]`),
    chromedp.SendKeys(`input[name="q"]`, "golang"),
    chromedp.Click(`button[type="submit"]`, chromedp.NodeVisible),
    chromedp.WaitVisible(`.results`),
    chromedp.Text(`.results`, &results, chromedp.NodeVisible),
)

Extracting from several elements uses Nodes or Evaluate:

var links []string
err := chromedp.Run(ctx,
    chromedp.Navigate(url),
    chromedp.Evaluate(`[...document.querySelectorAll('a')].map(a => a.href)`, &links),
)

Evaluate runs JavaScript in the page and unmarshals the result into a Go value, which is frequently the shortest route for anything involving several elements at once. The value must be JSON-serialisable.

For actions that return multiple values, the FAQ gives the wrapper:

chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error {
    _, err := domain.SomeAction().Do(ctx)
    return err
}))

ActionFunc is also how you drop into raw cdproto calls for anything the high-level API does not cover — setting cookies, intercepting network requests, emulating a device. That escape hatch is available for the entire DevTools Protocol, which is a large surface.

Waiting Properly

The difference between a reliable scraper and a flaky one, and the mistake is always the same.

Do not sleep. chromedp.Sleep(3*time.Second) exists, it is tempting, and it is either too short — producing intermittent failures on a slow day — or too long, wasting time on every run. Usually both, on different machines.

Wait for the thing you care about:

chromedp.WaitVisible(`.results`, chromedp.ByQuery)
chromedp.WaitNotVisible(`.spinner`)
chromedp.WaitReady(`#content`)

WaitVisible waits for the element to exist and be visible; WaitReady waits for it to exist in the DOM. For content loaded after an interaction, visible is usually the right condition.

For a condition no selector expresses, poll in the page:

chromedp.Poll(`document.querySelectorAll('.item').length >= 20`, nil)

This is the answer for "wait until the list has finished loading", which no element-based wait can express.

Always bound the wait with a context timeout. A WaitVisible on a selector that will never match blocks until the context expires, and without a timeout that is forever.

Running Headless, and in Docker

Chrome runs headless by default. The FAQ answers the first question people have: "By default, Chrome is run in headless mode. See DefaultExecAllocatorOptions, and an example to override the default options."

To watch it work while developing:

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

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

NewExecAllocator is where you set Chrome's command-line flags, and it is the layer above the browser context.

For containers, the project's recommendation is specific: "The simplest way is to run the Go program that uses chromedp inside the chromedp/headless-shell image. That image contains headless-shell, a smaller headless build of Chrome, which chromedp is able to find out of the box."

That is worth taking. Assembling a working Chrome in a container by hand means chasing missing shared libraries and font packages, and the result is larger than the purpose-built image.

One Linux-specific behaviour from the FAQ, which surprises people running Chrome separately: "On Linux, chromedp is configured to avoid leaking resources by force-killing any started Chrome child processes. If you need to launch a long-running Chrome instance, manually start Chrome and connect using RemoteAllocator."

RemoteAllocator connects to an already-running browser over its websocket endpoint, which is the pattern for a shared browser pool or a browser running in a separate container.

Using a Proxy

Three lines, and one trap.

opts := append(chromedp.DefaultExecAllocatorOptions[:],
    chromedp.ProxyServer("http://proxy.example.com:9000"),
)
allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), opts...)
defer cancelAlloc()

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

ProxyServer sets Chrome's --proxy-server flag.

The trap is authentication. Chrome's --proxy-server flag takes no credentials — a URL with a username and password in it will not authenticate. Chrome instead responds to a proxy challenge by showing a dialogue, and a headless browser has nobody to fill it in.

Two ways round it, in order of preference.

Use an IP allowlist. If your source address is stable, register it with your provider and drop the credentials entirely. This is the cleanest answer and it removes the problem rather than working around it.

Handle the authentication event. chromedp can respond to the DevTools Protocol's authentication request via fetch.Enable with handleAuthRequests, supplying credentials programmatically. This is more code, and it is the route when your address is not fixed.

Then verify it worked, because a misconfigured proxy in a browser is silent:

var ip string
err := chromedp.Run(ctx,
    chromedp.Navigate("https://api.ipify.org"),
    chromedp.Text("body", &ip, chromedp.NodeVisible),
)

Run it with and without the proxy option. If the address does not change, Chrome is not using it — and nothing will have told you. For geo-targeted work, go further and confirm that regionally distinct content actually differs, since the address is the easy part. This is the silent-failure pattern we described in why testing proxies matters.

Match your locale to your exit country while you are at it. A German exit with an en-US language header and a London time zone is a combination no real visitor produces, and many sites use locale independently of address:

chromedp.Flag("lang", "de-DE"),

Cutting Bandwidth

The section that saves the most money, and it applies to any browser automation.

A page that is 200 KB of HTML may be 4 MB once every image, font, tracking script and video preload has been fetched. At residential rates of $0.79/GB — our figures, checked against the pricing page in September 2026 — that difference is the whole budget.

Block resource types you do not need. Using fetch.Enable and a request-paused listener, abort requests for images, media and fonts:

chromedp.ListenTarget(ctx, func(ev interface{}) {
    if e, ok := ev.(*fetch.EventRequestPaused); ok {
        go func() {
            c := chromedp.FromContext(ctx)
            execCtx := cdp.WithExecutor(ctx, c.Target)
            switch e.ResourceType {
            case network.ResourceTypeImage, network.ResourceTypeMedia, network.ResourceTypeFont:
                _ = fetch.FailRequest(e.RequestID, network.ErrorReasonBlockedByClient).Do(execCtx)
            default:
                _ = fetch.ContinueRequest(e.RequestID).Do(execCtx)
            }
        }()
    }
})

This routinely cuts traffic by most of the total, and it speeds up your runs as a side effect.

Reuse the browser, create tabs. Browser start-up is expensive; a tab is cheap. For a run over many pages, allocate once and derive tab contexts.

And ask whether you need a browser at all. If the content is present in the initial HTML, a plain HTTP request costs a fraction as much and runs far faster. Check the page source before reaching for automation — the reflex to render everything is the single most common source of unnecessary cost in this area.

Debugging When Nothing Works

Browser automation fails opaquely — a selector that never matches and a page that never loaded produce the same timeout. A fixed sequence resolves most of it.

Turn the browser on and watch. The single fastest diagnostic, and the one people leave until last:

opts := append(chromedp.DefaultExecAllocatorOptions[:],
    chromedp.Flag("headless", false),
)

Half the time the answer is visible immediately — a cookie banner covering the button, a redirect to a login page, a challenge screen, or a layout that differs from what you tested against.

Capture the page when a run fails. In a headless environment this replaces watching:

var buf []byte
_ = chromedp.Run(ctx, chromedp.FullScreenshot(&buf, 90))
_ = os.WriteFile("failure.png", buf, 0644)

Pair it with the HTML, since a screenshot shows what rendered and the source shows what arrived:

var html string
_ = chromedp.Run(ctx, chromedp.OuterHTML("html", &html, chromedp.ByQuery))

Check the selector resolves at all before assuming a timing problem:

var count int
_ = chromedp.Run(ctx, chromedp.Evaluate(`document.querySelectorAll('.item').length`, &count))

Zero means a selector problem, and no amount of waiting fixes it.

Enable browser logging when you suspect the page itself is erroring:

opts := append(chromedp.DefaultExecAllocatorOptions[:],
    chromedp.Flag("enable-logging", true),
    chromedp.Flag("v", "1"),
)

Listen for console messages and failed requests, which frequently explain a page that renders empty:

chromedp.ListenTarget(ctx, func(ev interface{}) {
    switch e := ev.(type) {
    case *runtime.EventConsoleAPICalled:
        log.Printf("console.%s", e.Type)
    case *network.EventLoadingFailed:
        log.Printf("failed: %s %s", e.Type, e.ErrorText)
    }
})

A page whose API calls are all failing looks identical to a page whose selectors changed, and only the network events distinguish them.

And use chromedp-proxy as a last resort. It sits between your program and the browser and logs the DevTools Protocol traffic in both directions. When behaviour makes no sense at all, seeing the actual protocol exchange usually explains it in one reading.

When chromedp Is the Right Choice

Use it when you are already writing Go and want a single static binary with no driver to distribute, or when you need direct DevTools Protocol access for something the higher-level tools do not expose.

Consider Playwright when you want cross-browser support, auto-waiting built into every action, tracing and screenshots on failure, or a larger body of documentation and examples. Its Go port exists but the ecosystem is centred on JavaScript and Python.

Consider plain HTTP when the content is in the initial response. Faster, cheaper, simpler, and more of the web serves useful HTML than the discourse suggests.

The FAQ's own resource list is a good map of where to go next: the examples repository for complex actions and full-page screenshots, the cdproto reference for the generated protocol API, and chromedp-proxy — a CDP logging proxy — for seeing exactly what your program and the browser are saying to each other, which is the debugging tool of last resort and a genuinely good one.

People Also Ask

What is chromedp?

A Go package that drives browsers speaking the Chrome DevTools Protocol, without external dependencies. Unlike Selenium it needs no driver binary, and unlike Playwright it ships no runtime — a Go binary plus a Chrome installation is the whole deployment.

Why do I get "invalid context" in chromedp?

Because you executed an action without Run. The FAQ explains that a chromedp context has no executor by default. Actions are values that chromedp.Run performs; calling one directly has nothing to run it.

What does "context canceled" mean in chromedp?

Usually that the browser connection was lost. The FAQ attributes it to the browser being closed manually or the process being killed. It is worth distinguishing from a timeout, since the fix is different — a crashed Chrome is not solved by waiting longer.

How do I run chromedp with a visible browser?

Chrome runs headless by default. Append chromedp.Flag("headless", false) to DefaultExecAllocatorOptions and pass them to NewExecAllocator, then derive your context from that allocator.

How do I use a proxy with chromedp?

Add chromedp.ProxyServer("http://host:port") to the allocator options. Credentials in the URL will not work, because Chrome's --proxy-server flag does not accept them — use an IP allowlist if your address is stable, or handle the authentication request through the DevTools Protocol.

How do I run chromedp in Docker?

Run your Go program inside the chromedp/headless-shell image, which the project recommends explicitly. It contains a smaller headless Chrome build that chromedp finds without configuration, and it avoids assembling a working browser environment by hand.

How do I wait for an element in chromedp?

Use WaitVisible, WaitReady or WaitNotVisible with a selector, or Poll with a JavaScript expression for conditions no selector expresses. Avoid Sleep — it is either too short and flaky or too long and wasteful, usually both depending on the machine.

How do I reduce bandwidth when using chromedp?

Block images, fonts and media by enabling request interception and failing those resource types, which typically removes most of the traffic. Reuse one browser and create tabs rather than allocating repeatedly. And check whether the content is in the initial HTML, in which case skip the browser entirely.

Wrapping Up

chromedp's learning curve is almost entirely the context model. Once you internalise that a context carries the browser or tab, that cancelling one closes it, and that actions do nothing until Run performs them, the rest of the API is straightforward.

The habits that matter are the same as in any browser automation. Wait for conditions rather than sleeping, bound every wait with a context timeout, and reuse one browser across many tabs rather than paying start-up costs repeatedly.

Two Go-specific points are worth remembering. defer cancel() on every context, or you leak Chrome processes — and on Linux, chromedp force-kills the Chrome children it started, so a long-running browser needs to be launched separately and reached with RemoteAllocator.

And if you are running through a metered proxy, block the resource types you do not need before doing anything else. A rendered page costs an order of magnitude more than the HTML it contains, and most of that is images you were never going to look at.

ChromeDP in Go: Contexts Actions Proxies and the Gotchas | Geonode