HTML to PDF in Go — 3 options and the one most teams should pick cover illustration

HTML to PDF in Go — 3 options and the one most teams should pick

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:

ScenarioTime
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.

Get API key → See features

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

Frequently asked questions

What is the best Go library for HTML to PDF?

chromedp is the most widely-used and stable option — directly wraps Chrome DevTools Protocol, no extra abstraction. rod is a higher-level alternative with a nicer API. For production without managing Chromium yourself, a managed HTML-to-PDF API and a 40-line Go client is the cleanest path.

Does chromedp support PDF generation?

Yes. chromedp.PrintToPDF() wraps the Page.printToPDF CDP command, producing PDF bytes. Supports all the standard options — page size, margins, background colour, header/footer templates, print CSS.

Can I use gofpdf or jung-kurt/gofpdf for HTML to PDF?

No — those are programmatic PDF builders (draw-primitives style), not HTML renderers. They don't parse HTML or CSS. Use them for constructing PDFs from data structures, not converting HTML.

How do I run chromedp in Docker?

Use a base image with Chromium installed (chromedp/headless-shell is official and small ~270MB), set `--no-sandbox` and `--disable-dev-shm-usage` flags. See Dockerfile example in this post.

Is chromedp production-ready?

Yes — we use chromedp in 21pdf's own worker pool (internal/workers/pdf_engine.go). It's stable, well-maintained, handles crashes cleanly when wrapped with timeouts and a process pool.