JavaScript PDF rendering depends on environment: browser uses PDF.js (the library powering Firefox's built-in PDF viewer), Node uses pdf-img-convert or pdf-poppler (both wrap Poppler). All three approaches end up calling the same underlying renderers — pick based on where your code runs.
Method 1: PDF.js (browser, no server)
PDF.js renders PDFs to Canvas in pure JavaScript. It's the same library Firefox ships. Best when conversion happens in the browser (privacy, no upload).
<script type="module">
import * as pdfjsLib from "https://cdn.jsdelivr.net/npm/pdfjs-dist@4/build/pdf.mjs";
pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdn.jsdelivr.net/npm/pdfjs-dist@4/build/pdf.worker.mjs";
</script>
async function pdfToJpgPages(file, dpi = 200) {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
const scale = dpi / 72; // 72 = native PDF DPI
const blobs = [];
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const viewport = page.getViewport({ scale });
const canvas = document.createElement("canvas");
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext("2d");
await page.render({ canvasContext: ctx, viewport }).promise;
const blob = await new Promise((res) =>
canvas.toBlob(res, "image/jpeg", 0.9)
);
blobs.push(blob);
}
return blobs;
}
// Wire to a file input:
document.querySelector("input[type=file]").addEventListener("change", async (e) => {
const blobs = await pdfToJpgPages(e.target.files[0], 200);
blobs.forEach((blob, i) => {
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement("a"), {
href: url,
download: `page-${String(i + 1).padStart(3, "0")}.jpg`,
});
a.click();
URL.revokeObjectURL(url);
});
});
Three things to know:
- Always set workerSrc. PDF.js spawns a Web Worker for parsing. Without workerSrc set, parsing happens on the main thread and blocks the UI.
- scale = dpi / 72. PDFs are 72 DPI internally. For a 200 DPI render, scale = 200/72 = ~2.78.
- canvas.toBlob is async with a callback. Wrap in a Promise as shown above. quality 0-1 (0.9 is good for text PDFs).
Method 2: pdf-img-convert (Node, Poppler-backed)
For server-side conversion in Node, pdf-img-convert wraps Poppler's pdftoppm. Cleaner API than calling pdftoppm directly via child_process.
npm install pdf-img-convert
apt install poppler-utils # required system dep
import pdfImgConvert from "pdf-img-convert";
import fs from "node:fs/promises";
import path from "node:path";
async function pdfToJpg(pdfPath, outDir, dpi = 200) {
await fs.mkdir(outDir, { recursive: true });
const images = await pdfImgConvert.convert(pdfPath, {
width: undefined, // auto from DPI
height: undefined,
page_numbers: undefined, // all pages
base64: false,
});
const paths = [];
for (let i = 0; i < images.length; i++) {
const outPath = path.join(outDir, `page-${String(i + 1).padStart(3, "0")}.jpg`);
await fs.writeFile(outPath, images[i]);
paths.push(outPath);
}
return paths;
}
await pdfToJpg("document.pdf", "./pages", 200);
For Node services that need to run on Alpine or Lambda where you can't easily install Poppler, use pdf.js-extract or skip to the API.
Method 3: ChangeThisFile API (edge runtimes, no deps)
For Cloudflare Workers, Vercel Edge, or any runtime where you can't load PDF.js as a worker or install Poppler, the API is one fetch call. Free tier covers 1,000 conversions/month.
const API_KEY = "ctf_sk_your_key_here";
async function pdfToJpg(pdfBuffer, filename = "document.pdf") {
const form = new FormData();
form.append("file", new Blob([pdfBuffer], { type: "application/pdf" }), filename);
form.append("source", "pdf");
form.append("target", "jpg");
const response = await fetch("https://changethisfile.com/v1/convert", {
method: "POST",
headers: { Authorization: `Bearer ${API_KEY}` },
body: form,
});
if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
const contentType = response.headers.get("Content-Type") || "";
if (contentType.startsWith("application/zip")) {
// Multi-page: returned as zip
return await response.arrayBuffer();
}
// Single-page: returned as image
return await response.arrayBuffer();
}
// Cloudflare Worker example:
export default {
async fetch(request) {
const pdf = await request.arrayBuffer();
const result = await pdfToJpg(new Uint8Array(pdf));
return new Response(result, {
headers: { "Content-Type": "application/zip" },
});
},
};
The API renders at 150 DPI by default. Pass dpi=300 in form data for higher quality.
When to use each
| Approach | Best for | Tradeoff |
|---|---|---|
| PDF.js (browser) | Privacy-first conversion, no server cost | Big bundle (~1MB), main thread blocking on big PDFs |
| pdf-img-convert (Node) | Server-side batch, full DPI control | Requires Poppler system dep |
| ChangeThisFile API | Edge runtimes, no native deps, multi-tenant SaaS | Network call, file size limit (25MB free) |
Production tips
- For browser conversion of big PDFs, render in a Web Worker. PDF.js parsing already runs in a worker, but rendering happens on the main thread by default. Use OffscreenCanvas in a worker for true off-main-thread rendering.
- Set quality 0.85-0.9 for JPG output. Lower than 0.85 introduces visible artifacts on small text; higher than 0.9 inflates file size with no visible gain.
- Start at scale=2 (144 DPI). Most use cases (web previews, thumbnails) work fine at this resolution. Bump to scale=3 (216 DPI) for OCR or print preview.
- Watch for password-protected PDFs. PDF.js: pass
passwordoption to getDocument(). pdf-img-convert: not supported, fall back to pdf-lib + decrypt or the API. - For multi-page PDFs in browser, stream zips with JSZip. Don't hold all blobs in memory — stream into a JSZip and download as one .zip.
Pick the method that matches your runtime: PDF.js in the browser, pdf-img-convert in Node, the API on the edge or in multi-tenant services. Free tier covers 1,000 conversions/month.