How to Capture Web Pages as Images in Go With wkhtmltoimage
Use Go’s os/exec to run wkhtmltoimage, control rendering options, handle failures, and secure a production capture service.
Direct answer: Go does not render HTML itself with wkhtmltoimage. Install the wkhtmltoimage executable, then invoke it with Go’s standard os/exec package. Pass the source URL or HTML file and an output path as separate arguments, set the viewport and format explicitly, enforce a timeout, capture stderr, and verify that the output file is non-empty.
wkhtmltoimage is a headless command-line renderer built on QtWebKit. It can run without a display server. Because it uses a legacy WebKit engine, test your target pages: current CSS, browser APIs, authentication flows, and lazy-loaded content can render differently from a modern browser.
1. Install and verify wkhtmltoimage
Install a build appropriate for your operating system, then make its path explicit in deployment. Verify the binary before your Go service starts:
wkhtmltoimage --version
wkhtmltoimage --extended-help
Distribution builds expose different flags and may have different patches. Treat --extended-help and --version from the binary you actually deploy as the authority for supported options.
2. A complete Go wrapper
The following program accepts a URL and output filename, runs wkhtmltoimage, limits execution time, captures diagnostics, and checks the result.
package main
import (
"bytes"
"context"
"errors"
"flag"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
func capture(ctx context.Context, binary, source, output string, width, height, quality int, javascript bool, delay time.Duration) error {
args := []string{
"--format", "png",
"--width", fmt.Sprint(width),
"--height", fmt.Sprint(height),
"--quality", fmt.Sprint(quality),
}
if !javascript {
args = append(args, "--disable-javascript")
} else if delay > 0 {
args = append(args, "--javascript-delay", fmt.Sprint(delay.Milliseconds()))
}
args = append(args, source, output)
cmd := exec.CommandContext(ctx, binary, args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return fmt.Errorf("wkhtmltoimage timed out: %w", ctx.Err())
}
detail := strings.TrimSpace(stderr.String())
if detail == "" {
return fmt.Errorf("wkhtmltoimage failed: %w", err)
}
return fmt.Errorf("wkhtmltoimage failed: %w: %s", err, detail)
}
info, err := os.Stat(output)
if err != nil {
return fmt.Errorf("capture reported success but output is unavailable: %w", err)
}
if info.Size() == 0 {
return fmt.Errorf("capture produced an empty file")
}
return nil
}
func main() {
url := flag.String("url", "https://example.com", "URL or local HTML file")
output := flag.String("out", "shot.png", "output image path")
binary := flag.String("binary", "wkhtmltoimage", "wkhtmltoimage executable")
width := flag.Int("width", 1280, "viewport width")
height := flag.Int("height", 0, "viewport height; 0 lets the renderer choose")
quality := flag.Int("quality", 90, "image quality from 0 to 100")
js := flag.Bool("js", true, "enable JavaScript")
delay := flag.Duration("delay", 2*time.Second, "wait after load when JavaScript is enabled")
flag.Parse()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if err := capture(ctx, *binary, *url, *output, *width, *height, *quality, *js, *delay); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(*output)
}
Run it after installing the executable:
go run . -url https://example.com -out example.png -width 1440 -delay 3s
exec.CommandContext starts the process directly; it does not invoke a shell. Passing each argument separately prevents shell metacharacters in a URL from becoming shell syntax. This protects process invocation, but you still must validate URLs and restrict network and filesystem access.
3. Calling the executable directly
The equivalent command is useful while diagnosing a page:
wkhtmltoimage \
--format png \
--width 1280 \
--quality 90 \
--javascript-delay 2000 \
https://example.com shot.png
Use a local HTML file as the input when you control the document:
wkhtmltoimage --format png page.html page.png
4. Options that materially change the result
| Option | What it controls | Guidance |
|---|---|---|
--format |
Output image format | Choose a format supported by your installed build, commonly PNG or JPEG. |
--width |
Viewport width | Set it explicitly for repeatable responsive layouts. Smart-width behavior can affect the final size. |
--height |
Viewport height | Set a fixed viewport for a viewport shot; omit or use the documented default for content-driven height. |
--quality |
JPEG-style quality, 0–100 | Higher values increase output size. Confirm behavior for your chosen format. |
--disable-javascript |
Stops script execution | Use for static HTML or to reduce unpredictable script behavior. |
--javascript-delay |
Wait after page load | Increase it for scripts that insert content after load; it is a fixed wait, not proof that a specific element is ready. |
--window-status |
Waits for a requested JavaScript window status | Useful when your page can set a deterministic readiness signal. |
--load-error-handling |
Document load failures | Choose whether failures abort, warn, or continue according to your version’s help. |
--load-media-error-handling |
Image, font, and other media failures | Use diagnostics to distinguish a missing asset from a failed document. |
--disable-local-file-access |
Blocks local file reads | Keep this restriction for untrusted input; enable only when a controlled document needs local assets. |
--allow |
Permits selected local paths | Allow the smallest asset directories possible. |
--custom-header and cookie flags |
HTTP headers and authenticated cookies | Pass secrets only from protected configuration and avoid logging the complete command line. |
Read the installed binary’s wkhtmltoimage reference and extended help before relying on a flag in production.
5. JavaScript, lazy content, and page readiness
Many pages are not complete when the initial response arrives. Start with JavaScript enabled and a small delay, then increase the delay only when the page needs it. A deterministic window.status value is preferable to guessing a long sleep when you own the page. If a page loads content only after scrolling, hover, or an interaction, wkhtmltoimage may not reproduce that behavior.
When a capture is incomplete, save the stderr output and compare captures at the same width, URL, and installed version. A modern site can be valid HTML yet still depend on APIs unavailable in QtWebKit.
6. Security boundaries for a capture service
- Allow only
https(and, when needed,http) URLs. Reject unexpected schemes such asfile:. - Use an allowlist of hosts when users can submit URLs. Otherwise the renderer can make requests to internal services from your network.
- Keep
--disable-local-file-accessenabled for untrusted pages. If local assets are required, use a dedicated read-only directory with--allow. - Run each capture with a deadline and operating-system CPU, memory, process, and file-size limits.
- Run the converter in a low-privilege, isolated worker. Do not give it access to application secrets or writable source directories.
- Never build a shell command string from user input. Use
exec.CommandContextwith an argument slice. - Treat cookies and custom headers as secrets. Redact them from logs and error messages.
These controls matter because the command accepts URLs, headers, cookies, and local-file permissions. Argument separation prevents shell injection; it does not make arbitrary network requests safe.
7. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
exec: "wkhtmltoimage": executable file not found |
Not installed or absent from PATH |
Install it, pass an absolute -binary path, and verify the service user can execute it. |
| Blank image | JavaScript content was not ready, the URL was unreachable, or the renderer cannot parse the page | Inspect stderr, verify the URL from the deployment host, enable JavaScript, add a delay or window-status signal, and test a simple page. |
| Page is cut off | Viewport or content height is too small | Adjust --height, width, or the page’s print/layout CSS. Confirm whether your build supports content-driven height. |
| Modern CSS or fonts are missing | QtWebKit lacks current browser features or the asset request failed | Check media errors and asset URLs. Use a renderer with current-browser compatibility when fidelity is required. |
| Authenticated page redirects to login | Cookies or headers were not supplied, or the session expired | Pass the required cookie/header flags securely and verify the target host and redirect chain. |
| Timeouts on some URLs | Slow scripts, blocked resources, infinite navigation, or a large page | Keep a hard deadline, reduce allowed destinations, increase delay only when justified, and record duration and stderr. |
| Works locally but fails in production | Different binary build, CA certificates, DNS, permissions, fonts, or network policy | Log the version, run a startup smoke capture, and test from the same container or host image used in production. |
8. Performance, reliability, and cost considerations
Each capture starts a native process, loads a page, executes scripts, and writes an image. Reusing a long-lived worker can reduce process-start overhead, but isolation and cleanup are simpler when a worker handles a bounded number of jobs. Measure page load time, render time, output size, exit status, and timeout rate in your own environment; no universal benchmark applies to every page.
Use explicit dimensions and a sensible quality to control memory and output size. Avoid very large full-page captures unless they are required. Cache results for stable URLs, but include all rendering inputs—viewport, headers, cookies, JavaScript settings, and version—in the cache key. Retry only transient network failures, with a limit and backoff; retrying deterministic renderer errors wastes capacity.
wkhtmltoimage itself has no per-request service fee. Your operational cost comes from compute, memory, storage, network egress, isolation, and engineering time. A Go-native project such as gowkhtmltopdf documents static binaries and a Go 1.26+ requirement, but its own documentation says complex public sites and full browser CSS/JavaScript parity are unsupported. Compare those limits with your templates before switching.
9. Or skip the browser setup
ScreenshotNeo provides a website screenshot API and MCP server. One GET request returns a PNG, JPEG, WebP, or PDF. Cookie and consent banners are accepted and more than 60 known consent platforms, newsletter popups, and chat widgets are removed before the shot; each step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers report the page verdict and billing status. Its MCP server lets Claude, Cursor, and other MCP clients call take_screenshot, get_page_info, and capture_pdf.
See the ScreenshotNeo API documentation for all options. A minimal call is:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
There are 1,000 free screenshots each month with no card. Paid plans start at $5 for 3,000 shots, and every feature is available on every plan. Create a free ScreenshotNeo account.
10. FAQ
Does wkhtmltoimage need X11?
The wkhtmltopdf project describes it as headless, so it can run without a display service. Your package may still have system-library requirements; verify the deployed binary in its actual environment.
Can I capture a local HTML file?
Yes. Pass the file path as the input. Keep local-file access disabled for untrusted documents and allow only a controlled asset directory when necessary.
Why does the screenshot differ from Chrome?
wkhtmltoimage uses QtWebKit, a legacy renderer. Differences in CSS, JavaScript, fonts, and browser APIs are expected on modern sites.
Is a fixed JavaScript delay always reliable?
No. It is only a time-based guess. When you control the page, a readiness signal such as window.status is more deterministic.
When should I choose an API instead?
Use an API when you want to avoid installing and isolating a native renderer, need current-site cleanup, or need features such as device presets, element capture, signed links, asynchronous jobs, bulk requests, and MCP access.
11. Production checklist
- Pin and record the wkhtmltoimage version.
- Set width, format, quality, and JavaScript readiness behavior explicitly.
- Enforce a context deadline and worker resource limits.
- Capture stderr and verify a non-empty output file.
- Validate schemes and hosts; restrict local-file access.
- Protect cookies, authorization headers, and generated files.
- Run smoke captures from the exact production image.
- Monitor duration, failures, timeouts, output sizes, and representative visual regressions.


