ScreenshotNeo

BlogHTML to image & PDF

Convert HTML to PDF in Go with Headless Chrome

Use chromedp and Chrome’s Page.printToPDF to turn a web page into a PDF from Go, with print CSS, readiness checks, and practical troubleshooting.

By the ScreenshotNeo team4 October 202611 min read

To convert a web page to PDF in Go, drive Chrome with chromedp, wait for the page’s relevant content to be ready, call Chrome’s DevTools Protocol Page.printToPDF, then write the returned bytes to a file. For a one-off URL-to-PDF job, Chrome’s headless command line can do the conversion without a Go browser workflow.

The important distinction is control: the CLI prints a URL directly, while chromedp lets your Go program navigate, wait for application-specific conditions, interact with the page, and set PDF options. Both use Chrome’s print pipeline. See the Page.printToPDF protocol reference and Chrome Headless command-line reference.

Use chromedp to print a URL from Go

This example accepts a URL and output path, starts a Chrome instance managed by chromedp, navigates, waits for the document load event, prints with backgrounds and CSS page sizing enabled, and writes the PDF. Use a Go module and install compatible, pinned chromedp and CDP binding versions before building; chromedp’s generated bindings evolve, so check method signatures against the versions you pin.

package main

import (
	"context"
	"fmt"
	"os"
	"time"

	"github.com/chromedp/chromedp"
	"github.com/chromedp/cdproto/page"
)

func main() {
	if len(os.Args) != 3 {
		fmt.Fprintln(os.Stderr, "usage: html-to-pdf URL output.pdf")
		os.Exit(2)
	}
	url, outputPath := os.Args[1], os.Args[2]

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

	// Bound navigation and printing so a stalled page cannot hang forever.
	ctx, cancelTimeout := context.WithTimeout(ctx, 60*time.Second)
	defer cancelTimeout()

	var pdf []byte
	err := chromedp.Run(ctx,
		chromedp.Navigate(url),
		chromedp.WaitReady("body", chromedp.ByQuery),
		chromedp.ActionFunc(func(ctx context.Context) error {
			var err error
			pdf, _, err = page.PrintToPDF().
				WithPrintBackground(true).
				WithPreferCSSPageSize(true).
				Do(ctx)
			return err
		}),
	)
	if err != nil {
		fmt.Fprintf(os.Stderr, "render PDF: %v\n", err)
		os.Exit(1)
	}
	if len(pdf) == 0 {
		fmt.Fprintln(os.Stderr, "Chrome returned an empty PDF")
		os.Exit(1)
	}
	if err := os.WriteFile(outputPath, pdf, 0644); err != nil {
		fmt.Fprintf(os.Stderr, "write PDF: %v\n", err)
		os.Exit(1)
	}
	fmt.Printf("wrote %s (%d bytes)\n", outputPath, len(pdf))
}

Save as main.go, initialize a module, install your pinned dependencies, and run it with a page URL and destination:

go mod init example.com/html-to-pdf
go get github.com/chromedp/chromedp github.com/chromedp/cdproto
go run . https://example.com output.pdf

The code waits for the body element, which proves only that the element exists. It does not prove that a single-page app has finished fetching data, that images have loaded, or that charts have rendered. Replace or supplement that wait with a condition that represents readiness for your page.

Wait for the content you need

Prefer a stable application signal, such as a report container populated by the app, a loading indicator disappearing, or a known element becoming visible. For example, if the page adds #report-ready only after its data and charts are ready, wait for that selector before printing:

chromedp.Navigate(url),
chromedp.WaitVisible("#report-ready", chromedp.ByID),

Use the selector strategy appropriate to your page and verify that the element actually means the desired content is complete. A fixed sleep can be useful as a final allowance for a known animation, but it is not a reliable readiness test: network delays and application behavior vary. Chrome’s CLI also offers timing controls, but those do not define a universal readiness condition.

Load HTML that your Go program generates

If the HTML is already in memory, load it into the browser with Page.setDocumentContent after navigating to a document origin, then print it. This variant accepts HTML on standard input and writes a PDF file. It is useful for self-contained markup; relative URLs need an appropriate base URL and reachable resources.

package main

import (
	"context"
	"fmt"
	"io"
	"os"
	"time"

	"github.com/chromedp/chromedp"
	"github.com/chromedp/cdproto/page"
)

func main() {
	html, err := io.ReadAll(os.Stdin)
	if err != nil {
		fatal(err)
	}
	if len(os.Args) != 2 {
		fmt.Fprintln(os.Stderr, "usage: html-to-pdf-html output.pdf < input.html")
		os.Exit(2)
	}

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

	var pdf []byte
	err = chromedp.Run(ctx,
		chromedp.Navigate("about:blank"),
		chromedp.ActionFunc(func(ctx context.Context) error {
			return page.SetDocumentContent("", string(html)).Do(ctx)
		}),
		chromedp.WaitReady("body", chromedp.ByQuery),
		chromedp.ActionFunc(func(ctx context.Context) error {
			var err error
			pdf, _, err = page.PrintToPDF().
				WithPrintBackground(true).
				WithPreferCSSPageSize(true).
				Do(ctx)
			return err
		}),
	)
	if err != nil {
		fatal(err)
	}
	if len(pdf) == 0 {
		fatal(fmt.Errorf("Chrome returned an empty PDF"))
	}
	if err := os.WriteFile(os.Args[1], pdf, 0644); err != nil {
		fatal(err)
	}
}

func fatal(err error) {
	fmt.Fprintln(os.Stderr, err)
	os.Exit(1)
}

SetDocumentContent takes a frame identifier and HTML. The main frame identifier is the empty string in this usage. For generated documents that fetch external fonts, images, or stylesheets, ensure those URLs resolve from the document’s origin and wait for the relevant resources or app signal before printing. If your template uses relative paths, serve it from a suitable local or remote origin instead of assuming about:blank gives those paths a base.

Set page size, margins, orientation, and print CSS

Page.printToPDF controls the print output. The Go binding exposes builder methods for its parameters; the precise names available depend on the cdproto version you pin. The protocol’s options include:

Setting When to use it
printBackground Enable when background colors and images are part of the intended document. The binding default is false.
preferCSSPageSize Honor CSS @page dimensions instead of fitting content to the paper size. The protocol says content is scaled to fit when CSS page size is not preferred.
landscape Print wide tables, dashboards, or diagrams in landscape orientation.
paperWidth, paperHeight Set paper dimensions in inches when you need a specific paper size. Check the binding documentation for units and defaults.
marginTop, marginBottom, marginLeft, marginRight Set margins in inches to reserve space or control the printable area.
scale Adjust print scale when the layout does not fit as intended; inspect the output because scaling also affects legibility.
displayHeaderFooter, templates Enable Chrome’s header/footer templates when a date, title, URL, or page number is useful. Leave disabled to omit them.
pageRanges Print selected pages when only a subset is needed. Confirm the requested range is valid for the rendered document.
generateTaggedPDF, generateDocumentOutline Request tagged output or an outline when the pinned Chrome protocol version supports them and the result meets your accessibility or navigation needs.
transferMode Choose protocol transfer behavior for large output when supported by your pinned protocol version; the protocol documents stream transfer mode.

For example, typical options can be added to the earlier action in versions whose generated builder exposes these methods:

pdf, _, err = page.PrintToPDF().
    WithPrintBackground(true).
    WithPreferCSSPageSize(true).
    WithLandscape(true).
    WithMarginTop(0.5).
    WithMarginBottom(0.5).
    WithMarginLeft(0.5).
    WithMarginRight(0.5).
    Do(ctx)

Use your pinned cdproto Page binding as the authority for exact builders and supported parameters. Chrome and its protocol bindings are versioned independently enough that copying a builder name from a newer generated file into an older dependency can fail to compile.

Author layout for print with CSS where possible. For example:

@media print {
  .screen-only, nav, .cookie-banner { display: none !important; }
  a { color: inherit; text-decoration: none; }
}

@page {
  size: A4 portrait;
  margin: 14mm 12mm;
}

@media print {
  h1, h2 { break-after: avoid; }
  table, figure { break-inside: avoid; }
}

Set preferCSSPageSize when the CSS page dimensions should drive the PDF’s page size. If it is false, Chrome fits content to the paper dimensions configured through the protocol. CSS page breaks, margin rules, and browser support determine the final pagination, so inspect representative output when templates change.

Use Chrome’s headless CLI for a simple URL

When no Go-side browser control is needed, invoke Chrome directly. Its documented example writes output.pdf in the current working directory:

chrome --headless --print-to-pdf https://example.com/

Suppress Chrome’s date/time header and URL/page-number footer like this:

chrome --headless --print-to-pdf --no-pdf-header-footer https://example.com/

On older Chrome versions, the previous flag name may be needed:

chrome --headless --print-to-pdf --print-to-pdf-no-header https://example.com/

Chrome also documents --timeout, which sets the maximum wait before capture even if loading continues, and --virtual-time-budget, which fast-forwards time-dependent page code for capture. For example:

chrome --headless --print-to-pdf --timeout=5000 https://example.com/
chrome --headless --print-to-pdf --virtual-time-budget=42000 https://example.com/

These flags control capture timing; neither establishes that your app’s asynchronous data is ready. Prefer chromedp if the job must wait for a specific selector or perform browser actions before printing. The choice is based on the documented interfaces, not a speed benchmark.

Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server from Yorker Media. It also captures PDFs with a single request; see the API documentation for the request options. This is a hosted PDF capture request, rather than a Go-controlled local Chrome print job.

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -d format=pdf \
  -o page.pdf

Cookie and consent banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing status. Its MCP server gives AI agents tools to take screenshots, inspect page information, and capture PDFs. The free plan includes 1,000 shots a month with no card; paid plans start at $5 for 3,000 shots. Create a free account and get 1,000 screenshots a month with no card.

Operational notes: performance, reliability, and cost

Browser lifecycle and reliability

  • Create a chromedp context with chromedp.NewContext and use that context for browser actions and CDP calls. The context associates browser and tab state.
  • Cancel contexts when the job ends, and bound work with a deadline. Handle failures from navigation, printing, and file output separately so logs show which stage failed.
  • When Chrome is launched by chromedp, its Linux documentation says chromedp kills child processes it started when the program finishes. A lost browser connection can cancel the context.
  • Pin compatible versions of Chrome/Chromium, chromedp, and cdproto in deployment. Check the generated CDP API when upgrading.
  • Run Chrome with the executable and libraries available in the runtime environment. For headless deployments, the chromedp project documents a headless-shell image option.
  • Do not assume a successful protocol call means a visually correct PDF. Validate output for missing assets, pagination, print backgrounds, and page count using representative pages.

Performance and resource use

Rendering cost depends on the page, its resources, and the browser environment; the cited sources give no universal speed or memory figures. Reusing a browser process for a batch can avoid repeated startup, but isolate each job with its own tab/context as appropriate and close it after use. Limit concurrent pages based on the memory and CPU available to the service. Large pages, high-resolution images, complex scripts, and long PDFs require more rendering and output work.

For large output, the protocol documents a stream transfer mode in addition to returning PDF data. Check the pinned Go binding for its exact handling before switching from the simple byte-slice approach. Avoid logging sensitive page content or credentials passed through cookies and headers.

Cost

With chromedp, the relevant costs are your own compute and the maintenance of Chrome/Chromium in the environment where the Go program runs; the dossier supplies no benchmark or hosting price to estimate per-document cost. The CLI also requires an available Chrome executable. A hosted API shifts browser setup out of your application and charges according to its plan and billing rules; ScreenshotNeo’s stated tiers are 1,000 free shots monthly, then Starter at $5 for 3,000, Growth at $15 for 15,000, Pro at $39 for 60,000, Scale at $99 for 250,000, and Business at $249 for 1,000,000. Yearly billing gives two months free, and every feature is on every plan.

Troubleshooting

Symptom Likely cause Fix
undefined: page.PrintToPDF or a missing builder method The installed generated binding does not match the example’s API. Inspect the pinned cdproto/page package and use the method names it provides; update compatible module versions together if needed.
Chrome executable not found or launch fails Chrome/Chromium is absent, not on the executable path, or its runtime dependencies are missing. Install a compatible browser in the environment or configure chromedp’s allocator options to use its actual executable path. Check the deployment image’s required libraries.
Navigation or print times out The page keeps loading, the network is slow, Chrome is overloaded, or the deadline is too short. Set a realistic context deadline, wait on a page-specific readiness signal, and inspect network and browser logs. A timeout is a bound, not proof of readiness.
PDF contains a loading state or missing data Printing began after basic document readiness but before app data or charts finished rendering. Wait for an application-specific selector or state that means required content is ready. Do not rely on WaitReady("body") alone.
Images or fonts are missing Resources are still loading, blocked, unreachable, or use relative URLs without a suitable base. Use resolvable absolute paths or serve the document from the intended origin; wait for required assets and check access from the Chrome runtime.
Background colors or images disappear Print backgrounds are disabled, either in the protocol parameters or page styling. Set printBackground true and verify print CSS does not remove the background.
Content is clipped or scaled unexpectedly CSS page size, protocol paper size, margins, scale, and orientation conflict. Choose whether CSS or protocol dimensions should govern; set preferCSSPageSize accordingly, then adjust page CSS, margins, or landscape orientation.
Extra date, URL, or page numbers appear Chrome’s print header and footer are enabled. Disable displayHeaderFooter for CDP, or use --no-pdf-header-footer with the CLI (older Chrome may use --print-to-pdf-no-header).
Output file is empty or cannot be opened The print call failed, the output buffer is empty, or the destination path is unwritable. Check the CDP error before writing, reject empty byte slices, and verify directory permissions and available disk space.
Browser disconnect cancels the job Chrome exited or the DevTools connection was lost. Treat cancellation as a failed job, capture the error, and ensure the process has enough resources and is not terminated during rendering.

FAQ

Can I print HTML without a public URL?

Yes. Set the document content in a Chrome page, as in the in-memory HTML example. Provide a meaningful origin or absolute asset URLs when the HTML references external resources.

Does a fixed delay guarantee that a page is ready?

No. Delays and Chrome’s timing flags can allow time-dependent work to run, but only an application-specific condition can indicate that your page’s required data and components are ready.

Can I generate only selected PDF pages?

Yes. The CDP print parameters include page ranges. Confirm the range syntax and supported builder in the protocol binding version you use.

Should I use the CLI or chromedp?

Use the CLI for a straightforward URL-to-file command. Use chromedp when Go needs to control navigation, wait for a page condition, perform actions, or set protocol print parameters.

Primary references