Get started

Generate PDFs from HTML in Go: chromedp, Rod, and REST APIs compared

Generate PDFs from HTML in Go using chromedp, Rod, or a REST API. Covers templates, concurrency, Docker sizing, and production deployment patterns.

benoitded11 min read

Generating PDFs from HTML in Go requires a headless browser because Go has no pure-Go HTML rendering engine with full CSS support. This guide covers three approaches: chromedp (the standard choice), Rod (higher-level alternative), and a REST API. Each section includes working code you can compile and run.

Approach comparison

ApproachCSS supportDependenciesConcurrencyDocker size impactBest for
chromedpFull (Chromium)Chrome binaryManual pool+300 MBMost Go projects
RodFull (Chromium)Auto-downloads ChromeBuilt-in pool+300 MBRapid prototyping
wkhtmltopdf (exec)Poor (deprecated)C binaryProcess-per-call+100 MBLegacy only
PDF4.devFull (hosted Chromium)None (net/http)Unlimited0Production, serverless

Option 1: chromedp

chromedp is a Go package that drives Chrome or Chromium using the Chrome DevTools Protocol, without requiring a Node.js runtime. It is the most widely used Go library for browser automation and PDF generation.

Installation

go get github.com/chromedp/chromedp

You also need Chrome or Chromium installed on the system. chromedp auto-detects the binary path.

Basic PDF generation

package main
 
import (
	"context"
	"log"
	"os"
 
	"github.com/chromedp/cdproto/page"
	"github.com/chromedp/chromedp"
)
 
func main() {
	html := `<!DOCTYPE html>
<html>
<head>
  <style>
    body { font-family: Arial, sans-serif; margin: 20mm; }
    h1 { color: #111; font-size: 24px; }
    .total { font-size: 18px; font-weight: bold; }
  </style>
</head>
<body>
  <h1>Invoice #001</h1>
  <p>Client: Acme Corp</p>
  <p class="total">Total: $1,500.00</p>
</body>
</html>`
 
	ctx, cancel := chromedp.NewContext(context.Background())
	defer cancel()
 
	var pdfBuf []byte
	if err := chromedp.Run(ctx,
		chromedp.Navigate("about:blank"),
		chromedp.ActionFunc(func(ctx context.Context) error {
			frameTree, err := page.GetFrameTree().Do(ctx)
			if err != nil {
				return err
			}
			page.SetDocumentContent(frameTree.Frame.ID, html).Do(ctx)
			return nil
		}),
		chromedp.ActionFunc(func(ctx context.Context) error {
			buf, _, err := page.PrintToPDF().
				WithPaperWidth(8.27).    // A4 width in inches
				WithPaperHeight(11.69).  // A4 height in inches
				WithMarginTop(0.79).     // 20mm
				WithMarginBottom(0.79).
				WithMarginLeft(0.59).    // 15mm
				WithMarginRight(0.59).
				WithPrintBackground(true).
				Do(ctx)
			if err != nil {
				return err
			}
			pdfBuf = buf
			return nil
		}),
	); err != nil {
		log.Fatal(err)
	}
 
	os.WriteFile("invoice.pdf", pdfBuf, 0644)
}

Dynamic templates with html/template

Go's standard html/template package handles dynamic data. Render the template to a string, then pass it to chromedp.

package main
 
import (
	"bytes"
	"context"
	"fmt"
	"html/template"
	"log"
	"os"
 
	"github.com/chromedp/cdproto/page"
	"github.com/chromedp/chromedp"
)
 
type Invoice struct {
	Number string
	Client string
	Items  []LineItem
	Total  float64
}
 
type LineItem struct {
	Description string
	Qty         int
	UnitPrice   float64
}
 
const invoiceTmpl = `<!DOCTYPE html>
<html>
<head>
  <style>
    body { font-family: Arial, sans-serif; margin: 20mm; color: #333; }
    table { width: 100%; border-collapse: collapse; }
    th, td { padding: 8px 12px; border-bottom: 1px solid #eee; text-align: left; }
    .total-row { font-weight: bold; font-size: 16px; }
  </style>
</head>
<body>
  <h1>Invoice {{.Number}}</h1>
  <p>Client: {{.Client}}</p>
  <table>
    <thead>
      <tr><th>Description</th><th>Qty</th><th>Unit Price</th><th>Subtotal</th></tr>
    </thead>
    <tbody>
      {{range .Items}}
      <tr>
        <td>{{.Description}}</td>
        <td>{{.Qty}}</td>
        <td>${{printf "%.2f" .UnitPrice}}</td>
        <td>${{printf "%.2f" (mul .Qty .UnitPrice)}}</td>
      </tr>
      {{end}}
    </tbody>
    <tfoot>
      <tr class="total-row"><td colspan="3">Total</td><td>${{printf "%.2f" .Total}}</td></tr>
    </tfoot>
  </table>
</body>
</html>`
 
func mul(a int, b float64) float64 { return float64(a) * b }
 
func renderHTML(inv Invoice) (string, error) {
	tmpl, err := template.New("invoice").Funcs(template.FuncMap{
		"mul": mul,
	}).Parse(invoiceTmpl)
	if err != nil {
		return "", err
	}
	var buf bytes.Buffer
	if err := tmpl.Execute(&buf, inv); err != nil {
		return "", err
	}
	return buf.String(), nil
}
 
func htmlToPDF(ctx context.Context, html string) ([]byte, error) {
	var pdfBuf []byte
	if err := chromedp.Run(ctx,
		chromedp.Navigate("about:blank"),
		chromedp.ActionFunc(func(ctx context.Context) error {
			frameTree, err := page.GetFrameTree().Do(ctx)
			if err != nil {
				return err
			}
			return page.SetDocumentContent(frameTree.Frame.ID, html).Do(ctx)
		}),
		chromedp.ActionFunc(func(ctx context.Context) error {
			buf, _, err := page.PrintToPDF().
				WithPaperWidth(8.27).
				WithPaperHeight(11.69).
				WithMarginTop(0.79).
				WithMarginBottom(0.79).
				WithPrintBackground(true).
				Do(ctx)
			pdfBuf = buf
			return err
		}),
	); err != nil {
		return nil, err
	}
	return pdfBuf, nil
}
 
func main() {
	inv := Invoice{
		Number: "INV-0042",
		Client: "Acme Corp",
		Items: []LineItem{
			{Description: "Consulting", Qty: 10, UnitPrice: 150.00},
			{Description: "Setup fee", Qty: 1, UnitPrice: 200.00},
		},
		Total: 1700.00,
	}
 
	htmlStr, err := renderHTML(inv)
	if err != nil {
		log.Fatal(err)
	}
 
	ctx, cancel := chromedp.NewContext(context.Background())
	defer cancel()
 
	pdf, err := htmlToPDF(ctx, htmlStr)
	if err != nil {
		log.Fatal(err)
	}
 
	os.WriteFile(fmt.Sprintf("invoice-%s.pdf", inv.Number), pdf, 0644)
}

HTTP handler

func pdfHandler(w http.ResponseWriter, r *http.Request) {
	invoiceID := r.PathValue("id") // Go 1.22+ ServeMux
 
	inv := fetchInvoice(invoiceID) // your DB lookup
	htmlStr, err := renderHTML(inv)
	if err != nil {
		http.Error(w, "template error", http.StatusInternalServerError)
		return
	}
 
	ctx, cancel := chromedp.NewContext(context.Background())
	defer cancel()
 
	pdf, err := htmlToPDF(ctx, htmlStr)
	if err != nil {
		http.Error(w, "pdf error", http.StatusInternalServerError)
		return
	}
 
	w.Header().Set("Content-Type", "application/pdf")
	w.Header().Set("Content-Disposition",
		fmt.Sprintf(`inline; filename="invoice-%s.pdf"`, invoiceID))
	w.Write(pdf)
}

Option 2: Rod

Rod is a higher-level Go library that also uses the DevTools Protocol. It auto-downloads a compatible Chromium binary on first run and provides a simpler API.

Installation

go get github.com/go-rod/rod

Basic PDF generation with Rod

package main
 
import (
	"os"
 
	"github.com/go-rod/rod"
	"github.com/go-rod/rod/lib/proto"
)
 
func main() {
	browser := rod.New().MustConnect()
	defer browser.MustClose()
 
	page := browser.MustPage("")
 
	html := `<html><body><h1>Invoice #001</h1><p>Total: $1,500.00</p></body></html>`
	page.MustSetDocumentContent(html)
	page.MustWaitStable()
 
	pdf, _ := page.PDF(&proto.PagePrintToPDF{
		PaperWidth:      float64Ptr(8.27),
		PaperHeight:     float64Ptr(11.69),
		MarginTop:       float64Ptr(0.79),
		MarginBottom:    float64Ptr(0.79),
		MarginLeft:      float64Ptr(0.59),
		MarginRight:     float64Ptr(0.59),
		PrintBackground: true,
	})
 
	data, _ := pdf.ReadAll()
	os.WriteFile("invoice.pdf", data, 0644)
}
 
func float64Ptr(v float64) *float64 { return &v }

Rod downloads Chromium automatically on first run, which is convenient for development but can cause surprises in CI/CD pipelines. Pin the browser version in production using rod.New().ControlURL(...) pointed at a pre-installed binary.


When the DIY approach breaks down

Both chromedp and Rod work well for low-volume PDF generation. At production scale, several problems compound.

Docker image size

Chromium adds 300-400 MB to your container image. Even the slim chromedp/headless-shell image adds ~200 MB. This inflates container pull times, increases cold starts on cloud platforms, and raises registry storage costs. A Go binary that only needs net/http ships as a 10-15 MB static binary.

Concurrency and memory

Each chromedp context spawns a browser tab. Under concurrent load, memory usage grows linearly: each tab consumes 50-100 MB of RAM. At 50 concurrent PDF requests, the Chrome process alone uses 2.5-5 GB. You need explicit pool management, semaphores, or a worker queue to bound resource usage. Go's goroutines make this possible, but the Chrome process is the bottleneck, not Go.

No built-in template engine

Go's html/template handles basic interpolation, but it lacks built-in helpers for formatting dates, currencies, or conditionals beyond {{if}}. For invoice-grade templates with formatted numbers, repeated headers/footers, and conditional sections, you end up writing custom template functions or pulling in third-party libraries.

Serverless incompatibility

AWS Lambda has a 250 MB deployment limit. A Go binary plus Chromium exceeds that. Google Cloud Run has no binary size limit but cold-starting a Chromium process takes 2-5 seconds. On any serverless platform, running a browser process means paying for idle compute while the browser initializes.

The cost math

Cost factorSelf-hosted (chromedp/Rod)REST API (PDF4.dev)
Docker image+300 MB Chrome0 (net/http only)
RAM per request50-100 MB (Chrome tab)0 (API handles it)
Cold start2-5s (Chrome launch)None
Ops timeBrowser updates, crash recoveryNone
ServerlessDifficult (binary too large)Works everywhere
ConcurrencyManual pool, bounded by RAMUnlimited

Option 3: PDF4.dev API

PDF4.dev is a REST API for HTML-to-PDF conversion. Send an HTTP POST with your HTML and data, get a PDF back. The rendering runs on managed Chromium, so your Go binary stays small and dependency-free.

Generate a PDF with net/http

package main
 
import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
)
 
type RenderRequest struct {
	HTML string         `json:"html"`
	Data map[string]any `json:"data,omitempty"`
}
 
func generatePDF(apiKey, html string, data map[string]any) ([]byte, error) {
	body, err := json.Marshal(RenderRequest{HTML: html, Data: data})
	if err != nil {
		return nil, err
	}
 
	req, err := http.NewRequest("POST", "https://pdf4.dev/api/v1/render", bytes.NewReader(body))
	if err != nil {
		return nil, 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, err
	}
	defer resp.Body.Close()
 
	if resp.StatusCode != http.StatusOK {
		errBody, _ := io.ReadAll(resp.Body)
		return nil, fmt.Errorf("PDF API error (%d): %s", resp.StatusCode, errBody)
	}
 
	return io.ReadAll(resp.Body)
}
 
func main() {
	apiKey := os.Getenv("PDF4_API_KEY")
 
	html := `<html>
<head><style>
  body { font-family: Inter, sans-serif; margin: 20mm; }
  h1 { color: #111; }
</style></head>
<body>
  <h1>Invoice {{invoice_number}}</h1>
  <p>Client: {{client_name}}</p>
  <p>Total: {{total}}</p>
</body>
</html>`
 
	pdf, err := generatePDF(apiKey, html, map[string]any{
		"invoice_number": "INV-0042",
		"client_name":    "Acme Corp",
		"total":          "$1,700.00",
	})
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}
 
	os.WriteFile("invoice.pdf", pdf, 0644)
}

PDF4.dev uses Handlebars syntax ({{variable}}) for templates, with built-in helpers for formatting dates, numbers, and currencies.

Use a saved template

Save your HTML template once in the PDF4.dev dashboard, then reference it by slug. This separates template design from Go application code.

type TemplateRenderRequest struct {
	TemplateID string         `json:"template_id"`
	Data       map[string]any `json:"data"`
}
 
func renderTemplate(apiKey, templateID string, data map[string]any) ([]byte, error) {
	body, err := json.Marshal(TemplateRenderRequest{
		TemplateID: templateID,
		Data:       data,
	})
	if err != nil {
		return nil, err
	}
 
	req, err := http.NewRequest("POST", "https://pdf4.dev/api/v1/render", bytes.NewReader(body))
	if err != nil {
		return nil, 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, err
	}
	defer resp.Body.Close()
 
	if resp.StatusCode != http.StatusOK {
		errBody, _ := io.ReadAll(resp.Body)
		return nil, fmt.Errorf("PDF API error (%d): %s", resp.StatusCode, errBody)
	}
 
	return io.ReadAll(resp.Body)
}

HTTP handler with PDF4.dev

func pdfHandler(w http.ResponseWriter, r *http.Request) {
	invoiceID := r.PathValue("id")
	apiKey := os.Getenv("PDF4_API_KEY")
 
	pdf, err := renderTemplate(apiKey, "invoice", map[string]any{
		"invoice_number": invoiceID,
		"client_name":    "Acme Corp",
		"total":          "$1,700.00",
	})
	if err != nil {
		http.Error(w, "pdf generation failed", http.StatusInternalServerError)
		return
	}
 
	w.Header().Set("Content-Type", "application/pdf")
	w.Header().Set("Content-Disposition",
		fmt.Sprintf(`inline; filename="invoice-%s.pdf"`, invoiceID))
	w.Write(pdf)
}

No Chrome binary, no DevTools Protocol, no browser lifecycle. The Go binary stays at 10-15 MB.


Choosing the right approach

Use chromedp if:

  • You need full control over the rendering pipeline
  • You are already running Chrome in your infrastructure (e.g., for E2E tests)
  • Your deployment target has no size or binary constraints
  • You want zero external HTTP dependencies

Use Rod if:

  • You want auto-managed browser downloads during development
  • You prefer a higher-level API over raw DevTools Protocol calls
  • You are prototyping and want the fastest path to a working PDF

Use PDF4.dev if:

  • You deploy to serverless or size-constrained environments (Lambda, Cloud Run)
  • You want sub-300ms renders without managing a browser pool
  • You need designers to update templates without redeploying Go code
  • Concurrency matters and you do not want to manage Chrome memory

PDF format options

All approaches support standard page sizes and custom dimensions.

Formatchromedp / RodPDF4.dev (format.preset)
A4 portraitPaperWidth: 8.27, PaperHeight: 11.69"a4"
A4 landscapePaperWidth: 11.69, PaperHeight: 8.27"a4-landscape"
LetterPaperWidth: 8.5, PaperHeight: 11"letter"
CustomSet width/height in inchespreset: "custom", width: "150mm"

Production checklist

Itemchromedp / RodPDF API
Chrome binary in DockerRequired (~300 MB)Not needed
Concurrency controlSemaphore or worker poolHandled by API
Timeoutscontext.WithTimeoutHTTP client timeout (30s)
Error handlingRecover from Chrome crashesCheck HTTP status code
Font availabilitySystem fonts or @font-faceGoogle Fonts or @font-face
Memory budget50-100 MB per concurrent PDFNone

Summary

Go has no native HTML-to-PDF library, so every approach involves either running a browser (chromedp, Rod) or calling an external service. chromedp is the standard choice for projects that can carry a Chromium dependency. For serverless, size-constrained, or high-concurrency workloads, PDF4.dev removes the browser from the equation: one HTTP call, no binary dependencies, and the Go binary stays small.

Ready to generate PDFs from Go without managing Chrome? Create a free PDF4.dev account, grab an API key, and replace your chromedp pipeline with a single HTTP call.

Try it first with the HTML to PDF converter, or explore other tools like compress PDF and merge PDF.

Free tools mentioned:

Html To PdfTry it freeCompress PdfTry it freeMerge PdfTry it freeProtect PdfTry it freeWatermark PdfTry it free

Start generating PDFs

Build PDF templates with a visual editor. Render them via API from any language in ~300ms.