Skip to content

HTML to PDF JavaScript: html2pdf.js, jsPDF, Puppeteer, or an API

The browser can make a PDF and so can your server, and they produce very different files. Where html2pdf.js is genuinely fine, where it quietly ships a screenshot, and when the job moves to Node.

JavaScript can turn HTML into a PDF in two very different places, and picking the wrong one is the root of most bad PDFs on the internet. In the browser, html2pdf.js and jsPDF build the file on the user's machine: free, private, and fundamentally limited, because the popular path produces a picture of your page rather than a real document. On the server, Puppeteer or a rendering API print through actual Chromium: selectable text, correct pagination, consistent output, and some infrastructure to own. This post covers both paths with code, and the line where a job stops belonging in the browser.

Converting in the browser: html2pdf.js

html2pdf.js is the library most people find first, and for good reason: it is one script tag and one call.

html2pdf()
    .set({ margin: 10, filename: "report.pdf", jsPDF: { format: "a4" } })
    .from(document.getElementById("report"))
    .save();

Understand what it actually does, though. html2pdf.js runs html2canvas to paint your DOM onto a canvas, then hands that canvas to jsPDF as an image. The PDF you get is a stack of pictures. Nothing in it is selectable, searchable or accessible; a screen reader finds nothing, Ctrl+F finds nothing, and a printer gets a bitmap. File sizes balloon on long pages, and page breaks land wherever the image happens to be sliced, sometimes through the middle of a table row.

None of that makes it a bad library. For a user clicking "download this dashboard as a PDF" where the artifact is a visual keepsake, it is entirely fine, and no data ever leaves the browser, which is a real privacy property. Our own free in-browser converter exists for exactly that class of job. The mistake is shipping invoices or statements this way and discovering months later that your finance documents are unsearchable screenshots.

Why does my html2pdf.js output look blurry?

Because it is an image, rendered at your screen's pixel density and then scaled to paper. The standard fix is raising the html2canvas scale factor:

html2pdf().set({
    html2canvas: { scale: 2 },
}).from(element).save();

Scale 2 or 3 sharpens the output and multiplies the file size accordingly. It treats the symptom. The document is still a picture; it is just a larger picture. When sharpness matters because the document matters, that is usually the signal the job has outgrown the browser.

jsPDF on its own, and when the print dialog is honestly enough

jsPDF without html2canvas is a drawing API: you place text and shapes at coordinates, and it produces small, clean, real PDFs with selectable text. That is great for simple receipts and labels, and hopeless as an HTML converter, because you are re-describing your layout by hand instead of rendering it. Its .html() method loops back through html2canvas, which returns you to the picture problem above.

And do not skip the zero dependency option: a window.print() button plus a decent print stylesheet. For pages users occasionally want on paper, print CSS with sensible page break rules beats both libraries at a cost of zero kilobytes. The reason it cannot power a product feature is control: you cannot set the filename, suppress the dialog, or guarantee what any given browser and OS combination produces.

How do I convert HTML to PDF in Node.js?

Puppeteer, driving headless Chromium, is the standard answer, and it is a genuinely different class of output: Chromium's real print engine, so text is selectable, pagination follows print CSS, fonts embed properly, and the file looks identical on every run.

const puppeteer = require("puppeteer");

const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto("https://app.example.com/invoices/8412", {
    waitUntil: "networkidle0",
});
await page.waitForSelector("#invoice-total");
await page.pdf({ path: "invoice-8412.pdf", format: "A4" });
await browser.close();

Two lines carry all the weight. waitUntil: "networkidle0" and waitForSelector hold the render until your data has actually arrived; skip them and every chart on the page becomes an empty rectangle. Playwright does the identical job with a near identical API, and the choice between them is taste.

What the snippet hides is the operating cost. Each Chromium instance idles at 100 to 300 MB, spikes past that on heavy pages, and leaks under sustained load, so production deployments grow a browser pool, a restart policy, a concurrency queue and a Docker font package ritual. We broke the full bill down in the Puppeteer alternative analysis; it is real money disguised as engineering time.

The decision table

Approach Real text Runs where You operate Right for
html2pdf.js No, image based Browser Nothing Visual keepsakes, private one-offs
jsPDF (draw API) Yes Browser Nothing Simple receipts built by hand
Print CSS + window.print() Yes Browser Nothing Occasional user initiated printing
Puppeteer / Playwright Yes Node server A Chromium fleet Volume rendering with ops capacity
Rendering API Yes Any backend Nothing Volume rendering without the fleet

The API version: Chromium output, no Chromium to run

If you want Puppeteer's output quality without adopting a browser fleet as a pet, the last option is one HTTP call from any backend, Node included:

const resp = await fetch("https://api.sitepdf.com/v1/render", {
    method: "POST",
    headers: { Authorization: "Bearer " + process.env.SITEPDF_KEY },
    body: new URLSearchParams({
        url: "https://app.example.com/invoices/8412",
        wait_for: "#invoice-total",
        format: "A4",
        archive: "true",
    }),
});

Same managed Chromium print pipeline, same wait_for discipline, and one thing no library gives you: archive=true stores a timestamped snapshot of the source page next to the PDF, so when someone disputes an invoice in November you can show exactly what the page said in July. The URL to PDF API page documents the endpoint, and how it works walks the request and response end to end.

A boundary worth drawing while you are here: rendering a page to PDF is for when you need the document. If what you are actually after is the information inside the page, prices, listings, structured fields, that is a different tool entirely, a web scraping API that returns the page as clean data instead of a file. Teams occasionally build painful PDF-then-parse pipelines for what was a data problem all along.

So which JavaScript approach do you ship?

  • User clicks "save this view", fidelity is cosmetic: html2pdf.js, with the scale option set, and no apologies.
  • The document is hand drawable and tiny: jsPDF's draw API, which outputs real text at real quality.
  • Users just occasionally print: a print stylesheet. Zero dependencies is a feature.
  • The PDF is a product feature, invoices, reports, statements: server side Chromium, either Puppeteer you operate or an API call. Documents that matter deserve selectable text, deterministic pagination and a record of what was rendered.

The mistake to avoid is not picking a weak library; every tool here is good at its own job. It is generating documents that matter in a place, the user's browser, that cannot produce a real document, and finding out after they have been filed somewhere important.

Written by the team building Sitepdf, an HTML to PDF API that archives every page it renders. The in-browser converter is free to try; early access locks the launch pricing.

§ Early access

Get on the early-access list

The API opens to the list first, in order. Early access locks the planned launch rates for 12 months. No card required, launching soon.

Render + archive, one API