Cases / #44 · 2026-10-14 · Easy
Print to PDF across browsers: which engines can, and the fallback for the rest
`page.pdf()` on seven remote browsers: Chromium, Chrome and Edge print — headless and headful — while Firefox, Camoufox and WebKit refuse. Print CSS plus a full-page screenshot works everywhere.
Chromium Google Chrome Microsoft Edge Firefox Camoufox WebKit
Run on production on 2026-10-14: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
Invoices, reports and archived pages often have to end up as PDFs. Playwright's page.pdf() is documented as "headless Chromium only", which raises two questions on a fleet of real browsers: does a headful Chrome or Edge print, and what do you do on Firefox, Camoufox or WebKit when the site only works there?
What we used, and why
| What | Why |
|---|---|
page.setContent() | The same test page everywhere, with a coloured background, a screen-only banner and enough text for several printed pages — no proxy traffic. |
page.pdf({ format: "A4" }) | Asks the browser to print the page; the PDF bytes come back over the connection. |
printBackground: true | Backgrounds are left out of printed pages by default, as in a desktop print dialog. |
@media print + page.emulateMedia({ media: "print" }) | Applies the page's print stylesheet for screenshots — the part of printing every engine supports. |
page.screenshot({ fullPage: true }) | The fallback capture when page.pdf() isn't available. |
How it works
- Launch seven setups one after another: Chromium headless, Chrome headless and headful, Edge headful, Firefox, Camoufox and WebKit (headful, their default).
- Load the same test page and call
page.pdf()twice — without and with backgrounds — counting pages and bytes, or record the error. - Switch to print media, check that the print stylesheet hid the banner, and take a full-page screenshot.
The code
The same program in five languages (also on GitHub, with the raw output). Set these environment variables first:
CDPFLEET_API_KEY— your API key (dashboard → API keys)PROXY_URL— your proxy, e.g.http://user:[email protected]:8000
// npm install [email protected]
// env: CDPFLEET_API_KEY, PROXY_URL
import { chromium, firefox, webkit } from 'playwright';
const KEY = process.env.CDPFLEET_API_KEY;
const TYPES = { chromium, firefox, webkit };
// Which browsers can print a page to PDF over a remote connection, headless and headful.
const SETUPS = [
{ label: 'Chromium, headless', engine: 'chromium', type: 'chromium', headless: true },
{ label: 'Chrome, headless', engine: 'chrome', type: 'chromium', headless: 'new' },
{ label: 'Chrome, headful', engine: 'chrome', type: 'chromium', headless: false },
{ label: 'Edge, headful', engine: 'edge', type: 'chromium', headless: false },
{ label: 'Firefox, headful', engine: 'firefox', type: 'firefox', headless: false },
{ label: 'Camoufox, headful', engine: 'camoufox', type: 'firefox', headless: false },
{ label: 'WebKit, headful', engine: 'webkit', type: 'webkit', headless: false },
];
// A page with a coloured background, a screen-only banner hidden by print CSS and enough text
// for several printed pages. setContent: no proxy traffic, the same page everywhere.
const HTML = `<html><head><style>
body { font: 14px sans-serif; background: #e8f0ff; }
.screen-only { background: #c00; color: #fff; padding: 8px; }
@media print { .screen-only { display: none; } }
</style></head><body>
<div class="screen-only">Cookie banner (screen only)</div>
<h1>Quarterly report</h1>
${Array.from({ length: 120 }, (_, i) => `<p>Paragraph ${i + 1}: lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>`).join('')}
</body></html>`;
const pagesIn = (buf) => (buf.toString('latin1').match(/\/Type\s*\/Page(?!s)/g) || []).length;
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
async function launch(engine, body) {
for (let attempt = 1; ; attempt++) {
const res = await fetch(`https://starter.cdpfleet.com/${engine}/session`, {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify(body),
});
if (res.ok) return res.json();
if (res.status !== 503 || attempt === 5) throw new Error(`launch ${engine} ${res.status} ${await res.text()}`);
await new Promise((r) => setTimeout(r, (Number(res.headers.get('retry-after')) || 5) * 1000));
}
}
async function one(s) {
const { wsUrl } = await launch(s.engine, { proxy: process.env.PROXY_URL, headless: s.headless });
const browser = await TYPES[s.type].connect(wsUrl, { headers: { 'x-api-key': KEY } });
const out = { label: s.label };
try {
const page = await browser.newPage();
await page.setContent(HTML);
const t = Date.now();
try {
const pdf = await page.pdf({ format: 'A4' });
const withBg = await page.pdf({ format: 'A4', printBackground: true });
out.pdf = 'yes';
out.pdf_bytes = pdf.length;
out.pdf_pages = pagesIn(pdf);
out.with_background_bytes = withBg.length;
out.pdf_seconds = Math.round((Date.now() - t) / 10) / 100;
} catch (err) {
out.pdf = 'no';
out.error = err.message.split('\n')[0].slice(0, 120);
}
// The fallback that works everywhere: print CSS + a full-page screenshot.
await page.emulateMedia({ media: 'print' });
out.print_css_applied = await page.evaluate(() => getComputedStyle(document.querySelector('.screen-only')).display === 'none');
out.print_screenshot_kb = Math.round((await page.screenshot({ fullPage: true })).length / 1024);
} finally {
await browser.close();
}
return out;
}
const results = [];
for (const s of SETUPS) results.push(await one(s));
console.log(JSON.stringify({ results }, null, 2));
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL
import json
import os
import re
import time
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
# Which browsers can print a page to PDF over a remote connection, headless and headful.
SETUPS = [
{"label": "Chromium, headless", "engine": "chromium", "family": "chromium", "headless": True},
{"label": "Chrome, headless", "engine": "chrome", "family": "chromium", "headless": "new"},
{"label": "Chrome, headful", "engine": "chrome", "family": "chromium", "headless": False},
{"label": "Edge, headful", "engine": "edge", "family": "chromium", "headless": False},
{"label": "Firefox, headful", "engine": "firefox", "family": "firefox", "headless": False},
{"label": "Camoufox, headful", "engine": "camoufox", "family": "firefox", "headless": False},
{"label": "WebKit, headful", "engine": "webkit", "family": "webkit", "headless": False},
]
# A page with a coloured background, a screen-only banner hidden by print CSS and enough text
# for several printed pages. set_content: no proxy traffic, the same page everywhere.
PARAS = "".join(f"<p>Paragraph {i + 1}: lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>" for i in range(120))
HTML = f"""<html><head><style>
body {{ font: 14px sans-serif; background: #e8f0ff; }}
.screen-only {{ background: #c00; color: #fff; padding: 8px; }}
@media print {{ .screen-only {{ display: none; }} }}
</style></head><body>
<div class="screen-only">Cookie banner (screen only)</div>
<h1>Quarterly report</h1>
{PARAS}
</body></html>"""
def pages_in(pdf):
return len(re.findall(rb"/Type\s*/Page(?!s)", pdf))
def launch(engine, body):
# 503 means the fleet is momentarily busy: wait Retry-After and try again.
for attempt in range(1, 6):
res = requests.post(f"https://starter.cdpfleet.com/{engine}/session", headers={"x-api-key": KEY}, json=body, timeout=60)
if res.ok:
return res.json()
if res.status_code != 503 or attempt == 5:
raise RuntimeError(f"launch {engine} {res.status_code} {res.text}")
time.sleep(int(res.headers.get("retry-after", 5)))
def one(p, s):
session = launch(s["engine"], {"proxy": os.environ["PROXY_URL"], "headless": s["headless"]})
browser = getattr(p, s["family"]).connect(session["wsUrl"], headers={"x-api-key": KEY})
out = {"label": s["label"]}
try:
page = browser.new_page()
page.set_content(HTML)
t = time.time()
try:
pdf = page.pdf(format="A4")
with_bg = page.pdf(format="A4", print_background=True)
out.update(pdf="yes", pdf_bytes=len(pdf), pdf_pages=pages_in(pdf), with_background_bytes=len(with_bg),
pdf_seconds=round(time.time() - t, 2))
except Exception as err: # Firefox, Camoufox and WebKit refuse
out.update(pdf="no", error=str(err).split("\n")[0][:120])
# The fallback that works everywhere: print CSS + a full-page screenshot.
page.emulate_media(media="print")
out["print_css_applied"] = page.evaluate("() => getComputedStyle(document.querySelector('.screen-only')).display === 'none'")
out["print_screenshot_kb"] = round(len(page.screenshot(full_page=True)) / 1024)
finally:
browser.close()
return out
with sync_playwright() as p:
results = [one(p, s) for s in SETUPS]
print(json.dumps({"results": results}, indent=2))
// Maven: com.microsoft.playwright:playwright:1.60.0, com.google.code.gson:gson:2.11.0
// Run with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1.
// env: CDPFLEET_API_KEY, PROXY_URL
import com.google.gson.*;
import com.microsoft.playwright.*;
import com.microsoft.playwright.options.Media;
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.regex.*;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
// Which browsers can print a page to PDF over a remote connection, headless and headful.
record Setup(String label, String engine, String family, JsonPrimitive headless) {}
static final Setup[] SETUPS = {
new Setup("Chromium, headless", "chromium", "chromium", new JsonPrimitive(true)),
new Setup("Chrome, headless", "chrome", "chromium", new JsonPrimitive("new")),
new Setup("Chrome, headful", "chrome", "chromium", new JsonPrimitive(false)),
new Setup("Edge, headful", "edge", "chromium", new JsonPrimitive(false)),
new Setup("Firefox, headful", "firefox", "firefox", new JsonPrimitive(false)),
new Setup("Camoufox, headful", "camoufox", "firefox", new JsonPrimitive(false)),
new Setup("WebKit, headful", "webkit", "webkit", new JsonPrimitive(false)),
};
// A page with a coloured background, a screen-only banner hidden by print CSS and enough text
// for several printed pages. setContent: no proxy traffic, the same page everywhere.
static String html() {
StringBuilder paras = new StringBuilder();
for (int i = 1; i <= 120; i++) paras.append("<p>Paragraph ").append(i).append(": lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>");
return "<html><head><style>"
+ "body { font: 14px sans-serif; background: #e8f0ff; }"
+ ".screen-only { background: #c00; color: #fff; padding: 8px; }"
+ "@media print { .screen-only { display: none; } }"
+ "</style></head><body><div class=\"screen-only\">Cookie banner (screen only)</div><h1>Quarterly report</h1>"
+ paras + "</body></html>";
}
static int pagesIn(byte[] pdf) {
Matcher m = Pattern.compile("/Type\\s*/Page(?!s)").matcher(new String(pdf, StandardCharsets.ISO_8859_1));
int n = 0;
while (m.find()) n++;
return n;
}
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
static JsonObject launch(String engine, JsonObject body) throws Exception {
for (int attempt = 1; ; attempt++) {
HttpResponse<String> res = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/" + engine + "/session"))
.header("x-api-key", KEY).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toString())).build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() == 200) return JsonParser.parseString(res.body()).getAsJsonObject();
if (res.statusCode() != 503 || attempt == 5) throw new RuntimeException("launch " + engine + " " + res.statusCode() + " " + res.body());
Thread.sleep(Long.parseLong(res.headers().firstValue("retry-after").orElse("5")) * 1000);
}
}
static JsonObject one(Playwright pw, Setup s) throws Exception {
JsonObject body = new JsonObject();
body.addProperty("proxy", System.getenv("PROXY_URL"));
body.add("headless", s.headless());
JsonObject session = launch(s.engine(), body);
BrowserType type = switch (s.family()) { case "firefox" -> pw.firefox(); case "webkit" -> pw.webkit(); default -> pw.chromium(); };
Browser browser = type.connect(session.get("wsUrl").getAsString(), new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
JsonObject out = new JsonObject();
out.addProperty("label", s.label());
try {
Page page = browser.newPage();
page.setContent(html());
long t = System.currentTimeMillis();
try {
byte[] pdf = page.pdf(new Page.PdfOptions().setFormat("A4"));
byte[] withBg = page.pdf(new Page.PdfOptions().setFormat("A4").setPrintBackground(true));
out.addProperty("pdf", "yes");
out.addProperty("pdf_bytes", pdf.length);
out.addProperty("pdf_pages", pagesIn(pdf));
out.addProperty("with_background_bytes", withBg.length);
out.addProperty("pdf_seconds", Math.round((System.currentTimeMillis() - t) / 10.0) / 100.0);
} catch (PlaywrightException err) { // Firefox, Camoufox and WebKit refuse
// Java wraps the message: Error { message='page.pdf: …' … }
Matcher m = Pattern.compile("message='([^'\n]*)").matcher(err.getMessage());
String msg = m.find() ? m.group(1) : err.getMessage().split("\n")[0];
out.addProperty("pdf", "no");
out.addProperty("error", msg.substring(0, Math.min(120, msg.length())));
}
// The fallback that works everywhere: print CSS + a full-page screenshot.
page.emulateMedia(new Page.EmulateMediaOptions().setMedia(Media.PRINT));
out.addProperty("print_css_applied", (Boolean) page.evaluate("() => getComputedStyle(document.querySelector('.screen-only')).display === 'none'"));
out.addProperty("print_screenshot_kb", Math.round(page.screenshot(new Page.ScreenshotOptions().setFullPage(true)).length / 1024.0));
} finally {
browser.close();
}
return out;
}
public static void main(String[] args) throws Exception {
JsonArray results = new JsonArray();
try (Playwright pw = Playwright.create()) {
for (Setup s : SETUPS) results.add(one(pw, s));
}
JsonObject result = new JsonObject();
result.add("results", results);
System.out.println(GSON.toJson(result));
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
using System.Net.Http.Json;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Playwright;
var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
// Which browsers can print a page to PDF over a remote connection, headless and headful.
var setups = new (string Label, string Engine, string Family, JsonNode Headless)[]
{
("Chromium, headless", "chromium", "chromium", JsonValue.Create(true)),
("Chrome, headless", "chrome", "chromium", JsonValue.Create("new")),
("Chrome, headful", "chrome", "chromium", JsonValue.Create(false)),
("Edge, headful", "edge", "chromium", JsonValue.Create(false)),
("Firefox, headful", "firefox", "firefox", JsonValue.Create(false)),
("Camoufox, headful", "camoufox", "firefox", JsonValue.Create(false)),
("WebKit, headful", "webkit", "webkit", JsonValue.Create(false)),
};
// A page with a coloured background, a screen-only banner hidden by print CSS and enough text
// for several printed pages. SetContent: no proxy traffic, the same page everywhere.
var paras = string.Concat(Enumerable.Range(1, 120).Select(i => $"<p>Paragraph {i}: lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>"));
var html = "<html><head><style>"
+ "body { font: 14px sans-serif; background: #e8f0ff; }"
+ ".screen-only { background: #c00; color: #fff; padding: 8px; }"
+ "@media print { .screen-only { display: none; } }"
+ "</style></head><body><div class=\"screen-only\">Cookie banner (screen only)</div><h1>Quarterly report</h1>"
+ paras + "</body></html>";
int PagesIn(byte[] pdf) => Regex.Matches(Encoding.Latin1.GetString(pdf), @"/Type\s*/Page(?!s)").Count;
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
async Task<JsonElement> Launch(string engine, JsonObject body)
{
for (var attempt = 1; ; attempt++)
{
var res = await http.PostAsync($"https://starter.cdpfleet.com/{engine}/session",
new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"));
if (res.IsSuccessStatusCode) return await res.Content.ReadFromJsonAsync<JsonElement>();
if ((int)res.StatusCode != 503 || attempt == 5) throw new Exception($"launch {engine} {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
await Task.Delay((int)(res.Headers.RetryAfter?.Delta?.TotalMilliseconds ?? 5000));
}
}
async Task<JsonObject> One(string label, string engine, string family, JsonNode headless)
{
var session = await Launch(engine, new JsonObject { ["proxy"] = Environment.GetEnvironmentVariable("PROXY_URL"), ["headless"] = headless.DeepClone() });
var type = family switch { "firefox" => playwright.Firefox, "webkit" => playwright.Webkit, _ => playwright.Chromium };
var browser = await type.ConnectAsync(session.GetProperty("wsUrl").GetString()!, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
var outp = new JsonObject { ["label"] = label };
try
{
var page = await browser.NewPageAsync();
await page.SetContentAsync(html);
var t = DateTime.UtcNow;
try
{
var pdf = await page.PdfAsync(new() { Format = "A4" });
var withBg = await page.PdfAsync(new() { Format = "A4", PrintBackground = true });
outp["pdf"] = "yes";
outp["pdf_bytes"] = pdf.Length;
outp["pdf_pages"] = PagesIn(pdf);
outp["with_background_bytes"] = withBg.Length;
outp["pdf_seconds"] = Math.Round((DateTime.UtcNow - t).TotalSeconds, 2);
}
catch (PlaywrightException err) // Firefox, Camoufox and WebKit refuse
{
var msg = err.Message.Split('\n')[0];
outp["pdf"] = "no";
outp["error"] = msg[..Math.Min(120, msg.Length)];
}
// The fallback that works everywhere: print CSS + a full-page screenshot.
await page.EmulateMediaAsync(new() { Media = Media.Print });
outp["print_css_applied"] = await page.EvaluateAsync<bool>("() => getComputedStyle(document.querySelector('.screen-only')).display === 'none'");
outp["print_screenshot_kb"] = (int)Math.Round((await page.ScreenshotAsync(new() { FullPage = true })).Length / 1024.0);
}
finally
{
await browser.CloseAsync();
}
return outp;
}
var results = new JsonArray();
foreach (var s in setups) results.Add(await One(s.Label, s.Engine, s.Family, s.Headless));
Console.WriteLine(new JsonObject { ["results"] = results }.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
// go get github.com/playwright-community/[email protected]
// Driver: build playwright-core 1.60.0 from npm and set PLAYWRIGHT_DRIVER_PATH (see /docs/quickstart).
// env: CDPFLEET_API_KEY, PROXY_URL
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"time"
"github.com/playwright-community/playwright-go"
)
var key = os.Getenv("CDPFLEET_API_KEY")
// Which browsers can print a page to PDF over a remote connection, headless and headful.
type setup struct {
label, engine, family string
headless any
}
var setups = []setup{
{"Chromium, headless", "chromium", "chromium", true},
{"Chrome, headless", "chrome", "chromium", "new"},
{"Chrome, headful", "chrome", "chromium", false},
{"Edge, headful", "edge", "chromium", false},
{"Firefox, headful", "firefox", "firefox", false},
{"Camoufox, headful", "camoufox", "firefox", false},
{"WebKit, headful", "webkit", "webkit", false},
}
// A page with a coloured background, a screen-only banner hidden by print CSS and enough text
// for several printed pages. SetContent: no proxy traffic, the same page everywhere.
func html() string {
var paras strings.Builder
for i := 1; i <= 120; i++ {
fmt.Fprintf(¶s, "<p>Paragraph %d: lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>", i)
}
return "<html><head><style>" +
"body { font: 14px sans-serif; background: #e8f0ff; }" +
".screen-only { background: #c00; color: #fff; padding: 8px; }" +
"@media print { .screen-only { display: none; } }" +
`</style></head><body><div class="screen-only">Cookie banner (screen only)</div><h1>Quarterly report</h1>` +
paras.String() + "</body></html>"
}
// Go's regexp has no lookahead: match "/Type /Page" plus the next byte and drop "/Pages".
var pageRe = regexp.MustCompile(`(?s)/Type\s*/Page(.)`)
func pagesIn(pdf []byte) int {
n := 0
for _, m := range pageRe.FindAllSubmatch(pdf, -1) {
if string(m[1]) != "s" {
n++
}
}
return n
}
// Field order matches the other languages' output.
type result struct {
Label string `json:"label"`
Pdf string `json:"pdf"`
PdfBytes *int `json:"pdf_bytes,omitempty"`
PdfPages *int `json:"pdf_pages,omitempty"`
WithBackgroundBytes *int `json:"with_background_bytes,omitempty"`
PdfSeconds *float64 `json:"pdf_seconds,omitempty"`
Error string `json:"error,omitempty"`
PrintCSSApplied bool `json:"print_css_applied"`
PrintScreenshotKB int `json:"print_screenshot_kb"`
}
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
func launch(engine string, opts map[string]any) (map[string]any, error) {
body, _ := json.Marshal(opts)
for attempt := 1; ; attempt++ {
req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/"+engine+"/session", bytes.NewReader(body))
req.Header.Set("x-api-key", key)
req.Header.Set("content-type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
b, _ := io.ReadAll(res.Body)
res.Body.Close()
if res.StatusCode == 200 {
var session map[string]any
return session, json.Unmarshal(b, &session)
}
if res.StatusCode != 503 || attempt == 5 {
return nil, fmt.Errorf("launch %s %d %s", engine, res.StatusCode, strings.TrimSpace(string(b)))
}
wait, err := strconv.Atoi(res.Header.Get("retry-after"))
if err != nil {
wait = 5
}
time.Sleep(time.Duration(wait) * time.Second)
}
}
func one(pw *playwright.Playwright, s setup) (result, error) {
out := result{Label: s.label}
session, err := launch(s.engine, map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": s.headless})
if err != nil {
return out, err
}
browserType := pw.Chromium
switch s.family {
case "firefox":
browserType = pw.Firefox
case "webkit":
browserType = pw.WebKit
}
browser, err := browserType.Connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
if err != nil {
return out, err
}
defer browser.Close()
page, err := browser.NewPage()
if err != nil {
return out, err
}
if err := page.SetContent(html()); err != nil {
return out, err
}
t := time.Now()
pdf, err := page.PDF(playwright.PagePdfOptions{Format: playwright.String("A4")})
if err == nil {
withBg, err2 := page.PDF(playwright.PagePdfOptions{Format: playwright.String("A4"), PrintBackground: playwright.Bool(true)})
if err2 != nil {
return out, err2
}
bytesN, pages, bg := len(pdf), pagesIn(pdf), len(withBg)
secs := math.Round(time.Since(t).Seconds()*100) / 100
out.Pdf, out.PdfBytes, out.PdfPages, out.WithBackgroundBytes, out.PdfSeconds = "yes", &bytesN, &pages, &bg, &secs
} else { // Firefox, Camoufox and WebKit refuse
msg := strings.Split(err.Error(), "\n")[0]
if len(msg) > 120 {
msg = msg[:120]
}
out.Pdf, out.Error = "no", msg
}
// The fallback that works everywhere: print CSS + a full-page screenshot.
if err := page.EmulateMedia(playwright.PageEmulateMediaOptions{Media: playwright.MediaPrint}); err != nil {
return out, err
}
applied, err := page.Evaluate("() => getComputedStyle(document.querySelector('.screen-only')).display === 'none'")
if err != nil {
return out, err
}
out.PrintCSSApplied = applied == true
shot, err := page.Screenshot(playwright.PageScreenshotOptions{FullPage: playwright.Bool(true)})
if err != nil {
return out, err
}
out.PrintScreenshotKB = int(math.Round(float64(len(shot)) / 1024))
return out, nil
}
// run returns errors instead of exiting, so deferred Close and Stop calls still run.
func run() error {
pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
if err != nil {
return err
}
defer pw.Stop()
results := []result{}
for _, s := range setups {
r, err := one(pw, s)
if err != nil {
return fmt.Errorf("%s: %w", s.label, err)
}
results = append(results, r)
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(map[string]any{"results": results}); err != nil {
return err
}
fmt.Print(buf.String())
return nil
}
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
What we got
| Setup | page.pdf | Pages | KB (with background) | Print CSS | Print screenshot KB |
|---|---|---|---|---|---|
| Chromium, headless | yes, 0.03 s | 4 | 19.6 (19.7) | applied | 595 |
| Chrome, headless | yes, 0.17 s | 4 | 19.8 (19.9) | applied | 595 |
| Chrome, headful | yes, 0.16 s | 4 | 19.8 (19.9) | applied | 595 |
| Edge, headful | yes, 0.05 s | 4 | 19.8 (19.9) | applied | 595 |
| Firefox, headful | no | — | page.pdf: PDF generation is only supported for Headless Chromium | applied | 850 |
| Camoufox, headful | no | — | page.pdf: PDF generation is only supported for Headless Chromium | applied | 312 |
| WebKit, headful | no | — | page.pdf: PDF generation is only supported for Headless Chromium | applied | 759 |
From the Node.js run on 2026-10-14. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.
Raw output (Node.js)
{
"results": [
{
"label": "Chromium, headless",
"pdf": "yes",
"pdf_bytes": 20044,
"pdf_pages": 4,
"with_background_bytes": 20170,
"pdf_seconds": 0.03,
"print_css_applied": true,
"print_screenshot_kb": 595
},
{
"label": "Chrome, headless",
"pdf": "yes",
"pdf_bytes": 20240,
"pdf_pages": 4,
"with_background_bytes": 20366,
"pdf_seconds": 0.17,
"print_css_applied": true,
"print_screenshot_kb": 595
},
{
"label": "Chrome, headful",
"pdf": "yes",
"pdf_bytes": 20232,
"pdf_pages": 4,
"with_background_bytes": 20358,
"pdf_seconds": 0.16,
"print_css_applied": true,
"print_screenshot_kb": 595
},
{
"label": "Edge, headful",
"pdf": "yes",
"pdf_bytes": 20246,
"pdf_pages": 4,
"with_background_bytes": 20372,
"pdf_seconds": 0.05,
"print_css_applied": true,
"print_screenshot_kb": 595
},
{
"label": "Firefox, headful",
"pdf": "no",
"error": "page.pdf: PDF generation is only supported for Headless Chromium",
"print_css_applied": true,
"print_screenshot_kb": 850
},
{
"label": "Camoufox, headful",
"pdf": "no",
"error": "page.pdf: PDF generation is only supported for Headless Chromium",
"print_css_applied": true,
"print_screenshot_kb": 312
},
{
"label": "WebKit, headful",
"pdf": "no",
"error": "page.pdf: PDF generation is only supported for Headless Chromium",
"print_css_applied": true,
"print_screenshot_kb": 759
}
]
}Takeaways
- Every Chromium-based browser printed — headful too: Chrome and Edge in their default headful mode returned the same 4-page A4 PDF as headless Chromium. On the fleet, "headless only" really means "Chromium only".
- Firefox, Camoufox and WebKit refuse with
PDF generation is only supported for Headless Chromium— a client-side check in Playwright, so no setting changes it. - Backgrounds are off by default:
printBackground: truegave a larger file with the page's background colour; without it the colour is dropped, like a print dialog. - Print CSS works everywhere:
emulateMedia({ media: "print" })applied the print stylesheet on all seven, so a full-page screenshot in print media is the portable fallback — an image, not selectable text. - Printing is fast: both PDFs of the 4-page document came back in a fraction of a second over the remote connection.