Taking a screenshot of a URL in Go sounds straightforward until you try it. The standard approach — running a headless Chromium via chromedp or rod — works locally but falls apart fast in production: binary dependencies, ~300 MB Docker images, memory leaks under concurrent load, and Chromium version drift between environments.
This article covers both paths. You will see what the local browser approach actually looks like at the code level, understand where it breaks, and then see how to replace 60+ lines of infrastructure code with a 5-line HTTP call to the SnapSharp screenshot API.
The chromedp Approach
chromedp is the most popular Go library for driving headless Chrome. Here is a minimal working example:
package main
import (
"context"
"log"
"os"
"time"
"github.com/chromedp/chromedp"
)
func screenshotWithChromedp(targetURL string) ([]byte, error) {
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", true),
chromedp.Flag("no-sandbox", true),
chromedp.Flag("disable-gpu", true),
chromedp.Flag("disable-dev-shm-usage", true),
chromedp.WindowSize(1280, 720),
)
allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), opts...)
defer cancelAlloc()
ctx, cancel := chromedp.NewContext(allocCtx)
defer cancel()
ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
defer cancel()
var buf []byte
err := chromedp.Run(ctx,
chromedp.Navigate(targetURL),
chromedp.WaitVisible("body", chromedp.ByQuery),
chromedp.FullScreenshot(&buf, 90),
)
if err != nil {
return nil, err
}
return buf, nil
}
func main() {
data, err := screenshotWithChromedp("https://example.com")
if err != nil {
log.Fatal(err)
}
os.WriteFile("screenshot.png", data, 0644)
}That is already 50+ lines for a basic case. Before this runs in production you also need to:
- Install
google-chrome-stableorchromium-browseron the host (or base your Docker image onzenika/alpine-chrome) - Pin the Chrome version and keep it in sync with
chromedp - Allocate shared memory (
--shm-size=2gin Docker or--disable-dev-shm-usage) - Handle pool management yourself if you need concurrent captures
- Deal with
context deadline exceedederrors when a page is slow or blocks on JS
rod has a cleaner API and auto-downloads Chrome, but the same fundamental constraints apply: a Chrome binary lives in your container, and you own all of its operational issues.
When chromedp/rod makes sense: local CLI tools, one-off scripts, environments where you already manage a Chrome binary and concurrency is not a concern.
When it does not: containerized microservices, serverless functions (Lambda, Cloud Run), CI/CD pipelines where you want a lean image, or any scenario with burst traffic.
The API Approach: SnapSharp
The SnapSharp API runs Chromium in the cloud and returns the screenshot over HTTP. Your Go service makes one HTTP request and gets back bytes. No binary, no pool management, no memory allocation for browser processes.
1. Simple Screenshot via net/http
No SDK required. A plain GET request with query parameters:
package main
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
)
func takeScreenshot(targetURL, apiKey string) ([]byte, error) {
params := url.Values{}
params.Set("url", targetURL)
params.Set("width", "1280")
params.Set("height", "720")
params.Set("format", "png")
req, err := http.NewRequest(http.MethodGet,
"https://api.snapsharp.dev/v1/screenshot?"+params.Encode(), nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("api error %d: %s", resp.StatusCode, body)
}
return io.ReadAll(resp.Body)
}
func main() {
data, err := takeScreenshot("https://example.com", "sk_live_YOUR_API_KEY")
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if err := os.WriteFile("screenshot.png", data, 0644); err != nil {
fmt.Fprintf(os.Stderr, "write file: %v\n", err)
os.Exit(1)
}
fmt.Printf("saved %d bytes\n", len(data))
}That is the entire implementation. The API key comes from the Authorization header, exactly like every other REST API your Go service already talks to.
2. POST Request with Advanced Options
For full-page captures, dark mode, and custom viewport, use a POST with a JSON body:
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
type ScreenshotRequest struct {
URL string `json:"url"`
Width int `json:"width"`
FullPage bool `json:"full_page"`
DarkMode bool `json:"dark_mode"`
Format string `json:"format"`
BlockAds bool `json:"block_ads"`
CacheTTL int `json:"cache_ttl"`
}
func takeScreenshotPOST(params ScreenshotRequest, apiKey string) ([]byte, error) {
body, err := json.Marshal(params)
if err != nil {
return nil, fmt.Errorf("marshal params: %w", err)
}
req, err := http.NewRequest(http.MethodPost,
"https://api.snapsharp.dev/v1/screenshot",
bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("api error %d: %s", resp.StatusCode, b)
}
return io.ReadAll(resp.Body)
}
func main() {
data, err := takeScreenshotPOST(ScreenshotRequest{
URL: "https://github.com",
Width: 1440,
FullPage: true,
DarkMode: true,
Format: "webp",
BlockAds: true,
CacheTTL: 3600,
}, "sk_live_YOUR_API_KEY")
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if err := os.WriteFile("screenshot.webp", data, 0644); err != nil {
fmt.Fprintf(os.Stderr, "write: %v\n", err)
os.Exit(1)
}
fmt.Printf("full-page screenshot: %d bytes\n", len(data))
}The cache_ttl field tells the API to cache the result for 1 hour. Repeated calls to the same URL within that window return instantly from cache — useful if you are generating report thumbnails on a schedule.
3. Saving to File with Context and Timeout
Production services should always pass a context.Context for timeout and cancellation propagation:
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"time"
)
func saveScreenshot(ctx context.Context, targetURL, outputPath, apiKey string) error {
params := url.Values{}
params.Set("url", targetURL)
params.Set("width", "1280")
params.Set("format", "png")
params.Set("full_page", "true")
params.Set("wait_until", "networkidle")
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://api.snapsharp.dev/v1/screenshot?"+params.Encode(), nil)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("api returned %d: %s", resp.StatusCode, b)
}
f, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("create file: %w", err)
}
defer f.Close()
n, err := io.Copy(f, resp.Body)
if err != nil {
return fmt.Errorf("write file: %w", err)
}
fmt.Printf("saved %d bytes to %s\n", n, outputPath)
return nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := saveScreenshot(ctx,
"https://snapsharp.dev",
"snapsharp-homepage.png",
"sk_live_YOUR_API_KEY",
)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}The wait_until=networkidle parameter tells the renderer to wait until the page has no pending network requests before capturing — the equivalent of waitUntil: 'networkidle' in Playwright.
Using the Go SDK
If you prefer a typed interface, install the Go SDK:
go get github.com/snapsharp/sdk-gopackage main
import (
"context"
"fmt"
"os"
"github.com/snapsharp/sdk-go/snapsharp"
)
func main() {
client := snapsharp.New("sk_live_YOUR_API_KEY")
ctx := context.Background()
img, err := client.Screenshot(ctx, &snapsharp.ScreenshotParams{
URL: "https://example.com",
FullPage: true,
Width: 1440,
Format: "png",
DarkMode: true,
})
if err != nil {
fmt.Fprintf(os.Stderr, "screenshot: %v\n", err)
os.Exit(1)
}
if err := os.WriteFile("output.png", img, 0644); err != nil {
fmt.Fprintf(os.Stderr, "write: %v\n", err)
os.Exit(1)
}
fmt.Printf("done: %d bytes\n", len(img))
}The SDK wraps the same HTTP API with Go-idiomatic types, handles error unmarshalling, and exposes all parameters as struct fields with documentation comments.
Advanced Use Cases
Automated PDF Reports
Generate PDF from any URL for scheduled reporting pipelines:
params := url.Values{}
params.Set("url", "https://your-dashboard.example.com/report")
params.Set("format", "pdf")
params.Set("full_page", "true")
// Requires growth plan or abovePDF rendering respects print media queries, so pages with @media print styles render correctly.
OG Image Generation for Dynamic Pages
Generate Open Graph images server-side and cache the result:
params := url.Values{}
params.Set("url", "https://your-app.example.com/og?title=My+Post&author=Jane")
params.Set("width", "1200")
params.Set("height", "630")
params.Set("format", "png")
params.Set("cache_ttl", "86400") // cache for 24hSee how to generate OG images in Next.js for the rendering side of this pattern.
Visual Monitoring in CI/CD
Compare screenshots across deployments to catch visual regressions:
// Capture before deployment
before, _ := takeScreenshot("https://staging.example.com", apiKey)
os.WriteFile("before.png", before, 0644)
// ... deploy ...
// Capture after deployment
after, _ := takeScreenshot("https://staging.example.com", apiKey)
os.WriteFile("after.png", after, 0644)
// Or use the /v1/diff endpoint to get a pixel-level diff imageThe visual diff and monitoring article covers the /v1/diff endpoint in detail.
Batch Captures with Goroutines
The API is stateless, so concurrent requests are safe. Use a semaphore to stay within your plan's rate limit:
urls := []string{
"https://example.com",
"https://github.com",
"https://go.dev",
}
sem := make(chan struct{}, 5) // max 5 concurrent requests
var wg sync.WaitGroup
for i, u := range urls {
wg.Add(1)
go func(idx int, target string) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
data, err := takeScreenshot(target, "sk_live_YOUR_API_KEY")
if err != nil {
fmt.Fprintf(os.Stderr, "failed %s: %v\n", target, err)
return
}
os.WriteFile(fmt.Sprintf("shot-%d.png", idx), data, 0644)
}(i, u)
}
wg.Wait()Comparison: chromedp vs SnapSharp API
| chromedp / rod | SnapSharp API | |
|---|---|---|
| Lines of setup code | 50+ (pool, context, flags) | 5 (HTTP request) |
| Docker image size | +200–300 MB (Chrome binary) | No change |
| RAM per capture | ~200 MB | 0 (cloud-side) |
| Concurrent captures | Manual pool management | Built-in (your plan's rate limit) |
| Full-page screenshots | Requires scroll tricks | full_page=true |
| Dark mode | emulatedMedia setup | dark_mode=true |
| Ad blocking | Custom request interception | block_ads=true |
| PDF export | Page.printToPDF + flags | format=pdf |
| Chrome maintenance | Your responsibility | Managed |
| Works in Cloud Run / Lambda | No (binary too large) | Yes |
| Free tier | Unlimited (self-hosted cost) | 100 req/month |
Getting Started
- Create a free account — 100 screenshots per month, no credit card required.
- Copy your API key from the dashboard.
- Replace
sk_live_YOUR_API_KEYin any example above and run it.
Full parameter reference is in the screenshot API docs. The endpoint accepts url, width, height, format (png/jpeg/webp/pdf), full_page, dark_mode, device, wait_for, wait_until, block_ads, stealth, proxy, and more.
If your use case needs more than 100 screenshots per month, the Starter plan starts at 5,000 requests and adds full-page, WebP, and retina support.