---
title: "Replace a headless browser for rendering"
url: https://recursiveai.org/onerender/cookbook/replace-headless-browser/
description: "Move HTML-to-PDF and screenshot jobs off Puppeteer, Playwright or wkhtmltopdf onto OneRender: no Chromium download, no browser process, and the same…"
version: "1.0.0"
---

# Replace a headless browser for rendering

Move HTML-to-PDF and screenshot jobs off Puppeteer, Playwright or wkhtmltopdf onto OneRender: no Chromium download, no browser process, and the same output on every machine. Here is each common call and what replaces it.

```csharp
using System.Text;
using OneRender;
using OneRender.Image;
using OneRender.Interaction;
using OneRender.Pdf;

// What a headless browser would load from the network, the host supplies.
var files = new Dictionary<string, byte[]>
{
    ["https://app.example/css/report.css"] = Encoding.UTF8.GetBytes("""
        body { font: 15px/1.5 sans-serif; margin: 24px; color: #222 }
        .card { border: 1px solid #ddd; border-radius: 8px; padding: 12px 16px; margin: 0 0 12px }
        @media print { .no-print { display: none } }
        @page { size: A4; margin: 20mm; @bottom-center { content: "Page " counter(page) " of " counter(pages); font: 9pt sans-serif } }
        """),
};
byte[]? Resources(string url) => files.GetValueOrDefault(url);

var body = new StringBuilder();
for (int i = 1; i <= 12; i++)
    body.Append($"<div class=card><h2>Week {i}</h2><p>{i * 37} orders shipped.</p></div>");
string html = $"""
    <!DOCTYPE html>
    <html lang="en"><head><title>Shipping report</title><link rel="stylesheet" href="/css/report.css"></head>
    <body><button class="no-print">Download</button>{body}</body></html>
    """;
const string baseUrl = "https://app.example/reports/weekly";

// page.pdf({ format: 'A4', printBackground: true, displayHeaderFooter: true, ... })
HtmlPdfResult pdf = HtmlPdf.ToPdf(html, File.Create("report.pdf"), new HtmlPdfOptions { BaseUrl = baseUrl, Resources = Resources });
Console.WriteLine($"report.pdf: {pdf.PageCount} pages");

// page.setViewport({ width: 800, height: 600, deviceScaleFactor: 2 }); page.screenshot()
RgbaImage shot = HtmlRender.ToImage(html, new HtmlRenderOptions { Width = 800, Height = 600, DevicePixelRatio = 2, BaseUrl = baseUrl, Resources = Resources });
File.WriteAllBytes("viewport.png", shot.ToPng());
Console.WriteLine($"viewport.png: {shot.Width}x{shot.Height}");

// page.screenshot({ fullPage: true }): measure the document, then render that tall.
var measure = InteractivePage.Create(html, new HtmlPageOptions(FontProviders.CreateDefault())
    { ViewportWidth = 800, ViewportHeight = 600, BaseUrl = baseUrl, StylesheetBytes = Resources });
int height = measure.Layout.ScrollHeight(measure.Page.Document.DocumentElement);
RgbaImage full = HtmlRender.ToImage(html, new HtmlRenderOptions { Width = 800, Height = height, BaseUrl = baseUrl, Resources = Resources });
File.WriteAllBytes("full.png", full.ToPng());
Console.WriteLine($"full.png: {full.Width}x{full.Height}");
```

```text
report.pdf: 2 pages
viewport.png: 1600x1200
full.png: 800x1818
```

The PDF uses print media, so the Download button is gone, and the page numbers come from the `@page` rule. The screenshots use screen media and keep the button.

## Call by call

| Headless browser | OneRender |
|---|---|
| `page.setContent(html)` | pass the HTML string |
| `page.goto(url)` | fetch the HTML yourself, set `BaseUrl` to its address |
| network requests, `waitUntil: 'networkidle0'` | the `Resources` callback returns each style sheet, image and font; nothing is fetched |
| `page.pdf({ format, margin })` | `HtmlPdf.ToPdf` with `PdfPageSetup`, or `@page { size; margin }` in CSS |
| `printBackground: true` | always on: backgrounds are painted |
| `displayHeaderFooter`, `headerTemplate`, `footerTemplate` | `@page` margin boxes with `counter(page)`, `counter(pages)` and `string()` |
| `page.emulateMediaType('print')` | `HtmlPdf` uses print media; `HtmlPageOptions.PrintMedia` for anything else |
| `page.screenshot()` | `HtmlRender.ToImage` with `Width`, `Height` and `DevicePixelRatio` |
| `screenshot({ fullPage: true })` | measure with `ScrollHeight`, then render at that height, as above |
| `screenshot({ omitBackground: true })` | `PageColor = PaintColor.Transparent` |
| `element.boundingBox()` | `Layout.GetBoundingClientRect(node)` on an `InteractivePage` |
| `page.evaluate(...)` | not available: there is no JavaScript |

## Full-page screenshots

`ScrollHeight` of the root element is the height of the whole document at that viewport width, as `document.documentElement.scrollHeight` is in a browser. `HtmlRender.ToImage` accepts up to 16,384 CSS pixels on each side, so a longer page belongs in a PDF.

## What changes

- **No JavaScript.** Scripts are inert text, so a page that builds itself in the browser renders empty. Render the final HTML on the server, for example with Razor ([Render a Razor component to PDF](https://recursiveai.org/onerender/cookbook/razor-to-pdf/)).
- **No fonts from the machine.** Text uses the bundled fonts and the packages you add, so output doesn’t change between a laptop and a container. See [Fonts](https://recursiveai.org/onerender/cookbook/fonts/).
- **No process to manage.** Rendering is a function call in your process: no Chromium download, no sandbox flags, no browser pool or crashed tabs, and it works with NativeAOT.
- **Tagged PDFs.** Output is tagged from the HTML semantics by default, and PDF/UA-1 or PDF/A-2a is one option away ([Accessible PDF](https://recursiveai.org/onerender/cookbook/accessible-pdf/)).
- **Gaps to check for.** `<img>` of an SVG file isn’t drawn (inline `<svg>` is), there are no vertical writing modes or CSS footnotes, and repeated table headers don’t show in 1.0.0 ([Headers, footers and page breaks](https://recursiveai.org/onerender/cookbook/paged-media/#tables-across-pages)).

## How close is it to Chrome?

Layout is compared with Chrome for Testing 156 on the same font files, element by element. On the six document templates, 99.967% of the geometry is within tolerance at both 1280 by 800 and 390 by 844. Web Platform Tests reftests gate each CSS module. The method and the numbers are in [Geometry against Chrome](https://recursiveai.org/onerender/docs/geometry-differential/) and [Web Platform Tests](https://recursiveai.org/onerender/docs/wpt-results/).
