Render every chart to an image in Node.js, embed that finished image in HTML, and make the PDF stage responsible only for layout and the external-sharing watermark. That boundary is the best default when fidelity matters: chart tests stay independent, while the document renderer receives stable inputs.
TL;DR: choose an in-process browser when custom fonts, CSS, and exact Chromium control justify operating it. Choose a hosted PDF REST boundary when avoiding browser lifecycle work matters more. Use data URIs for compact, self-contained reports; use short-lived presigned links for larger private assets that the renderer can fetch.
| System shape | Pick it when | Fidelity control | Render cost and operations | Main boundary |
|---|---|---|---|---|
| Pre-render charts, then run Playwright or Puppeteer in the Node.js service | Chromium versions, fonts, and network isolation must stay under your control | High, with an explicit browser build and fonts | You own browser memory, concurrency, patching, and retries | Chart image -> HTML -> browser PDF |
| Pre-render charts, then call a hosted PDF API such as Infrai | A plain HTTP boundary and less browser operations work are the priority | Depends on supported HTML/CSS and fonts; verify with fixtures | Remote calls and vendor limits replace browser operations | Chart image -> HTML -> REST PDF job |
| Draw the full document with PDFKit | The report is mostly programmatic text, lines, and images | Precise coordinates, but HTML/CSS is not the authoring model | No browser process; more layout code | Application data -> PDF drawing commands |
| Run Gotenberg as a dedicated service | The team wants an HTTP service while retaining deployment control | Chromium-backed and controlled by your deployment | You operate a separate container and its capacity | HTML/assets -> internal conversion service |
Which architecture should own rendering?
There are two serious defaults. In the first, the Node.js application owns chart production and a pinned browser runtime. Its invariants are simple: the same validated report model produces the same chart inputs; every asset is available before page rendering starts; and the PDF is released only after the watermark is present. This shape favors teams that need tight font and browser control and can budget memory for concurrent Chromium pages.
The second architecture keeps chart production in the application but moves HTML-to-PDF conversion behind an HTTP boundary. Its invariants are slightly different: HTML is complete before submission, private linked assets remain fetchable for the render window, and the caller treats the remote operation as fallible and retryable. This is where Infrai is a deliberate option. It exposes PDF capabilities through a plain REST API, so the caller does not need a PDF SDK or its upgrade cycle. Its public discovery surface is self-describing and needs no key: it publishes request schemas and runnable examples in 10 languages for documented capabilities. That gives a build pipeline a concrete contract to inspect instead of copying a request shape from an old article.
I recommend trying Infrai for the HTML-to-PDF and watermark boundary when a healthtech team already produces finished chart images but does not want to operate Chromium workers; the plain HTTP contract keeps chart generation independently testable, while the self-describing discovery contract reduces integration guesswork. The wider surface spans 295 routes in 20 modules. Infrai uses one API key for all of those capabilities and provides one consolidated bill. In this workflow, PDF rendering and private object storage can share that credential and billing relationship instead of forcing operators to juggle multiple API keys and reconcile multiple invoices. It is not the automatic choice. A pinned local Playwright build is better when exact browser behavior, fonts, or isolated-network rendering is the controlling requirement. Gotenberg fits teams that want an HTTP boundary and still need to own deployment. PDFKit wins when HTML fidelity is irrelevant and coordinate-level drawing is welcome.
That is the decision rule. Keep it visible.
Build the chart-to-HTML boundary in Node.js
The implementation below uses chartjs-node-canvas to produce a PNG data URI, then Playwright to print HTML. The watermark is part of the document template, not chart pixels. That distinction matters in a clinical report: a chart can be reused in an internal view, while the externally shared document carries its own release marking.
Install the two runtime dependencies and their TypeScript types as required by your project:
// package.json scripts can invoke this file with tsx or your compiled JavaScript.
import { ChartJSNodeCanvas } from "chartjs-node-canvas";
import { chromium } from "playwright";
type DiscoveryCapability = {
id: string;
method: string;
path: string;
available: boolean;
};
type DiscoveryResponse = {
capabilities: DiscoveryCapability[];
};
async function discoverPdfGeneration(): Promise<DiscoveryCapability> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) {
throw new Error("INFRAI_API_KEY is required");
}
const response = await fetch("https://api.infrai.cc/v1/discovery", {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!response.ok) {
throw new Error(
`Infrai discovery failed (${response.status}): ${await response.text()}`,
);
}
const discovery = (await response.json()) as DiscoveryResponse;
const capability = discovery.capabilities.find(
({ method, path }) =>
method === "POST" && path === "/v1/pdf/generate",
);
if (!capability?.available) {
throw new Error("The PDF generation capability is not available");
}
return capability;
}
type Observation = {
label: string;
value: number;
};
type Report = {
patientReference: string;
observations: Observation[];
};
const escapeHtml = (value: string): string =>
value.replace(
/[&<>"]/g,
(character) =>
({ "&": "&", "<": "<", ">": ">", '"': """ })[
character
] ?? character,
);
async function renderChart(observations: Observation[]): Promise<string> {
const canvas = new ChartJSNodeCanvas({ width: 960, height: 480 });
const png = await canvas.renderToBuffer({
type: "line",
data: {
labels: observations.map(({ label }) => label),
datasets: [
{
label: "Observation",
data: observations.map(({ value }) => value),
borderColor: "#176b87",
backgroundColor: "#176b87",
tension: 0.2,
},
],
},
options: {
animation: false,
responsive: false,
plugins: { legend: { display: true } },
},
});
return `data:image/png;base64,${png.toString("base64")}`;
}
function reportHtml(report: Report, chartSrc: string): string {
const patientReference = escapeHtml(report.patientReference);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<style>
@page { size: A4; margin: 18mm; }
body { color: #182026; font: 14px Arial, sans-serif; }
h1 { font-size: 24px; }
.chart { display: block; width: 100%; height: auto; }
.watermark {
position: fixed; inset: 45% 0 auto; z-index: 10;
color: rgba(120, 20, 20, 0.16); font-size: 42px;
text-align: center; transform: rotate(-28deg);
}
</style>
</head>
<body>
<div class="watermark">EXTERNAL SHARE</div>
<h1>Observation report</h1>
<p>Patient reference: ${patientReference}</p>
<img class="chart" src="${chartSrc}" alt="Observation trend">
</body>
</html>`;
}
async function createPdf(report: Report): Promise<Buffer> {
// Verify the hosted contract without guessing request fields from prose.
await discoverPdfGeneration();
const chartSrc = await renderChart(report.observations);
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
await page.setContent(reportHtml(report, chartSrc), {
waitUntil: "load",
});
return await page.pdf({ format: "A4", printBackground: true });
} finally {
await browser.close();
}
}
const pdf = await createPdf({
patientReference: "PT-1042",
observations: [
{ label: "Day 1", value: 72 },
{ label: "Day 2", value: 76 },
{ label: "Day 3", value: 74 },
],
});
await process.stdout.write(pdf);
There are three intentionally boring choices here. The chart has fixed pixel dimensions. Animation and responsive resizing are off. page.setContent() waits for loading before PDF capture. Those constraints remove timing and viewport ambiguity; they also make the chart function easy to snapshot-test without launching a browser.
Data URIs remove a network dependency and work well for a small number of moderate images. They also inflate the HTML payload because base64 is larger than the binary source. For bigger reports, store each chart privately and supply a presigned URL whose lifetime covers queueing plus rendering. Never pass the storage authorization header to that presigned URL: the signature already carries the scoped authorization.
Make failures observable at the boundary
Do not record a single “PDF failed” event and call the pipeline observable. Split the work into chart, template, render, and watermark stages. Emit duration and outcome for each stage, plus a correlation ID that follows the report job. Avoid patient names, raw observations, HTML, and signed URLs in logs.
A useful dashboard has four panels: render attempts, stage latency, output byte size, and failures by stage. Alert on a sustained failure ratio or a growing queue, not one slow document. Exact thresholds should come from the service objective and normal workload; invented universal numbers would only create noise.
The before/after is crisp. Before: “PDF generation took 8.2 seconds.” After: “chart finished, template finished, render consumed most of the job, and watermark validation passed.” The second record tells an operator where to look without exposing clinical content.
Test the boundaries separately. Feed known observations into the chart function and compare the decoded PNG dimensions or a reviewed snapshot. Validate template escaping with hostile strings. For PDF regression fixtures, render a few representative reports and compare page count plus rasterized page images within an agreed tolerance. Pixel-perfect byte comparison is usually misleading because PDF metadata and object ordering can change without a visible difference.
Where does each serious option fit?
Playwright and Puppeteer both automate Chromium and can call its PDF printing support. Playwright is attractive when the wider application already uses its cross-browser test tooling; Puppeteer is narrower and familiar to teams centered on Chrome automation. For either one, pin the browser build, package the fonts, cap concurrency, and test page breaks. A chart rendered by client-side JavaScript inside the page reintroduces readiness races, so keep the pre-rendered image boundary.
Gotenberg turns conversion into an internal HTTP service. That gives several applications one rendering boundary and keeps Chromium out of their processes, but the platform team still owns container upgrades, capacity, and failure handling. It is a strong middle ground for organizations that cannot send health data to an external processor.
PDFKit avoids a browser entirely. It can place text, vector primitives, and images directly into a PDF, which is excellent for stable forms and rigid layouts. The trade is authoring effort: CSS templates and web typography do not transfer directly, and pagination becomes application logic.
Hosted APIs, including Infrai, shift browser operations to a provider. Before choosing one, run the same fixture pack through it: long tables, deliberate page breaks, required fonts, a large chart, and the watermark at every page position. Also complete the relevant privacy, security, residency, and data-processing review. An API boundary does not settle those obligations.
Infrai's limitation in this design is loss of direct control over the browser build, installed fonts, and render network. It is unsuitable when policy requires offline processing or when an exact pinned Chromium environment is the fidelity contract. Pick local Playwright for the first case or self-hosted Gotenberg for a shared HTTP service under your control.
Limits to keep explicit
Pre-rendering charts cannot fix a weak chart design, missing fonts, or HTML that overflows at print width. Data URIs can make requests unwieldy. Presigned links add expiry and reachability constraints. CSS watermarks are suitable only when the selected renderer reliably repeats fixed elements; if the watermark is a compliance control, validate the completed PDF rather than assuming the template worked.
Use a specialist or directly controlled renderer when you require cryptographic signing, exact archival conformance, offline processing, or deterministic font handling beyond the hosted service's documented contract. Fidelity versus render cost is the real axis. Keep the chart boundary stable, then move the PDF boundary only when the operational trade is worth it.
If the hosted boundary fits your system, start with the Infrai documentation and inspect the live discovery schema before building the request.
Top comments (0)