Get started with 33% off your first certification using code: 33OFFNEW

How to Test an HTML to PDF API Before You Commit

8 min read
Published on 23rd August 2026

Picking an HTML to PDF API from feature pages does not work, because every vendor's feature page says the same thing: pixel-perfect rendering, blazing speed, simple integration. The differences that will actually hurt you in production, a table header that vanishes on page two, a font that silently falls back, a document that gets deleted after 24 hours, never appear in the marketing. They only appear when you test for them.

This article gives you a repeatable way to evaluate any PDF API during its free trial: what to ask before you write a line of code, the torture test document to build, the checks to run on the output, and the pricing maths to do at your real volume. Budget an afternoon. It is cheaper than migrating six months in.

Ask which engine it runs before anything else

Every HTML to PDF service is a wrapper around a rendering engine, and the engine determines its behaviour more than anything the vendor built on top. There are only four families that matter in 2026.

Chromium-based services render your document exactly as Chrome does, which means modern CSS, JavaScript execution, and screen-accurate layout. Prince, the commercial engine behind the highest-end services, is the strongest print typographer available, with footnotes, cross-references and accessible tagged output, but it is not a browser: it famously does not support CSS Grid, and its JavaScript engine is its own. WeasyPrint, the Python favourite, implements CSS Paged Media well but runs no JavaScript at all. And wkhtmltopdf, still wired into a surprising number of services, is an archived project rendering with a WebKit from before flexbox settled down, so treat any service leading with it as a legacy option.

If the vendor's documentation does not say which engine it runs, that is itself an answer. Ask support directly, and ask a second question while you are there: which version? A Chromium service pinned to a three-year-old build behaves very differently from one tracking current Chrome, and you can verify their answer yourself once you have an output file. More on that trick below.

Build a torture test, not a hello world

The sample code in every vendor's docs renders a heading and a paragraph. Your production documents contain the things that break engines, so your trial document should too. Build one HTML file containing all of the following and reuse it against every service you evaluate:

<style>
  /* A webfont: must be fetched, rendered and embedded */
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap');

  /* Custom properties: legacy engines resolve none of this */
  :root { --brand: #B23A48; }

  /* Flexbox and Grid in the same document */
  header { display: flex; justify-content: space-between; }
  .summary { display: grid; grid-template-columns: repeat(3, 1fr); }

  /* Fragmentation rules for the page boundary */
  tr, .keep-together { break-inside: avoid; }

  /* Page numbers straight from CSS Paged Media */
  @page {
    size: A4;
    margin: 18mm 16mm 22mm;
    @bottom-right { content: "Page " counter(page) " of " counter(pages); }
  }
</style>

<script>
  /* A value only present if the engine executes JavaScript */
  document.getElementById('total').textContent = '4,852.20';
</script>

Then give the body enough content to cross a page boundary: a table with 30 or more rows, a block marked keep-together positioned so it straddles the break, and one image. A document that fits on one page tests almost nothing, because pagination is where PDF engines earn or lose their keep.

The pagination checks that separate engines

Three behaviours at the page boundary tell you most of what you need to know.

First, does a row split across two pages, or does break-inside: avoid push it whole onto the next one? Second, does your <thead> repeat at the top of page two? Browsers repeat table headers when printing, but several API pipelines do not, and a 40-row statement with a headerless second page looks broken to every customer who receives it. If it does not repeat, the vendor should say so and you will be building the repeated header into your template yourself.

Third, the page numbers. For years the rule of thumb was that only the CSS Paged Media engines, Prince and WeasyPrint, honoured @page margin boxes, and Chromium services made you use proprietary header and footer templates instead. Chromium 131 changed that in November 2024 by shipping native support for margin boxes, including counter(page) and counter(pages). So the test is now version-sensitive: the same CSS produces page numbers on a service running current Chromium and produces nothing on one pinned to Chromium 120. Your torture test's @bottom-right rule answers the version question without anyone in support having to look it up.

Verify the output properly, not by eyeballing it

Opening the PDF and nodding is not verification. Three command line tools, all part of the standard poppler-utils package, will tell you what actually came back:

pdfinfo output.pdf     # page count, page size, and the producer string
pdffonts output.pdf    # which fonts are embedded, and whether they are subsets
pdftotext output.pdf - # the real text layer, if there is one

The producer string is the underrated one. A Chromium-based service stamps its output with something like Skia/PDF m131, and that number after the m is the Chromium major version. You have just verified the engine and its version from the artefact itself, whatever the sales page said. Prince and WeasyPrint identify themselves just as clearly.

From pdffonts, you want to see your webfont listed with emb and sub set to yes, meaning it was embedded as a subset rather than substituted. From pdftotext, you want your table contents to come out as selectable text, and you want to search it for the value your JavaScript computed. If the number is in the text layer, the engine ran your script. If a dash is there instead, it did not, and every calculated field in your real documents will fail the same way.

One more check that catches a surprising number of services: file size. If a two-page text document comes back as a 4MB file, the service rasterised your pages into images instead of producing a vector PDF. The text layer test will confirm it, and it should disqualify the service for anything a customer will zoom, search or print.

Test the failure modes during the trial, not in production

Happy-path rendering is table stakes. How a service fails is what you will be debugging at some point, so provoke the failures while it costs you nothing. Send a document referencing a font URL that 404s and see whether you get an error, a silent fallback, or a hung request. Send content wider than the page and find out whether it crops, scales or overflows. Send HTML that takes 45 seconds to settle and learn where the timeout sits and what a timeout response looks like. Send malformed HTML and check the error is a structured JSON body with a reference ID you could quote to support, rather than an HTML error page from a proxy.

While you are there, note the shape of every error. You will be writing retry logic against these responses, and a service that returns clean, documented status codes for validation failures, timeouts and server faults is signalling something about the engineering culture behind it.

Measure latency the way billing day will

Run your torture test 50 times and look at the 95th percentile, not the average. PDF rendering involves a real browser engine spinning up your document, so the tail is where the truth lives, and a service that averages 1.5 seconds with a p95 of 12 will feel broken inside a synchronous checkout flow.

for i in $(seq 1 50); do
  curl -s -o /dev/null -w "%{time_total}\n" \
    -X POST https://api.example.com/render \
    -H "Content-Type: application/json" \
    -d @torture-test.json
done | sort -n | awk '{a[NR]=$1} END {print "p50:", a[int(NR*0.5)], " p95:", a[int(NR*0.95)]}'

Then think about your burst, because document generation is rarely smooth. Invoices go out on the first of the month, statements at quarter end, certificates when a cohort finishes. Check the plan's rate limits and concurrency allowance against that burst, not against your monthly average, and check whether an asynchronous mode with webhooks exists for exactly those runs. A per-minute cap that never troubles you on the 14th can stall your entire billing run on the 1st.

Find out where the bytes live and for how long

Services split into two camps: those that return the PDF bytes and make storage your problem, and those that host the file and return a URL. Neither is wrong, but each carries a question you must answer during the trial.

If you get bytes, you are building the storage layer: an S3 bucket, naming, signed URLs for customers. If you get a URL, read the retention policy twice, because it is where the surprises hide. Some services delete documents after 24 hours on entry-level plans. If your application emails customers a link to their invoice, and that link points at a file the vendor deletes the next day, you have shipped a support ticket generator. Check retention per plan tier, check whether links survive a plan change, and if your documents contain personal data, check where the files are stored and what the deletion story is for a GDPR request.

Do the pricing maths at your real volume, in their units

No two services meter the same way, and the unit of billing changes the bill more than the headline price does. One service charges per document. Another charges per credit, where a credit covers 5MB of output. Another meters per 0.5MB, so a single 2MB report costs four credits. Another bills per megabyte and per second of compute. Run the same calculation for each candidate: your monthly document count, times your typical document size in their units, at your peak month rather than your average one.

Then look for the ladder's missing rungs. Plenty of vendors publish their first two or three tiers and go quiet exactly where volume gets interesting, which means your cost at scale is a negotiation, not a number. That is not automatically disqualifying, but it belongs in the decision, and it is much better discovered during the trial than during the renewal.

A worked example, run for real

If you want to see this whole process applied, I ran a version of it across seven commercial services, using a deliberately awkward two-page invoice as the torture test: webfont, Grid, a 38-row table across the page boundary, @page counters and JavaScript-computed totals, with the output verified through pdfinfo, pdffonts and pdftotext exactly as above. The full write-up, including the scorecard and the pricing at three volumes, is in the best HTML to PDF APIs in 2026, tested against the same invoice. The short version proves the point of this article: the seven services split on almost every check, and none of the splits were visible from their feature pages.

The checklist

Ten questions, answerable in one afternoon of any free trial:

  1. Which engine, and which version? Verify it from the producer string, not the sales page.
  2. Does your webfont come back embedded as a subset?
  3. Do break-inside: avoid blocks survive the page boundary intact?
  4. Does <thead> repeat on page two, and if not, are you prepared to repeat it yourself?
  5. Do @page margin boxes produce your page numbers on their Chromium build?
  6. Is the JavaScript-computed value present in the text layer?
  7. Is the output a vector PDF with selectable text, or rasterised pages?
  8. What do the error responses look like, and where does the timeout sit?
  9. What is the p95 latency, and do the rate limits survive your worst burst day?
  10. Where do the bytes live, for how long, and what does your real volume cost in their billing units?

Any service that passes all ten for your documents is a safe choice, whatever it is called. Any service that fails one is asking you to build the workaround, and now you know the price of it before you have signed up.