DEV Community

Adrian
Adrian

Posted on • Originally published at adrianani.com

The blueprint: an HTML-to-PDF API on Postgres, Chromium, and not much else

PDF generation in 2026 is glued together from Puppeteer, LiquidJS, and S3. What's not solved by the glue is the production shape around it:

  • Documents range from 50 KB to hundreds of megabytes. Memory can't care.
  • Traffic arrives in bursts (10,000 documents at once). Rendering is CPU-bound.
  • Documents contain names, amounts, addresses. Sensitive by default.
  • For a solo operator, whatever you build has to be runnable by one person.

I built DocPenny around those four constraints in about twelve weeks. This is the blueprint.

The stack

SvelteKit, Hono, pg-boss, PostgreSQL, Chromium, S3-compatible storage. Nothing else. No Redis, no Kafka, no Kubernetes. No connection pooler — I ran one and deleted it.

pg-boss is the load-bearing choice: a job queue on the Postgres you already have. One database for app data and async jobs. One thing to back up, one thing to monitor. The alternative (Redis + BullMQ) adds a second stateful service whose failure modes (persistence config, eviction policies, split-brain) are exactly the kind you debug at 3 AM. For a solo product there is no on-call rotation; there's just you. Every dependency you don't have is one that can't bring the service down.

Memory-bounded streaming

A PDF can be 50 KB or 400 MB. If any stage holds the whole document in memory, RAM scales with the largest thing any customer generates. So no stage does:

Page.printToPDF({ transferMode: 'ReturnAsStream' })
   IO.read(handle, { size: 1MB })
   Node.js Readable (backpressure-aware)
   S3 multipart upload (5 MB parts)
Enter fullscreen mode Exit fullscreen mode

Chrome writes to a stream as it renders. Node pulls 1 MB chunks. If S3 slows down, reads from Chrome slow down — nothing accumulates. S3 starts receiving parts before Chrome finishes rendering the last page. Memory per document is a fixed ~20 MB, regardless of output size.

The default (transferMode omitted) buffers the entire PDF in Chrome's memory, then again in Node's. Works in every demo. Falls over the first time a real customer sends something big.

The queue is the load balancer

Client → API (Hono) → pg-boss queue (Postgres)
                            ↓
              Workers (N replicas, CDP pool)
                            ↓
        Chromium renders → stream → S3 multipart
                            ↓
              Webhook (HMAC-signed) → client
Enter fullscreen mode Exit fullscreen mode

Ingestion: ~5,900 documents/second. Rendering: ~63/second. That 90× gap is the design. The queue absorbs spikes; workers drain at their natural rate. Nothing drops. Never couple accept rate to render rate.

Security from day one

Templates are structure, not data. Sensitive values come as JSON payloads at render time, encrypted with AES-256-GCM before touching storage. Each payload gets a fresh HKDF-derived key from the master secret and a random salt — used in memory, never persisted. No key table to leak.

Webhooks are HMAC-SHA256 signed with a constant-time verification helper. Artifacts are short-lived: previews purge in 24 hours, documents in 4 days. The best breach mitigation is data that no longer exists.

The numbers

One slice of a shared OVH server, ~€45/mo for 9 of 24 threads. k6 saturation test:

  • API ingestion: 8.47 jobs/s ≈ 5,938 documents/s enqueued, 100% success
  • Render throughput: ~63 PDFs/s (CDP pool is the bottleneck, by design)
  • p(95) latency under saturation: 4.7 s

Build or buy?

HTML-to-PDF is a crowded market with healthy competition. The pattern:

Buy when document generation is not your product. If PDFs are an output of your actual business (invoices, reports), the twelve weeks I spent are twelve weeks of your roadmap rebuilding a solved problem, plus permanent ops burden (Chromium upgrades, storage lifecycle, security patching). Managed APIs charge fractions of a cent per document.

Build when volumes make per-doc pricing material, data can't legally leave your infrastructure, or no vendor supports your rendering requirements. Then this blueprint is your starting point. The mistake most teams make: defaulting to the infrastructure they'd deploy at a company ten times their size. Teams don't fail by choosing the wrong queue. They fail by carrying five services when the problem needed two.


Full article with the complete business side, unit economics, build-vs-buy framework, and a note on OSS: adrianani.com/articles/html-to-pdf-api-blueprint

Top comments (0)