Every night, a fintech analytics platform we work alongside renders a per-tenant performance dashboard to PDF: equity curves, allocation donuts, and risk heat maps, all drawn client-side with Chart.js, then frozen into a document an account manager emails before markets open. The chart canvases only exist after JavaScript runs, so a static HTML-to-PDF pass produces blank rectangles where the visuals should be. The task demands a real browser engine that loads the page, executes its scripts, waits for the charts to finish painting, and captures the rendered result as a faithful PDF.

For an advanced .NET team, the interesting question is rarely "can this be done", it is "what does this cost to run when the tenant count climbs into the hundreds and the job fires every night without supervision." That framing is the spine of this article.

This article walks through that one task two ways in a .NET 10 service: first with PuppeteerSharp, the .NET port of Google's Puppeteer, then with IronPDF. Both drive Chromium, so both render the dashboard identically. (Disclosure: we are the Iron Software architecture team; IronPDF is our product, and we have kept the PuppeteerSharp path complete and production-shaped so you can judge the two on their own terms.) The difference shows up not in the pixels when you convert HTML, but in what each one asks your infrastructure to operate at scale.

The Reporting Service Contract

Both implementations sit behind the same service interface, so the rest of the scheduling and tenant-fan-out code never changes. Listing 1 is the abstraction the nightly job calls.

public interface IDashboardReportRenderer
{
    Task<byte[]> RenderAsync(string dashboardHtml, CancellationToken ct = default);
}

Listing 1. The renderer contract. The scheduler resolves one implementation through dependency injection and calls RenderAsync once per tenant.

The dashboardHtml argument is a fully self-contained page: the Chart.js bundle, the tenant's serialized data, and an onload hook that sets window.chartsReady = true after every chart's animation completes. That flag is how each renderer knows the canvases are painted.

Rendering with PuppeteerSharp

PuppeteerSharp needs a Chromium binary before it can launch. The BrowserFetcher API downloads a pinned revision (roughly 150 MB) on first use; in a container you do this once at build time so cold instances do not stall. Listing 2 is the full renderer.

using PuppeteerSharp;
using PuppeteerSharp.Media;

public sealed class PuppeteerDashboardRenderer : IDashboardReportRenderer, IAsyncDisposable
{
    private readonly IBrowser _browser;
    private PuppeteerDashboardRenderer(IBrowser browser) => _browser = browser;

    public static async Task<PuppeteerDashboardRenderer> CreateAsync()
    {
        await new BrowserFetcher().DownloadAsync();

        var browser = await Puppeteer.LaunchAsync(new LaunchOptions
        {
            Headless = true,
            Args = new[] { "--no-sandbox", "--disable-dev-shm-usage" }
        });
        return new PuppeteerDashboardRenderer(browser);
    }

    public async Task<byte[]> RenderAsync(string dashboardHtml, CancellationToken ct = default)
    {
        await using var page = await _browser.NewPageAsync();
        await page.SetContentAsync(dashboardHtml, new NavigationOptions
        {
            WaitUntil = new[] { WaitUntilNavigation.Networkidle0 }
        });

        await page.WaitForFunctionAsync("() => window.chartsReady === true");

        return await page.PdfDataAsync(new PdfOptions
        {
            Format = PaperFormat.A4,
            PrintBackground = true,
            MarginOptions = new MarginOptions { Top = "20px", Bottom = "20px" }
        });
    }
    public async ValueTask DisposeAsync() => await _browser.DisposeAsync();
}

PuppeteerSharp Output PDF Document

PDF document generated with PuppeteerSharp

Listing 2. The PuppeteerSharp renderer. WaitForFunctionAsync polls until the Chart.js paint flag flips, then PdfDataAsync returns the document bytes. The browser is launched once and reused; --disable-dev-shm-usage keeps Chromium off the 64 MB /dev/shm that Docker provisions by default.

This works, and it works well. WaitForFunctionAsync is exactly the right tool for the Chart.js timing problem: it polls inside the page until the application's own readiness flag flips, so the capture never races the animation. The same engine that prints the PDF can also screenshot pages, fill forms, intercept network requests, scrape rendered content, and run end-to-end UI tests, PuppeteerSharp earns its place in a toolbox well beyond reporting. For a service whose job is browser automation first and PDF generation second, it is a genuinely good fit, and the dashboard it produces is pixel-faithful to what a user sees in the live app. If your reporting service already drives a browser for other reasons, you have most of this infrastructure standing up already.

Rendering PDF documents with the IronPDF .NET Library

IronPDF

IronPDF runs on a Chromium engine too, so the rendered dashboard is the same: same charts, same fonts, same backgrounds. What changes is the lifecycle around it: there is no fetcher to invoke, no browser process to launch, and no page object to dispose. The renderer is a plain class you register once in your DI container and reuse. Listing 3 implements the identical contract from Listing 1.

using IronPdf;

public sealed class IronPdfDashboardRenderer : IDashboardReportRenderer
{
    private readonly ChromePdfRenderer _renderer = new();

    public IronPdfDashboardRenderer()
    {
        _renderer.RenderingOptions.WaitFor.JavaScript(5000);
        _renderer.RenderingOptions.PrintHtmlBackgrounds = true;
        _renderer.RenderingOptions.PaperSize = IronPdf.Rendering.PdfPaperSize.A4;
    }

    public async Task<byte[]> RenderAsync(string dashboardHtml, CancellationToken ct = default)
    {
        PdfDocument pdf = await _renderer.RenderHtmlAsPdfAsync(dashboardHtml);
        return pdf.BinaryData;
    }
}

IronPDF Output PDF Document

PDF generated with IronPDF

Listing 3. The IronPDF renderer. ChromePdfRenderer.RenderHtmlAsPdfAsync executes the page's JavaScript in the bundled engine; WaitFor.JavaScript holds for the Chart.js animation before capture. The engine is managed in-process, so the class has no IAsyncDisposable and no browser handle to track.

The Architectural Divide

While both codebases successfully produce the exact same pixels, they do so by leveraging entirely different execution topologies.

Architectural Blueprint: PuppeteerSharp vs. IronPDF

Understanding this fundamental boundary line makes it much easier to predict how each library behaves when your workload scales up.

What Each One Gives You at 10,000 Reports a Night

The dashboards are identical because the engine is identical. The delta is operational, and it sharpens with concurrency.

The PuppeteerSharp path puts the Chromium lifecycle in your hands. You ship the ~150 MB binary that BrowserFetcher pins into every image, Google Chrome v115 if you want to mirror that default, budget the 200–500 MB of RAM each browser holds, and decide how to pool and recycle instances from the project directory. Headless Chromium grows its memory footprint over long-running sessions, which is why a restart-as-SLO policy is a community-documented mitigation, and a browser that is not cleanly disposed can leave a zombie process behind. Linux users can also hit Chrome runtime issues in deployment, which is one reason teams harden container images and restart policies carefully. None of this is a defect; it is the cost of operating a browser, and a team already running browser automation absorbs it as a fixed expense.

IronPDF folds that lifecycle into the library. The Chromium engine is managed in-process, so there is no fetcher step in your deploy, no pool to orchestrate, and no separate process to watch. On top of that sits a PDF-purpose API: page headers and footers, page numbering, PDF/A archival output via SaveAsPdfA, and merging the per-tenant reports into one nightly bundle with PdfDocument.Merge, document operations you would otherwise script around a browser. It runs on .NET 10, 9, 8, and .NET Framework 4.6.2+ across Windows, Linux, macOS, Docker, and the major clouds, and carries commercial support for the times a render misbehaves in production. IronPDF is the more capable option here, not the cheaper one.

At one report a day, neither distinction matters. At ten thousand a night across hundreds of tenants, the difference is the infrastructure you operate, not the pixels you produce. A team that already lives in browser automation may want PuppeteerSharp's full surface for task automation too, including logging in to websites, submitting forms, and exporting scraped data to CSV files; a team that wants documents out and a smaller operational surface will lean the other way. Run both renderers against your own dashboard, watch what each does to your container size, your memory ceiling, and your on-call rotation, and let the numbers your scale produces make the call.

Simplify Your .NET PDF Workflows

Test IronPDF's in-process engine against your own dashboards. No complex browser lifecycles, no zombie processes, just clean, reliable HTML to PDF conversion.