chromedp vs rod: Go Screenshot Libraries Measured

chromedp vs rod for screenshots in Go: measured launch and capture times, two full-page behaviours that change your output, and tested code for both.

By Simon Chiu · Updated

Disclosure: I build RenderScreenshot, a hosted screenshot API with a Go SDK. chromedp and rod are free, open-source libraries and I am not involved in either. The measurements below were run by me on one machine and are reproducible with the code on this page.

chromedp and rod both drive Chrome over the DevTools Protocol from Go, and both produce the same pixels for a simple page, because the browser does the rendering. The choice between them is about API style, error handling, process management, and two full-page behaviours that silently change your output. In short: pick rod if you want a chainable API, automatic browser download and helpers for waiting; pick chromedp if you want a small, context-driven API that feels like the rest of your Go code. Then read the full-page section, because both libraries have a trap there.

This page is part of our comparison hub. If you came here to build a Go screenshot service, the Go screenshot API guide covers the hosted route as well.

Last verified: October 15, 2026, with Go 1.27.1, chromedp v0.14.2 and go-rod/rod v0.116.2.

The short answer

Choose chromedp if Choose rod if
You like context.Context for timeouts and cancellation everywhere You prefer chained calls (page.MustNavigate(u).MustWaitStable()) for scripts and tests
You want every step as an action in one chromedp.Run list You want the library to download a matching Chromium on a fresh machine
You run many short captures in one long-lived browser (lower per-capture time in our test) You start a browser per job (faster launch in our test)
You want full-page captures that keep 100vh sections at screen height You want full-page captures that trigger lazy images without extra code
You already use cdproto types elsewhere You want a guard process (leakless) that kills Chrome if your Go process dies

Two full-page behaviours you need to know

This is the most useful part of the comparison, and it is not in either README. The two libraries implement "full page" differently, and each method fails on a different kind of page.

chromedp (chromedp.FullScreenshot) calls Page.captureScreenshot with captureBeyondViewport: true. The viewport stays at 1280x800 while Chrome paints the whole document. rod (page.Screenshot(true, ...)) reads the content size, then calls Emulation.setDeviceMetricsOverride to make the viewport as tall as the page, captures, and restores the old viewport.

Lazy images

We built a test page with eight loading="lazy" images, each in a 700 px section. Same Chromium, same 1280x800 starting viewport, capture right after the load event:

Two full-page captures of the same 5,608 px test page: chromedp shows images 1 to 3 and empty boxes below, rod shows all eight images

Rendered locally on October 15, 2026 with Chromium 141.0.7390.37: left chromedp.FullScreenshot(&buf, 100), right page.Screenshot(true, png). First capture of each run.

chromedp rendered 3 of 8 images. rod rendered all 8, because a viewport the height of the page puts every image "in view", so Chrome starts loading them. This is a side effect of rod's method, not a lazy-loading feature.

Viewport-height layouts

The same side effect breaks pages that size things with vh. Our second test page has a height: 100vh hero followed by three 600 px sections:

Two full-page captures of a page with a 100vh hero: chromedp keeps the hero at 800 px with three sections below, rod stretches the hero over the whole 2,747 px image

Same setup. Both images are 2,747 px tall.

chromedp keeps the hero at one screen (800 px). rod makes the viewport 2,747 px tall, so 100vh becomes 2,747 px and the hero covers the whole capture; the sections are pushed below the bottom edge. Many landing pages use 100vh or min-height: 100vh heroes, so this is not an edge case.

What to do: with chromedp, scroll through the page and wait for images before FullScreenshot. With rod, either do the same and capture with a clip, or freeze vh-based heights with injected CSS before the full-page call. The scroll helper is the same idea in both libraries; this is the chromedp version:

// Fragment: run before chromedp.FullScreenshot. Scrolls one screen at a time,
// then waits until every <img> has decoded (or 10 s pass).
chromedp.Evaluate(`(async () => {
  for (let y = 0; y < document.body.scrollHeight; y += innerHeight) {
    scrollTo(0, y);
    await new Promise(r => setTimeout(r, 150));
  }
  scrollTo(0, 0);
  const deadline = Date.now() + 10000;
  while (![...document.images].every(i => i.complete && i.naturalWidth > 0) && Date.now() < deadline) {
    await new Promise(r => setTimeout(r, 100));
  }
})()`, nil, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) }),

(runtime is github.com/chromedp/cdproto/runtime.) With this step before FullScreenshot, chromedp loaded 8 of 8 images on the test page. The same scroll-then-wait logic also fixed the page in Chromium, Firefox and WebKit through Playwright; the three-engine comparison has the details.

One more chromedp detail: FullScreenshot(&buf, quality) returns PNG only when quality is 100. Any other value switches the format to JPEG.

Measured launch and capture times

Method. One Go program per library, built with Go 1.27.1, both pointed at the same Chromium 141.0.7390.37 binary. Pages served from localhost (a mirror of example.com, a CSS-heavy page, the lazy-image page), so the network is not in the numbers. Launch time is measured once per program run. Then 10 captures per mode, each in a new tab of the same browser, timed from tab creation to PNG bytes, with navigation waiting for the load event. Apple M1 Pro, 32 GB, macOS 27.0.1, October 15, 2026. With 10 samples, p95 is effectively the slowest run.

Page Library Browser launch Viewport p50 Viewport p95 Full page p50 Full page p95 Full-page PNG bytes
example.com mirror chromedp 828 ms 117 ms 1,181 ms 129 ms 230 ms 18,565
example.com mirror rod 287 ms 184 ms 471 ms 200 ms 253 ms 18,565
CSS features page chromedp 621 ms 149 ms 1,130 ms 151 ms 169 ms 129,623
CSS features page rod 249 ms 219 ms 304 ms 212 ms 234 ms 129,623
Lazy images page chromedp 390 ms 108 ms 132 ms 197 ms 224 ms 45,363
Lazy images page rod 259 ms 183 ms 491 ms 277 ms 407 ms 66,120

How to read it:

  • Per capture, chromedp was faster by about 70 ms at the median. Most of that is our rod harness doing more round trips per tab (create, set viewport, navigate, wait), so treat it as "chromedp's action list is lean", not as a law.
  • chromedp's p95 spikes to over a second on the first two pages. That is the first tab of a fresh browser; later tabs were fast. Warm the browser with one throwaway capture if your p95 matters.
  • rod launched faster in every run. chromedp's launch varied from 390 to 828 ms between program runs, so the difference is real but noisy.
  • Bytes match where both libraries produced the same pixels, which confirms the rendering is Chrome's, not the library's. The lazy-page bytes differ because rod's image contains five more photos.

None of these numbers matter much against a real network: a page that loads web fonts and a hero image from a CDN spends more time in goto than either library spends in its own code.

Minimal code for each

Both programs write viewport.png and full.png. Pass a URL as the first argument.

chromedp

package main

import (
    "context"
    "log"
    "os"
    "time"

    "github.com/chromedp/chromedp"
)

func main() {
    url := "https://example.com/"
    if len(os.Args) > 1 {
        url = os.Args[1]
    }

    // Allocator = the Chrome process; context = one tab.
    allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(),
        append(chromedp.DefaultExecAllocatorOptions[:], chromedp.WindowSize(1280, 800))...)
    defer cancelAlloc()
    ctx, cancel := chromedp.NewContext(allocCtx)
    defer cancel()
    ctx, cancelTimeout := context.WithTimeout(ctx, 30*time.Second)
    defer cancelTimeout()

    var viewport, full []byte
    err := chromedp.Run(ctx,
        chromedp.EmulateViewport(1280, 800),
        chromedp.Navigate(url), // waits for the load event
        chromedp.CaptureScreenshot(&viewport),
        chromedp.FullScreenshot(&full, 100), // quality 100 = PNG; anything else = JPEG
    )
    if err != nil {
        log.Fatal(err)
    }
    if err := os.WriteFile("viewport.png", viewport, 0o644); err != nil {
        log.Fatal(err)
    }
    if err := os.WriteFile("full.png", full, 0o644); err != nil {
        log.Fatal(err)
    }
}

rod

package main

import (
    "log"
    "os"
    "time"

    "github.com/go-rod/rod"
    "github.com/go-rod/rod/lib/launcher"
    "github.com/go-rod/rod/lib/proto"
)

func main() {
    url := "https://example.com/"
    if len(os.Args) > 1 {
        url = os.Args[1]
    }

    // Use the installed Chrome if there is one; otherwise rod downloads a Chromium build.
    l := launcher.New().Headless(true)
    if path, ok := launcher.LookPath(); ok {
        l = l.Bin(path)
    }
    controlURL, err := l.Launch()
    if err != nil {
        log.Fatal(err)
    }
    browser := rod.New().ControlURL(controlURL)
    if err := browser.Connect(); err != nil {
        log.Fatal(err)
    }
    defer browser.Close()

    page, err := browser.Timeout(30 * time.Second).Page(proto.TargetCreateTarget{URL: url})
    if err != nil {
        log.Fatal(err)
    }
    if err := page.SetViewport(&proto.EmulationSetDeviceMetricsOverride{Width: 1280, Height: 800, DeviceScaleFactor: 1}); err != nil {
        log.Fatal(err)
    }
    if err := page.WaitLoad(); err != nil {
        log.Fatal(err)
    }
    png := &proto.PageCaptureScreenshot{Format: proto.PageCaptureScreenshotFormatPng}
    viewport, err := page.Screenshot(false, png)
    if err != nil {
        log.Fatal(err)
    }
    full, err := page.Screenshot(true, png) // resizes the viewport to the full content size first
    if err != nil {
        log.Fatal(err)
    }
    if err := os.WriteFile("viewport.png", viewport, 0o644); err != nil {
        log.Fatal(err)
    }
    if err := os.WriteFile("full.png", full, 0o644); err != nil {
        log.Fatal(err)
    }
}

Both tested on October 15, 2026 with Go 1.27.1 (darwin/arm64), chromedp v0.14.2, rod v0.116.2 and Google Chrome 155 found on the default path. go vet clean.

Note the style difference in error handling. rod's documentation leans on Must* methods, which panic; they are pleasant in tests and scripts. In a server, use the non-Must methods as above so a bad URL returns an error instead of crashing the process. chromedp returns errors from Run by design.

Operations: what you own with either library

The library choice is a small part of running screenshots in Go. Either way you own:

  • Chrome itself. A binary per server or container, kept up to date for security fixes, plus fonts for every script your users write in (CJK and emoji are the usual gaps on Linux).
  • Process lifetime. A Chrome that outlives a crashed Go process keeps its memory. rod's launcher starts a small guard process for this; with chromedp, cancel the allocator context on every exit path.
  • Concurrency limits. One browser, many tabs, and a semaphore around tab creation. Without a limit, a traffic spike opens hundreds of tabs.
  • Timeouts at every level. Context timeouts in chromedp, browser.Timeout() in rod, and a hard limit on the whole job.

If owning Chrome is the part you want to remove, the RenderScreenshot Go SDK does the capture over HTTP. This is the full-page capture from above, with scroll simulation for lazy images handled by the full_page preset:

package main

import (
    "context"
    "errors"
    "log"
    "os"
    "time"

    rs "github.com/Render-Screenshot/rs-go"
)

func main() {
    client, err := rs.New(os.Getenv("RS_API_KEY"), rs.WithTimeout(60*time.Second))
    if err != nil {
        log.Fatal(err)
    }

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

    image, err := client.Take(ctx, rs.URL("https://example.com/").
        Preset("full_page"). // full page plus scroll simulation for lazy images
        Width(1280).
        WaitFor(rs.WaitNetworkIdle).
        Format(rs.FormatPNG))
    if err != nil {
        var apiErr *rs.Error
        if errors.As(err, &apiErr) && apiErr.IsRetryable() {
            log.Fatalf("retryable: %s", apiErr.Code)
        }
        log.Fatal(err)
    }
    if err := os.WriteFile("full.png", image, 0o644); err != nil {
        log.Fatal(err)
    }
}

Tested on October 15, 2026 with rs-go v1.1.0 against a local mock of the API (request body and error path), not against production.

You can get a free API key and run it on the same test pages. If you stay with a library, which is the right call when screenshots are a small part of a larger browser-automation job, use the measurements above to pick one and the full-page section to avoid the two traps.

Frequently asked questions

Is rod faster than chromedp?
Not per capture in our test. With one browser and a new tab per capture, chromedp's median was 108 to 151 ms and rod's was 183 to 219 ms for viewport screenshots of local pages. rod started its browser faster (249 to 287 ms against 390 to 828 ms), which matters only if you launch a browser per job.
Why are lazy-loaded images missing in my chromedp full-page screenshot?
chromedp.FullScreenshot captures beyond the viewport without resizing it, so images with loading=lazy below the first screens never start loading. In our test it rendered 3 of 8 lazy images. Scroll the page in steps and wait for the images before you capture, or resize the viewport first.
Why is the hero section huge in my rod full-page screenshot?
rod's page.Screenshot(true, ...) sets the viewport to the full content height before capturing. Any element sized with vh units grows to that height. A 100vh hero filled the whole 2,747 px image in our test.
Does chromedp or rod download Chrome for me?
rod's launcher can download a Chromium build when it finds no browser. chromedp expects Chrome or Chromium to be installed and finds it on common paths, or you pass chromedp.ExecPath.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.