How to Embed Images and Logos Reliably in Generated PDFs
Embedding a logo in a generated PDF should be a one-liner. In practice, it's where most PDF pipelines silently break: the image renders locally but vanishes in production, looks pixelated on print, or blocks rendering until a remote server times out. This post covers the failure modes and the patterns that make image embedding boring and predictable.
The four ways to reference an image
When you generate a PDF from HTML (the most common approach today), you have four options for getting an image into the output:
- Remote URL —
<img src="https://cdn.example.com/logo.png"> - Data URI —
<img src="data:image/png;base64,iVBOR..."> - Local file path —
<img src="file:///app/assets/logo.png"> - Pre-uploaded asset — referenced by ID through your PDF service
Each has tradeoffs. Pick based on how your renderer fetches resources and where it runs.
Why remote URLs fail in production
Remote URLs are the default choice and the most common source of bugs. The PDF renderer (headless Chromium, wkhtmltopdf, or a hosted API) has to make an outbound HTTP request during render. That request can fail for reasons that don't affect your app:
- DNS resolution differs inside the renderer's network
- Your CDN blocks the renderer's user agent
- TLS handshake times out under load
- The image is behind auth your renderer can't pass
If you must use remote URLs, set explicit timeouts and wait conditions. With Puppeteer:
tsawait page.setContent(html, { waitUntil: "networkidle0", timeout: 15000, });
networkidle0 waits until there are no network connections for 500ms. Without it, the PDF can snapshot before images finish loading, leaving you with broken-image icons.
Data URIs: reliable but heavy
Embedding the image as base64 directly in the HTML eliminates the network entirely:
tsimport fs from "node:fs/promises"; const logo = await fs.readFile("./assets/logo.png"); const dataUri = `data:image/png;base64,${logo.toString("base64")}`; const html = `<img src="${dataUri}" width="120" alt="Logo">`;
This always works. The downsides:
- Base64 adds ~33% size overhead
- Large images bloat your HTML and slow rendering
- Caching is per-document, not shared
For logos under ~50KB this is the most reliable approach. For photographs or product images, prefer pre-uploaded assets.
Picking the right format
PDFs support raster (PNG, JPEG) and vector (SVG) images, but renderers handle them differently.
- SVG — Best for logos. Scales cleanly at any zoom, prints crisply, tiny file size. Inline the SVG directly in HTML when possible; some renderers handle
<img src="logo.svg">inconsistently. - PNG — Use when you need transparency or pixel-perfect graphics like screenshots.
- JPEG — Use for photographs. Smaller than PNG for continuous-tone images.
- WebP/AVIF — Skip them. Renderer support is uneven, and the size savings don't matter once the document is a PDF.
For SVG logos, inlining beats embedding:
html<div class="header"> <svg width="120" height="32" viewBox="0 0 120 32"> <path d="M10 16 L30 8 L50 16 Z" fill="#1a1a1a"/> </svg> <h1>Invoice #INV-2024-0142</h1> </div>
Sizing for print
A PDF rendered for screen at 96 DPI will look fuzzy when printed at 300 DPI. Always provide images at 2x or 3x their display size:
html<img src="logo.png" width="120" style="width: 120px; height: auto;">
If your logo displays at 120px wide, the source PNG should be at least 360px wide. For SVG this doesn't matter — that's why it's the right choice for logos.
Handling user-uploaded images
If your PDFs include user-provided images (avatars, product photos, signed documents), you need to handle a few extra cases:
- Validate before render. Check Content-Type and size before passing the URL to the renderer. A 50MB image will time out your PDF job.
- Re-host on your own CDN. Don't let users' image hosts dictate your render reliability.
- Fall back gracefully. If an image fails to load, render a placeholder rather than failing the entire document.
A simple pattern with a placeholder:
tsasync function safeImageTag(url: string, fallback: string) { try { const res = await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(3000) }); if (!res.ok) throw new Error("not ok"); return `<img src="${url}" alt="">`; } catch { return `<img src="${fallback}" alt="">`; } }
Using a hosted API
If you're using a service like Kamy, you can upload assets once and reference them by ID, which removes both the bandwidth and the timing problems:
tsimport { kamy } from "@kamydev/sdk"; const asset = await kamy.assets.upload({ file: await fs.readFile("./logo.svg"), name: "company-logo", }); const pdf = await kamy.documents.render({ template: "invoice", data: { logoId: asset.id, invoiceNumber: "INV-001", }, });
Inside your template, the logo is resolved server-side with no outbound HTTP request at render time. Updates to the asset