Written By Hanzala Saleem
Updated At September 03, 2026 | 9 min read
If you have searched for how to screenshot a website in Go, you have probably landed on a chromedp tutorial, tried it, and then hit the part nobody warns you about: keeping a headless Chrome instance alive in production. This guide walks through every practical option, from raw browser automation to a hosted API, with code that actually compiles.
The fastest way is a single HTTP GET request to a screenshot API. No browser binary, no driver, no context management, just a URL and a few query parameters.
package main
import (
"fmt"
"io"
"net/http"
url2 "net/url"
"os"
)
func main() {
token := "YOUR_API_KEY"
target := url2.QueryEscape("https://example.com")
query := fmt.Sprintf(
"https://shot.screenshotapi.net/v3/screenshot?token=%s&url=%s&output=image&file_type=png",
token, target,
)
resp, err := http.Get(query)
if err != nil {
panic(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
panic(fmt.Errorf("screenshot request failed: %s", body))
}
file, err := os.Create("screenshot.png")
if err != nil {
panic(err)
}
defer file.Close()
if _, err := io.Copy(file, resp.Body); err != nil {
panic(err)
}
fmt.Println("saved screenshot.png")
}
That is the entire integration. If you only need a handful of screenshots or you are prototyping a feature, this is usually the right starting point. If you are building something that runs your own browser fleet, keep reading, because the tradeoffs matter more once you scale past a few requests a day.
There are three practical categories, and they solve different problems.
| Approach | Best for | Setup | Browser required | Infrastructure | Flexibility |
|---|---|---|---|---|---|
| chromedp | Go-native browser control, no CGo dependencies | go get github.com/chromedp/chromedp | Yes, Chrome/Chromium locally | You manage it | High, but manual |
| Rod | Similar to chromedp with a friendlier chained API | go get github.com/go-rod/rod | Yes, auto-downloads Chromium | You manage it | High, but manual |
| playwright-go (community) | Cross-browser testing, not just Chromium | go get github.com/playwright-community/playwright-go | Yes, plus a Node.js driver bundle | You manage it | Highest, heaviest |
| Screenshot API | Production apps that just need the image | One HTTP call | No | Fully managed | Parameter-driven |
Worth noting up front: unlike the Python, Java, and .NET bindings, the Go bindings for Playwright are community maintained rather than an official Microsoft package. That does not make them unusable, but it is a fact that most Go screenshot articles skip, and it matters if you are picking dependencies for a production service.
Yes. Go does not ship a browser, so every browser-based approach in Go works by driving an external Chromium (or Firefox/WebKit) process over the Chrome DevTools Protocol or a similar RPC bridge. chromedp implements the DevTools Protocol directly in Go with no CGo and no external dependencies beyond the browser binary itself, which is why it is the most commonly reached-for library for this task.
Here is a minimal, current example using chromedp's FullScreenshot action, which captures the entire scrollable page rather than just the visible viewport.
package main
import (
"context"
"log"
"os"
"github.com/chromedp/chromedp"
)
func main() {
ctx, cancel := chromedp.NewContext(context.Background())
defer cancel()
var buf []byte
if err := chromedp.Run(ctx,
chromedp.Navigate("https://example.com"),
chromedp.FullScreenshot(&buf, 90),
); err != nil {
log.Fatal(err)
}
if err := os.WriteFile("fullScreenshot.png", buf, 0o644); err != nil {
log.Fatal(err)
}
}
The 90 argument is the compression quality. When it is set to 100, chromedp encodes the image as PNG; any lower value switches the output to JPEG. If you only want the visible viewport instead of the full page, swap FullScreenshot for CaptureScreenshot, which is documented in the chromedp Go reference.
A couple of details that trip people up in production:
Browser automation gives you full control, but that control comes with operational cost that is easy to underestimate until it shows up in an incident. A single headless Chromium instance typically holds 300 to 500 MB of RAM, and that number climbs fast once you run several in parallel to keep request latency reasonable. Chrome ships security patches on a near-weekly cadence, so a self-hosted fleet needs someone watching CVEs and rebuilding images. Under real traffic, Chrome processes leak memory and occasionally hang, which means you also need process supervision and restart logic, not just a happy-path script.
There is also a rendering-correctness problem specific to modern pages. Lazy-loaded images, infinite scroll, and client-side hydration on React, Vue, or Next.js apps do not automatically populate before a screenshot fires. You end up writing custom scroll-and-wait logic per site, which is exactly the kind of code that breaks silently when a target site changes its layout.
None of this means chromedp or Rod are bad libraries. It means the library is the easy 20% of the problem, and the browser infrastructure is the other 80%.

Yes, and it is worth being specific about the tradeoff rather than just calling one option "better." A screenshot API is an HTTP endpoint backed by managed Chromium infrastructure. Your Go code sends a URL and some parameters; the service navigates, renders, waits for the page to settle, and streams back an image or PDF. You give up direct control over the browser process in exchange for not maintaining one.
ScreenshotAPI is built around this model. It runs a real Chromium browser rather than a simplified HTML-to-image renderer, so JavaScript-heavy pages, SPA hydration, and CSS animations render the same way they would in an actual browser tab.
The endpoint is a plain GET request, which means the standard library is enough. No SDK install required.
package main
import (
"fmt"
"io"
"net/http"
url2 "net/url"
"os"
)
func main() {
// @param token - your ScreenshotAPI key
// @param url - the encoded target URL
// @param output - "image" or "json"
// @param file_type - "png", "jpg", or "webp"
token := "Your API Key"
target := url2.QueryEscape("https://example.com")
output := "image"
fileType := "png"
query := "https://shot.screenshotapi.net/v3/screenshot"
query += fmt.Sprintf("?token=%s&url=%s&output=%s&file_type=%s", token, target, output, fileType)
resp, err := http.Get(query)
if err != nil {
panic(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
errorBody, _ := io.ReadAll(resp.Body)
panic(fmt.Errorf("error calling api: %s", errorBody))
}
file, err := os.Create("./screenshot.png")
if err != nil {
panic(err)
}
defer file.Close()
if _, err := io.Copy(file, resp.Body); err != nil {
panic(err)
}
fmt.Println("screenshot saved")
}
You will find the same base pattern in the Get Started docs for Node.js, Python, PHP, Java, and Ruby, since every language integration is the same GET request with different HTTP client syntax. Your API key comes from the dashboard after signup, and the free tier includes 100 screenshots with no card required.
Every option is a query parameter on the same endpoint, so customizing output means adding key-value pairs to the url2.Values map above. A few that come up constantly:
| Parameter | What it does |
|---|---|
| full_page=true | Captures the entire scrollable page instead of one viewport |
| width / height | Sets viewport dimensions in pixels |
| retina=true | Renders at 2x pixel density |
| dark_mode=true | Renders the page using prefers-color-scheme: dark |
| block_ads=true | Strips ads using a 20,000+ rule blocklist |
| no_cookie_banners=true | Removes GDPR/consent popups before capture |
| selector=#id | Screenshots one element instead of the full page |
| output=pdf | Returns a PDF instead of an image |
Full parameter documentation, including file type and quality controls, lives in the rendering docs and the viewport and full page docs.
With chromedp, this means calling FullScreenshot instead of CaptureScreenshot, as shown earlier. The tradeoff is that chromedp will scroll and capture whatever has loaded at the moment it fires, so lazy-loaded sections below the fold can come out blank unless you add manual scroll-and-wait logic first.
With ScreenshotAPI, setting full_page=true handles this on the server side: the renderer scrolls the page top to bottom before capturing, which also triggers IntersectionObserver-based lazy loading along the way. This is the same underlying idea, just moved out of your Go code and into the rendering service.
params := url2.Values{}
params.Set("output", "image")
params.Set("file_type", "png")
params.Set("full_page", "true")
params.Set("lazy_load", "true")
This is where browser-based and API-based approaches converge on the same underlying strategy: wait for the right signal before capturing, not just for a fixed delay.
For chromedp, that usually means combining chromedp.WaitVisible with a CSS selector that only appears once your target content has rendered, rather than sleeping for a guessed number of milliseconds.
For ScreenshotAPI, the same idea maps to three parameters documented under lazy loading and delay:
params.Set("wait_for_event", "networkidle")
params.Set("lazy_load", "true")
Combining wait_for_event=networkidle with lazy_load=true covers most SPA rendering issues without per-site tuning.
| Situation | Recommended approach |
|---|---|
| One-off script, internal tool, low volume | chromedp or Rod, either works fine |
| Production service with unpredictable traffic | Screenshot API, avoids scaling your own Chrome fleet |
| CI/CD visual regression testing | Screenshot API, consistent Chromium version across every run |
| Need cross-browser (Firefox, WebKit) coverage | playwright-go, accept the community-maintenance tradeoff |
| Scheduled monitoring, competitor tracking, archiving | Screenshot API, built-in cron scheduling and cloud storage |
| Deep custom DOM manipulation before capture | chromedp or Rod, you need full page control |
If your Go service already has infrequent, predictable screenshot needs and a team comfortable maintaining Docker images with Chrome inside them, self-hosting is a reasonable choice. If screenshots are a small part of a larger product and you would rather not own browser infrastructure, an API removes that entire category of operational work.

http.Client{Timeout: ...} around an API call. Slow or hung pages should not block your goroutines indefinitely.Go does not have a built-in way to screenshot a webpage, so every option runs through either a locally driven Chromium process or a remote rendering service. chromedp and Rod give you full control at the cost of infrastructure you have to build and patch yourself. A screenshot API trades that control for a single HTTP call, which is usually the better fit once screenshots move from a side script to something running in production. Try the direct HTTP approach first since it takes minutes to wire up, and drop down to chromedp only if you find yourself needing page interaction the API's parameters cannot express.
Yes. Go has no native screenshot capability, but libraries like chromedp and Rod drive a real Chromium browser over the DevTools Protocol to capture pages. Alternatively, a screenshot API handles the browser side entirely and returns the image over a normal HTTP response.
With chromedp, use chromedp.FullScreenshot instead of CaptureScreenshot, which captures the full scrollable page rather than just the viewport. With a screenshot API, set full_page=true, which scrolls the page server-side before capturing so lazy-loaded sections are not left blank.
Only if you are using a browser automation library like chromedp, Rod, or playwright-go, all of which need a local Chromium binary (or the driver bundle that installs one). If you use a hosted screenshot API instead, your Go code never touches a browser directly, so no local Chrome install is required.