Go’s HTML-to-PDF story is smaller than Node’s — fewer libraries, less churn. That’s mostly a good thing: the options are stable, the choice is clearer.
TL;DR
- chromedp — the standard choice. Direct CDP wrapper, minimal abstraction, battle-tested.
- rod — higher-level API over CDP, slightly nicer ergonomics. Pick if you prefer its style.
- Managed API — 40 lines of
net/http. No Chromium to operate. - gofpdf and friends — not HTML-to-PDF. Skip if that’s your need.
chromedp
chromedp drives Chromium via the DevTools Protocol directly. It’s what 21pdf’s own rendering engine uses under the hood.
Install
go get github.com/chromedp/chromedp
chromedp uses your system’s Chromium/Chrome binary. Install it separately (Homebrew, Chocolatey, apt install chromium, or the chromedp/headless-shell Docker image).
Minimum viable example
The idiomatic way to feed HTML into chromedp is via a data URL. It’s safer than injecting HTML at runtime and it works with the full load lifecycle:
package main
import (
"context"
"encoding/base64"
"os"
"time"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp"
)
func htmlToPDF(parent context.Context, html string) ([]byte, error) {
ctx, cancel := chromedp.NewContext(parent)
defer cancel()
ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
defer cancel()
dataURL := "data:text/html;base64," +
base64.StdEncoding.EncodeToString([]byte(html))
var pdf []byte
err := chromedp.Run(ctx,
chromedp.Navigate(dataURL),
chromedp.WaitReady("body"),
chromedp.ActionFunc(func(ctx context.Context) error {
data, _, err := page.PrintToPDF().
WithPrintBackground(true).
WithPreferCSSPageSize(true).
WithMarginTop(0.78). // inches, ~20mm
WithMarginBottom(0.78).
WithMarginLeft(0.59). // ~15mm
WithMarginRight(0.59).
Do(ctx)
if err != nil {
return err
}
pdf = data
return nil
}),
)
return pdf, err
}
func main() {
ctx := context.Background()
pdf, err := htmlToPDF(ctx, "<h1>Invoice #2041</h1><p>Total $1,284</p>")
if err != nil {
panic(err)
}
_ = os.WriteFile("out.pdf", pdf, 0644)
}
For URL inputs, swap chromedp.Navigate(dataURL) for chromedp.Navigate("https://example.com/report").
Browser pool pattern
chromedp’s NewContext() creates a new tab in a shared browser. The browser itself is expensive to launch; tab creation is cheap. For production, launch the browser once:
package pdfservice
import (
"context"
"sync"
"sync/atomic"
"time"
"github.com/chromedp/chromedp"
)
const recycleAfter = 1000
type Pool struct {
mu sync.Mutex
browserCtx context.Context
browserCancel context.CancelFunc
requestCount atomic.Uint64
}
func NewPool(parent context.Context) *Pool {
p := &Pool{}
p.spawn(parent)
return p
}
func (p *Pool) spawn(parent context.Context) {
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.NoSandbox,
chromedp.DisableGPU,
chromedp.Flag("disable-dev-shm-usage", true),
)
allocCtx, _ := chromedp.NewExecAllocator(parent, opts...)
browserCtx, cancel := chromedp.NewContext(allocCtx)
// Start the browser process.
_ = chromedp.Run(browserCtx)
p.browserCtx = browserCtx
p.browserCancel = cancel
p.requestCount.Store(0)
}
func (p *Pool) Render(parent context.Context, html string) ([]byte, error) {
p.mu.Lock()
if p.requestCount.Load() >= recycleAfter {
p.browserCancel()
p.spawn(parent)
}
p.requestCount.Add(1)
browserCtx := p.browserCtx
p.mu.Unlock()
tabCtx, cancel := chromedp.NewContext(browserCtx)
defer cancel()
tabCtx, cancel = context.WithTimeout(tabCtx, 30*time.Second)
defer cancel()
return htmlToPDFInContext(tabCtx, html)
}
Fresh tab per request, shared browser, recycle browser every 1000 requests to reclaim leaked memory. Matches the pattern we use inside 21pdf.
chromedp in Docker
FROM chromedp/headless-shell:stable AS chrome
FROM golang:1.22 AS build
WORKDIR /src
COPY go.* .
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/server
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates fonts-liberation fonts-noto-color-emoji && \
rm -rf /var/lib/apt/lists/*
COPY --from=chrome /headless-shell /headless-shell
COPY --from=build /out/app /app
ENV PATH="/headless-shell:${PATH}"
CMD ["/app"]
chromedp/headless-shell is the official Docker image with a slimmed-down Chromium (~270MB compressed). Faster to pull than installing chromium from apt.
Real 21pdf pattern
The pattern we actually use in production, simplified:
func (e *Engine) Render(ctx context.Context, html string, opts PDFOptions) ([]byte, error) {
browser := e.pool.Acquire()
defer e.pool.Release(browser)
tabCtx, cancel := chromedp.NewContext(browser.ctx)
defer cancel()
tabCtx, cancel = context.WithTimeout(tabCtx, opts.Timeout)
defer cancel()
var pdf []byte
dataURL := "data:text/html;base64," +
base64.StdEncoding.EncodeToString([]byte(html))
tasks := []chromedp.Action{
chromedp.Navigate(dataURL),
chromedp.WaitReady("body"),
}
if opts.WaitForNetworkIdle {
tasks = append(tasks, waitNetworkIdle(500*time.Millisecond))
}
tasks = append(tasks, printToPDF(opts, &pdf))
if err := chromedp.Run(tabCtx, tasks...); err != nil {
return nil, fmt.Errorf("chromedp render: %w", err)
}
return pdf, nil
}
The code in internal/workers/pdf_engine.go is similar in shape.
rod
rod is a higher-level Go library over Chrome DevTools Protocol. Nicer API, slightly more opinionated.
go get github.com/go-rod/rod
package main
import (
"encoding/base64"
"io"
"os"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/proto"
)
func main() {
browser := rod.New().MustConnect()
defer browser.MustClose()
html := "<h1>Invoice #2041</h1><p>Total $1,284</p>"
dataURL := "data:text/html;base64," +
base64.StdEncoding.EncodeToString([]byte(html))
page := browser.MustPage(dataURL).MustWaitLoad()
stream, err := page.PDF(&proto.PagePrintToPDF{
PrintBackground: true,
PreferCSSPageSize: true,
MarginTop: ptrF64(0.78),
MarginBottom: ptrF64(0.78),
MarginLeft: ptrF64(0.59),
MarginRight: ptrF64(0.59),
})
if err != nil {
panic(err)
}
bytes, _ := io.ReadAll(stream)
_ = os.WriteFile("out.pdf", bytes, 0644)
}
func ptrF64(v float64) *float64 { return &v }
Pick rod over chromedp if: you prefer its Must* convention and higher-level helpers. Output is identical; both talk CDP.
Pick chromedp over rod if: you want the minimal abstraction, or you’re running production and prefer the more widely-adopted library’s track record.
In 21pdf we went with chromedp because the fewer-layers approach makes failure modes easier to diagnose.
Managed API
The Go client for a managed HTML-to-PDF API is ~40 lines of net/http:
package qpdf
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
const baseURL = "https://login.21pdf.com/v1"
var client = &http.Client{Timeout: 90 * time.Second}
type RenderOptions struct {
PageSize string `json:"page_size"`
MarginTop int `json:"margin_top"`
MarginBottom int `json:"margin_bottom"`
MarginLeft int `json:"margin_left"`
MarginRight int `json:"margin_right"`
WaitForNetworkIdle bool `json:"wait_for_network_idle"`
}
type submitReq struct {
HTML string `json:"html"`
Options RenderOptions `json:"options"`
}
type submitResp struct {
JobID string `json:"job_id"`
Status string `json:"status"`
}
type statusResp struct {
Status string `json:"status"`
Message string `json:"message"`
}
func RenderPDF(ctx context.Context, html string, opts RenderOptions) ([]byte, error) {
key := os.Getenv("QPDF_KEY")
if key == "" {
return nil, fmt.Errorf("QPDF_KEY not set")
}
// 1. Submit.
body, _ := json.Marshal(submitReq{HTML: html, Options: opts})
req, _ := http.NewRequestWithContext(ctx, "POST",
baseURL+"/convert", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 202 && resp.StatusCode != 200 {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("submit %d: %s", resp.StatusCode, b)
}
var sub submitResp
if err := json.NewDecoder(resp.Body).Decode(&sub); err != nil {
return nil, err
}
// 2. Poll.
deadline := time.Now().Add(60 * time.Second)
for time.Now().Before(deadline) {
time.Sleep(500 * time.Millisecond)
r, _ := http.NewRequestWithContext(ctx, "GET",
baseURL+"/jobs/"+sub.JobID, nil)
r.Header.Set("Authorization", "Bearer "+key)
sr, err := client.Do(r)
if err != nil {
return nil, err
}
var st statusResp
_ = json.NewDecoder(sr.Body).Decode(&st)
sr.Body.Close()
if st.Status == "succeeded" {
break
}
if st.Status == "failed" {
return nil, fmt.Errorf("render: %s", st.Message)
}
}
// 3. Download.
r, _ := http.NewRequestWithContext(ctx, "GET",
baseURL+"/jobs/"+sub.JobID+"/download", nil)
r.Header.Set("Authorization", "Bearer "+key)
d, err := client.Do(r)
if err != nil {
return nil, err
}
defer d.Body.Close()
if d.StatusCode != 200 {
return nil, fmt.Errorf("download %d", d.StatusCode)
}
return io.ReadAll(d.Body)
}
Use:
pdf, err := qpdf.RenderPDF(ctx, htmlContent, qpdf.RenderOptions{
PageSize: "A4",
MarginTop: 20,
MarginBottom: 20,
MarginLeft: 15,
MarginRight: 15,
WaitForNetworkIdle: true,
})
Standard library only. No chromedp, no browser binary, no Dockerfile adjustments.
Framework integrations
net/http
func invoiceHandler(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
inv, err := loadInvoice(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), 404)
return
}
html := renderInvoiceHTML(inv)
pdf, err := qpdf.RenderPDF(r.Context(), html, defaultOpts)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Disposition",
fmt.Sprintf(`inline; filename="invoice-%s.pdf"`, inv.Number))
_, _ = w.Write(pdf)
}
gin
r.GET("/invoices/:id/pdf", func(c *gin.Context) {
inv, err := loadInvoice(c.Request.Context(), c.Param("id"))
if err != nil { c.JSON(404, gin.H{"error": err.Error()}); return }
html := renderInvoiceHTML(inv)
pdf, err := qpdf.RenderPDF(c.Request.Context(), html, defaultOpts)
if err != nil { c.JSON(500, gin.H{"error": err.Error()}); return }
c.Data(200, "application/pdf", pdf)
})
chi / stdlib
Same shape as the net/http example above — Go’s HTTP handler signature is universal.
Rendering from html/template
import "html/template"
var invoiceTmpl = template.Must(template.ParseFiles("invoice.html"))
func renderInvoiceHTML(inv Invoice) string {
var buf bytes.Buffer
_ = invoiceTmpl.Execute(&buf, inv)
return buf.String()
}
Standard Go templating. The PDF renderer doesn’t care where the HTML came from.
Performance reference
Rough numbers on an M2 Pro laptop, warmed up:
| Scenario | Time |
|---|---|
| chromedp cold start (new browser) | 600-1200ms |
| chromedp warm (new tab) | 40-120ms |
| Simple HTML → PDF (no JS, no fonts) | 100-200ms |
| Complex HTML (fonts, JS) | 400-1500ms |
| 21pdf API round-trip (warm) | 300-800ms |
| 21pdf API round-trip (cold) | 1200-2500ms |
Chromium is Chromium — Go and Node perform the same. The language difference is negligible.
Gotchas
Goroutine leaks in chromedp
Every chromedp.NewContext() returns a cancel function. Defer the cancel immediately — if the goroutine that created the context dies without calling cancel, you leak a browser tab and potentially the whole browser:
// GOOD
ctx, cancel := chromedp.NewContext(parentCtx)
defer cancel()
// BAD — no cancel, memory leak
ctx, _ := chromedp.NewContext(parentCtx)
Context cancellation mid-render
If the HTTP request context is cancelled (client disconnected), chromedp will return before the PDF is complete. Either use a derived context with your own timeout, or accept the cancel and handle the partial failure:
// Decouple PDF render from HTTP request lifetime
renderCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
pdf, err := htmlToPDF(renderCtx, html)
Browser crash handling
Chromium occasionally crashes. Wrap your render call and tear down the pool’s browser on crash:
pdf, err := pool.Render(ctx, html)
if err != nil && isBrowserCrash(err) {
pool.Recycle()
// optionally retry once
}
21pdf runs chromedp under the hood
Our Go worker pool uses the same chromedp + pool pattern shown above. If you don’t want to operate it yourself, we do — 100 PDFs/mo free.
Which option should you pick?
- Self-hosting with moderate volume: chromedp. Stable, well-understood, same pattern 21pdf uses.
- Self-hosting, prefer higher-level API: rod. Functionally equivalent.
- Don’t want to operate Chromium: managed API. The Go client is trivial.
- Serverless Go (Cloud Run, Lambda): managed API strongly preferred — Chromium in a Lambda cold start isn’t fun.
- Constructing PDFs from scratch (drawing shapes, no HTML source): gofpdf or similar. This post is not for that use case.
The language-independent considerations — @page support, SSRF, wait conditions, async models — apply across Go options too. See the HTML-to-PDF API guide for the broader picture.
— 21pdf Engineering