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

ApproachBest forTradeoff
PDF.js (browser)Privacy-first conversion, no server costBig bundle (~1MB), main thread blocking on big PDFs
pdf-img-convert (Node)Server-side batch, full DPI controlRequires Poppler system dep
ChangeThisFile APIEdge runtimes, no native deps, multi-tenant SaaSNetwork 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 password option 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.