← dompdf.js Studio

Using dompdf.js in Node.js: A Server-Side Guide

Most frontend PDF libraries only work in the browser, so server-side export requirements drag in headless browsers like Puppeteer, with their heavy memory footprint and complex deployment. dompdf.js is different: its WASM core and pure-frontend architecture make it usable in Node.js without a browser process and without system-level dependencies. The same export module can run in the browser and on the server, with environment detection choosing the right path — one codebase, two runtimes. For server-side rendering applications, this means PDFs can be generated directly in the request handling chain and streamed back to the client, or produced in background batch jobs for contracts, reports, and invoices. Compared with headless browsers, the operational profile is dramatically lighter: no process pool to manage, no zombie processes, no browser-specific limits on concurrency, and rendering that is measured in seconds rather than minutes even for thousand-page documents. This guide covers the full server-side story: why dompdf.js makes sense in Node, how to design an isomorphic export module with environment detection, the specifics of loading wasm and handling workers in Node, a complete Express endpoint implementation, performance and concurrency strategy with caching, a comparison with headless-browser approaches for selection decisions, and the common problems and best practices that keep server-side export stable in production. The code examples use only the library's documented API, and the operational guidance is drawn from running export services in production: what to validate, what to cache, how to scale, and which failure modes actually occur. By the end you will have a concrete template for adding server-side PDF generation to a Node service without adopting a browser fleet.

Why Use dompdf.js in a Node Environment

Traditional server-side PDF approaches such as headless-browser screenshots maintain a pool of browser processes, each consuming hundreds of megabytes of memory, with cold-start latency and periodic restarts to contain leaks. dompdf.js is a pure computation library: the WASM core performs typesetting and encoding directly, with no DOM rendering environment required. Load it in Node, call it, and release it. The resource footprint is an order of magnitude smaller, which makes it an attractive option for export-heavy backend services where machine costs and operational burden actually matter.

Compared with Puppeteer-style pipelines, dompdf.js eliminates an entire category of operational problems: there are no browser processes to crash, no zombie processes to reap, and no browser-level concurrency ceilings. Generation speed follows the same story — WASM producing PDF bytes directly is much faster than screenshotting and re-encoding, shrinking the gap for a thousand-page document from minutes to seconds. For services with high export volume and strict stability requirements, these advantages translate directly into lower infrastructure cost and fewer alerts.

There is also an architectural benefit that teams appreciate after the first integration: the same templates and the same code run in the browser and in Node. Frontend preview uses the browser path; server-side archiving uses the Node path; the difference lives only in the environment adaptation layer, and business logic is written once. Template improvements take effect in both environments simultaneously, and the project maintains a single implementation instead of two divergent ones.

Security posture improves as a side effect. A headless browser executing arbitrary page JavaScript is a substantial attack surface; a pure rendering library that parses HTML and CSS computes nothing executable. Server-side export endpoints still need input validation, but the underlying engine is a much smaller target, which simplifies the review and compliance story for teams that export untrusted content.

Code Example: Environment Detection and an Isomorphic Export Module

Isomorphic export hinges on environment adaptation. A unified export function checks the environment internally and selects the appropriate path, exposing the same interface to every caller. Business code calls the function without knowing whether it runs in a browser or on a server; the isServer check in the example is where that boundary lives, and keeping the boundary inside one module is what keeps the rest of the codebase environment-agnostic.

The server-side endpoint must handle the engineering concerns that browsers absorb for free: request validation with content size limits, timeouts so a pathological document cannot tie up a worker forever, and structured error responses. The example shows the minimal skeleton — validation, a thirty-second timeout, and a JSON error contract — with serial execution inside the process to avoid CPU contention. Production deployments layer on rate limiting and authentication, but the skeleton carries the essential shape.

Error visibility is the interface layer's most important property. Return structured error codes, log duration and failure reasons for every request, and expose export success rate and latency distribution on the monitoring dashboard. Alert separately on requests that exceed the latency threshold, so performance degradation surfaces immediately rather than as a trickle of user complaints. Content validation is the security baseline: HTML arriving at a server endpoint may be untrusted, so bound its size and treat it as data, not code.

One design note: keep the export function's return contract explicit in the module documentation. Callers should not have to guess whether save produces a download, a Blob, or a buffer in a given environment; the wrapper's documentation states the contract per environment, and the response layer adapts. A documented contract is what lets the same module serve a browser button and an HTTP endpoint without confusion.

// pdf-service.js — an isomorphic export module
import { DomPDF } from 'dompdf.js';

const isServer = typeof window === 'undefined';

export async function exportPdf(html, options = {}) {
  const pdf = new DomPDF();
  pdf.addPage(html, { format: options.format || 'A4' });
  // Browser: save triggers the download. Server: the response layer
  // writes the returned output to the HTTP response.
  return pdf.save(options.filename || 'output.pdf');
}

// server.js — an Express export endpoint
import express from 'express';
import { exportPdf } from './pdf-service.js';

const app = express();
app.use(express.json());

app.post('/api/export-pdf', async (req, res) => {
  try {
    const { html } = req.body;
    if (!html || html.length > 500000) {
      return res.status(400).json({ error: 'content missing or too large' });
    }
    const timeout = setTimeout(() => res.status(504).end(), 30000);
    const output = await exportPdf(html, { filename: 'report.pdf' });
    clearTimeout(timeout);
    res.setHeader('Content-Type', 'application/pdf');
    res.setHeader('Content-Disposition', 'attachment; filename="report.pdf"');
    res.send(output);
  } catch (error) {
    res.status(500).json({ error: String(error) });
  }
});

app.listen(3000, () => console.log('export server on :3000'));

Loading WASM and Workers in Node

Node loads wasm differently than the browser: there is no fetch-based loader in the same sense, and the library's ESM/CJS dual format imports cleanly under Node's module system. Loading the library lazily through dynamic import — only when the first export request arrives — keeps server startup fast and memory usage lower, because the wasm module initializes on demand rather than at boot. For services that export rarely, this is a meaningful improvement in startup time and resident memory.

Workers present a genuine divergence: Node's worker_threads and the browser's Web Workers are different mechanisms with different APIs. The browser pipeline in dompdf.js relies on Web Workers, but in Node there is no need to replicate that structure unless parallel export is required. Calling the library directly on the main thread is simpler and, for the vast majority of server workloads, fast enough; worker_threads should be evaluated only after measurement shows a real bottleneck, because thread management adds complexity that most services never need.

Runtime requirements deserve an upfront check. Confirm the Node version satisfies the library's requirements and that wasm support is available — modern Node LTS versions enable it by default. Container deployments add their own considerations: verify the image includes any required system libraries, though a pure-computation wasm module has minimal system dependencies, and container images stay small compared with headless-browser images, which simplifies the deployment pipeline.

Because Node runs long-lived processes, also plan for resource reclamation. If exports run on a shared process alongside web traffic, consider isolating the export path — a dedicated worker thread or a separate service — so a pathological document cannot degrade the whole process. The isolation boundary is cheap to add early and expensive to retrofit, so decide based on the expected export volume and document sizes.

Performance, Concurrency, and Caching

Caching is the first performance lever for server-side export. Identical templates with identical data produce identical PDFs, so cache the output and serve repeated requests from the cache. A content-hash cache key invalidates automatically when the template or data changes, and high-hit-rate scenarios see throughput improve by an order of magnitude. For reports, invoices, and statements whose content repeats heavily, this single mechanism delivers more than any rendering optimization.

On concurrency, prefer serial or lightly parallel execution within a single Node process: WASM computation is CPU-bound, and excessive concurrency makes every job slower as threads contend. Scale horizontally with multiple processes or instances behind a load balancer instead of piling threads into one process, and load-test to find the optimal concurrency per instance, documenting the number in the deployment guide. Capacity planning then has data behind it, and scaling decisions stop being guesses.

Streaming is the advanced optimization: render a very long document in segments and write the response incrementally, so the client's time-to-first-byte drops dramatically. Combined with content chunking and progress reporting, server-side export can offer the same perceived responsiveness as a client-side pipeline. The implementation cost is real, so reserve streaming for systems with high export frequency and very long documents; caching plus serial execution is enough for ordinary workloads.

Finally, measure before and after every change. Record the baseline: per-page render time, memory per export, and cache hit rate. Every optimization — caching, concurrency tuning, streaming — should be justified by a measured delta against that baseline rather than by intuition, because CPU-bound wasm workloads behave counterintuitively under load and only data reveals the truth.

Isolation also matters for graceful degradation: a process that runs exports and web traffic together couples their failure modes, so a memory spike during a large export can degrade request latency for everyone. If export volume is significant, give it its own process or worker thread, and keep the health checks separate, so one workload's problems do not silently become the other's.

Comparing with Headless Browser Solutions

Headless-browser approaches excel at rendering pages whose content depends on heavy client-side JavaScript: the browser executes the full application, and the screenshot reflects what a real user would see. dompdf.js renders HTML and CSS structurally, so it suits documents whose content is present in the markup — templates, reports, invoices — where the extra fidelity of a full browser buys nothing. The selection criterion reduces to one question: does the document require full JavaScript execution to render its content? If yes, a headless browser is justified; if no, dompdf.js wins on cost and speed.

Hybrid architectures are common and sensible. Browser-side export serves interactive preview; the Node-side pipeline handles archival batch jobs; simple documents go through dompdf.js while genuinely dynamic pages fall back to a headless browser. When both paths coexist, unify template management — one HTML template rendered by both environments — so the maintenance burden stays low and template versions do not diverge between paths.

The cost perspective settles most debates. Headless browsers carry persistent process and memory overhead as ongoing cloud spend, and they need scaling infrastructure proportional to peak concurrency. dompdf.js computes on demand and releases resources when the job finishes, so large export volumes cost a fraction as much to operate. Run both approaches against the same documents on the same machine, record time and memory, and let the data pick the architecture — a benchmark in the decision document carries more weight than any opinion.

Operational maturity differs as well. Headless browsers require version pinning, process supervision, and careful timeouts to avoid leaks; a WASM library in a Node process behaves like any other computation, with the standard process lifecycle and observability tooling. Teams that have operated both consistently describe the WASM path as dramatically less eventful, which, for an export feature, is exactly the desired property.

Common Problems and Best Practices

Q: Fonts or images are missing in server-generated PDFs? A: The server has no relative-path resolution like a browser; every resource reference in the snapshot must be an absolute URL or already inlined data. Download fonts to local storage or inline them, use complete image URLs, and verify resource reachability before export. A quick reachability check at request time turns silent missing-resource failures into explicit errors.

Q: The service slows down under concurrent export requests? A: Check whether concurrency exceeds the measured optimum: stabilize throughput with serial execution plus caching first, then raise concurrency only as load-test data supports. Watch CPU and memory curves, and scale instances when approaching the ceiling instead of stacking concurrency inside one process.

Q: How do I keep the browser and server paths from diverging? A: Keep the shared module's interface small and stable, and run the same integration test suite against both environments. A test that exports a fixture document and asserts on page count and text content catches divergence the moment it appears, in CI rather than in production.

Best-practice summary: wrap export in an isomorphic module with environment detection; load the library lazily through dynamic import; validate input, bound timeouts, and return structured errors from endpoints; cache aggressively with content-hash keys; run serial first and scale with measured data; and make every resource reference absolute. Follow these, and server-side PDF export becomes a stable, efficient, observable foundation that the whole platform relies on without surprises.

Operational visibility rounds out the performance story: export metrics — requests per minute, p95 duration, cache hit rate, failure rate by error code — should be on the same dashboard as the rest of the service. A sudden change in any of them is usually the first signal of a template regression, a data anomaly, or an infrastructure issue, and seeing it on a dashboard beats discovering it in a customer report.

⚡ 现场演示(点击生成 PDF)

下面的按钮用 dompdf.js 在浏览器端实时生成 PDF,无需后端:

Hello from dompdf.js!

这是由 dompdf.js 渲染的示例 PDF 内容。