ZetCode

Go chromedp

last modified October 30, 2026

In this article we show how to automate a browser in Go with chromedp, a library that drives Chrome or Chromium through the DevTools protocol.

chromedp starts the browser in headless mode, loads pages, queries elements with CSS selectors, clicks and types, evaluates JavaScript and takes screenshots. No external driver such as ChromeDriver is needed. The examples use chromedp 0.21.0, Chrome 156 and Go 1.27.0; that version of chromedp has a functional API in which actions return their values and chromedp.Run returns a single result.

The examples load a small local site. Save the two files below in a site directory and serve it with python3 -m http.server 8080.

Reading the page title

chromedp.NewContext creates a browser context, and chromedp.Do runs a sequence of actions. The Title action returns the document title.

site/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Falcon shop</title>
</head>
<body>
<h1>Falcon shop</h1>
<ul>
    <li class="product" data-sku="F-1">
        <a href="/falcon.html">Peregrine falcon</a>
        <span class="price">120</span>
    </li>
    <li class="product" data-sku="F-2">
        <a href="/fox.html">Arctic fox</a>
        <span class="price">90</span>
    </li>
</ul>
</body>
</html>

The home page of the demo site.

main.go
package main

import (
    "context"
    "fmt"
    "log"

    "github.com/chromedp/chromedp"
)

func main() {

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

    if err := chromedp.Do(ctx,
        chromedp.Navigate("http://127.0.0.1:8080/"),
    ); err != nil {
        log.Fatal(err)
    }

    title, err := chromedp.Run(ctx, chromedp.Title())
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println("title:", title)
}

NewContext starts Chrome on first use, and the deferred cancel closes it. The title action is run separately because it returns a string, while Do runs actions that return no value.

$ python3 -m http.server 8080   # in site/
$ go run main.go
title: Falcon shop

Reading the text of an element

The Text action returns the visible text of the first element that matches a selector. chromedp.CSS selects elements with a CSS selector, which is the usual choice for scraping.

main.go
package main

import (
    "context"
    "fmt"
    "log"

    "github.com/chromedp/chromedp"
)

func main() {

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

    if err := chromedp.Do(ctx,
        chromedp.Navigate("http://127.0.0.1:8080/"),
    ); err != nil {
        log.Fatal(err)
    }

    heading, err := chromedp.Run(ctx, chromedp.Text(chromedp.CSS("h1")))
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println("heading:", heading)
}

The heading is returned as a plain string with surrounding whitespace removed. Query actions retry until an element matches, so a selector that never appears keeps waiting instead of returning an empty string; give the context a deadline with context.WithTimeout when that is a possibility.

$ go run main.go
heading: Falcon shop

Reading an attribute

AttributeValue returns an AttributeResult with the value and an Exists flag. The flag distinguishes an empty attribute from a missing one.

main.go
package main

import (
    "context"
    "fmt"
    "log"

    "github.com/chromedp/chromedp"
)

func main() {

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

    if err := chromedp.Do(ctx,
        chromedp.Navigate("http://127.0.0.1:8080/"),
    ); err != nil {
        log.Fatal(err)
    }

    res, err := chromedp.Run(ctx,
        chromedp.AttributeValue(chromedp.CSS("li.product"), "data-sku"))
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println("sku:", res.Value, "found:", res.Exists)
}

The first product has the SKU F-1, and the flag confirms that the attribute is present. Attributes are read from the element matched by the selector, not from its children.

$ go run main.go
sku: F-1 found: true

Clicking a link

Clicks are actions too. Click dispatches a real mouse click, and WaitVisible waits until the next page has rendered the element before the text is read.

main.go
package main

import (
    "context"
    "fmt"
    "log"

    "github.com/chromedp/chromedp"
)

func main() {

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

    if err := chromedp.Do(ctx,
        chromedp.Navigate("http://127.0.0.1:8080/"),
        chromedp.Click(chromedp.CSS(`a[href="/falcon.html"]`)),
        chromedp.WaitVisible(chromedp.CSS("h1")),
    ); err != nil {
        log.Fatal(err)
    }

    text, err := chromedp.Run(ctx, chromedp.Text(chromedp.CSS("h1")))
    if err != nil {
        log.Fatal(err)
    }

    location, err := chromedp.Run(ctx, chromedp.Location())
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println("heading:", text)
    fmt.Println("url:", location)
}

The click follows the link to the detail page, the wait covers the navigation, and the last two actions read the new heading and the new URL. The browser is still the same process, so the context is reused.

$ go run main.go
heading: Peregrine falcon
url: http://127.0.0.1:8080/falcon.html

Filling in a form

SendKeys types into a focused element, and Click submits the form. The page updates a paragraph through JavaScript, which the example then reads.

site/form.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Falcon form</title>
</head>
<body>
<h1>Falcon form</h1>
<form onsubmit="event.preventDefault(); document.querySelector('#out').textContent = document.querySelector('#name').value + ' is ready';">
    <input id="name" type="text" placeholder="name">
    <button id="go" type="submit">Go</button>
</form>
<p id="out"></p>
</body>
</html>

The form page: the submit handler writes the typed value into the paragraph with the id out.

main.go
package main

import (
    "context"
    "fmt"
    "log"

    "github.com/chromedp/chromedp"
)

func main() {

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

    if err := chromedp.Do(ctx,
        chromedp.Navigate("http://127.0.0.1:8080/form.html"),
        chromedp.SendKeys(chromedp.CSS("#name"), "Falcon"),
        chromedp.Click(chromedp.CSS("#go")),
    ); err != nil {
        log.Fatal(err)
    }

    out, err := chromedp.Run(ctx, chromedp.Text(chromedp.CSS("#out")))
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println("result:", out)
}

The script runs in the page, so the result is visible in the DOM. If the form posted to the server instead, WaitVisible or a navigation wait would be needed before reading the response.

$ go run main.go
result: Falcon is ready

Evaluating JavaScript

Evaluate is generic: the type argument tells chromedp how to decode the value produced by the expression. Numbers, strings, booleans, slices and maps all work.

main.go
package main

import (
    "context"
    "fmt"
    "log"

    "github.com/chromedp/chromedp"
)

func main() {

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

    if err := chromedp.Do(ctx,
        chromedp.Navigate("http://127.0.0.1:8080/"),
    ); err != nil {
        log.Fatal(err)
    }

    count, err := chromedp.Run(ctx,
        chromedp.Evaluate[int](`document.querySelectorAll("li.product").length`))
    if err != nil {
        log.Fatal(err)
    }

    title, err := chromedp.Run(ctx,
        chromedp.Evaluate[string](`document.title`))
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println("products:", count)
    fmt.Println("evaluated title:", title)
}

The first expression counts the product elements and the second one reads the document title. The Go type must match the JavaScript value, otherwise the decode fails.

$ go run main.go
products: 2
evaluated title: Falcon shop

Taking a screenshot

EmulateViewport sets the viewport size, and CaptureScreenshot returns the image as bytes. The example decodes the PNG header to check the dimensions before writing the file.

main.go
package main

import (
    "bytes"
    "context"
    "fmt"
    "image"
    _ "image/png"
    "log"
    "os"

    "github.com/chromedp/chromedp"
)

func main() {

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

    if err := chromedp.Do(ctx,
        chromedp.EmulateViewport(800, 600),
        chromedp.Navigate("http://127.0.0.1:8080/"),
    ); err != nil {
        log.Fatal(err)
    }

    buf, err := chromedp.Run(ctx, chromedp.CaptureScreenshot())
    if err != nil {
        log.Fatal(err)
    }

    cfg, format, err := image.DecodeConfig(bytes.NewReader(buf))
    if err != nil {
        log.Fatal(err)
    }

    if err := os.WriteFile("screenshot.png", buf, 0o644); err != nil {
        log.Fatal(err)
    }

    fmt.Println("format:", format)
    fmt.Println("size:", cfg.Width, "x", cfg.Height)
}

The saved screenshot.png is a PNG image of exactly 800 by 600 pixels. FullScreenshot would capture the entire scrollable page instead of the viewport.

$ go run main.go
format: png
size: 800 x 600

Source

chromedp - package reference

chromedp examples

In this article we have shown how to automate Chrome with chromedp: starting a browser context, reading the title, text and attributes, clicking links, filling in forms, evaluating JavaScript, and taking screenshots.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Go tutorials.