DEV Community

Madhushan Sanjeewa
Madhushan Sanjeewa

Posted on

Browser-only image-to-PDF conversion: memory, privacy, and rendering trade-offs

This article examines the design and limitations of a browser-side image-to-PDF implementation, not an independent product benchmark. The code blocks are focused excerpts or adaptations, not a complete converter.

A browser-only image-to-PDF pipeline sounds straightforward: read a file, draw it on a canvas, and embed the result in a PDF.

The harder part is deciding what stays in memory, when resources become obsolete, and what “local processing” actually promises.

This walkthrough covers a TypeScript implementation using browser image APIs, Canvas, and pdf-lib. It does not use an image-upload endpoint or a server-side conversion service. Its export path runs on the browser's main thread; it does not currently use a Web Worker.

The implementation discussed here is used in PixToPaper, an image-to-PDF tool I maintain.

1. Start with resource ownership, not the download button

The processing path is:

Selected File
  -> inspect format and dimensions
  -> decode briefly to create a small thumbnail
  -> keep File, object URLs, and edit metadata
  -> decode again when rendering is needed
  -> render one image into a bounded canvas
  -> encode PNG or JPEG
  -> embed in the in-memory PDF
  -> create a Blob URL for download
Enter fullscreen mode Exit fullscreen mode

The application state retains each selected File, a source object URL, a thumbnail object URL, dimensions, and crop/rotation/flip metadata. It does not retain a full-resolution decoded bitmap for every image.

That is a deliberate trade-off: re-decoding costs work, but keeping all full-resolution decoded images alive can make a multi-image session expensive before export even starts.

A thumbnail is useful for navigation, not evidence of final export quality. The implementation creates thumbnails with a longest edge of 320 pixels and renders the source again for export.

2. A small compressed file can become a large allocation

File size is not a reliable proxy for decoded memory.

For one four-channel, eight-bit-per-channel image surface, the arithmetic is:

6000 x 4000 pixels x 4 bytes = 96,000,000 bytes
                             approximately 91.6 MiB
Enter fullscreen mode Exit fullscreen mode

That is only one surface. Browser decoding, canvases, encoded blobs, PDF-library structures, and final serialization can overlap. This calculation is not a measurement of total process memory.

The implementation checks three different budgets:

Budget Implementation limit What it constrains
Individual input file 50 MiB Compressed file bytes
Source image 40 million pixels Accepted source dimensions
Session 100 images and 250 MiB of input Accepted batch size
Rendered canvas 16 million pixels and 8192 pixels per edge Export render dimensions

These are application limits, not universal safe browser limits. A low-memory device can still fail below them. In particular, a render cap does not prevent the decoder from allocating the larger accepted source image first.

Before decoding, the importer inspects up to the first 1 MiB of the file to identify PNG, JPEG, or WebP dimensions. It also checks the extension and declared MIME type against the detected format. After decoding, it checks the actual dimensions again.

This header check is a guardrail, not a replacement for browser decoding or a complete security audit. Files with unusually large metadata can be rejected even when another application would open them. The error message recommends saving a fresh supported image rather than silently accepting an uncertain input.

3. Bound both the longest edge and total pixel count

A longest-edge limit alone is not enough. An 8192 by 8192 canvas contains more than 67 million pixels.

The sizing function applies three constraints: do not upscale, respect the edge limit, and respect the total pixel budget.

This JavaScript adaptation expects positive, finite image dimensions and a positive, finite maxEdge, supplied by the validated rendering path:

const MAX_RENDER_PIXELS = 16_000_000;
const MAX_RENDER_EDGE = 8192;

function canvasSize(width, height, maxEdge) {
  const scale = Math.min(
    1,
    Math.min(maxEdge, MAX_RENDER_EDGE) / Math.max(width, height),
    Math.sqrt(MAX_RENDER_PIXELS / (width * height)),
  );

  return {
    width: Math.max(1, Math.floor(width * scale)),
    height: Math.max(1, Math.floor(height * scale)),
  };
}
Enter fullscreen mode Exit fullscreen mode

For a 6000 by 4000 image with maxEdge set to 8192, the result is 4898 by 3265. The pixel budget wins even though neither original edge exceeds 8192.

With a 1600-pixel edge cap, the result is 1600 by 1066. A 300 by 200 image remains 300 by 200: selecting a higher-quality export does not invent source detail.

The resulting 16-million-pixel ceiling represents about 61 MiB for one RGBA surface. Again, it is not a ceiling on total application memory.

4. “Lossless” and “original file” are different contracts

In this implementation, the higher-fidelity export encodes the rendered canvas as PNG. The smaller alternatives encode JPEG with different quality values and edge caps:

Export mode Encoding Longest-edge cap JPEG quality argument
Lossless PNG 8192 Not applicable
High JPEG 3200 0.92
Compact JPEG 1600 0.75

Every mode also respects the 16-million-pixel render budget. JPEG quality arguments are encoder inputs, not guarantees of a specific file size or identical results across browsers.

PNG encoding avoids an additional lossy JPEG step. It does not bypass resizing, crop transforms, or the chosen background. The renderer paints white before drawing, so transparent source areas become white in the output. Animated input is handled as a still image, not as a multipage animation export.

The user-facing promise needs to match those facts. “Lossless rendered-image encoding” is more precise than “your original file is preserved unchanged.”

5. Process pages sequentially, but acknowledge retained PDF data

Rendering every selected image with Promise.all can keep several decoded images and canvases alive at once.

The export loop instead renders, encodes, and embeds one image at a time. After encoding, it clears the canvas dimensions before embedding. A finally block clears the canvas again on error paths. The rendering helper closes its decoded image before returning the canvas to the exporter.

The PDF library still retains document data as pages accumulate. Sequential rendering reduces overlapping render resources; it does not make the entire export constant-memory or stream the PDF directly to disk. Saving the document can require additional memory for the final byte array and Blob.

Between pages, the implementation yields with:

await new Promise((resolve) => setTimeout(resolve, 0));
Enter fullscreen mode Exit fullscreen mode

That gives the event loop an opportunity to process other work. It does not move decoding, PNG embedding, or PDF serialization off the main thread. One expensive operation can still cause a noticeable pause.

A Worker could be a separate improvement, but this article does not claim one exists. Browser-side execution and background-thread execution are different architectural decisions.

6. Share page geometry, not every rendering step

Preview and export need to agree on orientation, margins, image placement, and clipping.

A pure layoutPage function computes page dimensions and the image rectangle from the edited image dimensions and PDF settings. Both the canvas preview and PDF exporter consume that layout.

For a zero-margin US Letter landscape page, the page dimensions are 792 by 612 PDF points. A 1200 by 800 image fitted into that page occupies 792 by 528 points, centered vertically. The image's rectangle is the same if the source has 300 by 200 pixels; its available detail is not.

The preview uses top-origin coordinates. PDF drawing uses bottom-origin coordinates, so the exporter converts the vertical position:

const pdfY = layout.pageHeight - layout.imageY - layout.imageHeight;
Enter fullscreen mode Exit fullscreen mode

The exporter also clips drawing to the content rectangle. Otherwise, a “Fill” image that exceeds that rectangle can draw into margins instead of being cropped to the intended printable area.

Sharing geometry does not make the preview a pixel-perfect PDF proof. The preview uses its own render resolution; it does not decode the finished PDF or reproduce its JPEG compression. It is a layout preview. Final quality must be checked in the actual downloaded file.

7. Coalesce previews instead of racing them

Rapid changes can create stale work: page A starts rendering, the user selects page B, and A finishes last.

The preview controller uses two pieces of state:

  • A revision counter that changes for each request.
  • A flag allowing only one active render.

Every request updates the desired page and increments the revision. If a render is already running, the controller returns rather than launching another. When that render finishes, it checks whether its revision is still current before copying pixels onto the visible canvas.

If the revision changed, it discards that result and starts a render for the latest requested state. Closing the dialog also increments the revision and clears the visible preview canvas.

This coalesces intermediate requests. It does not cancel an image decode already in progress. That distinction matters when describing responsiveness and resource use.

8. Treat object URLs as owned resources

An object URL is a handle to a Blob or File, not an upload destination.

The implementation creates URLs for source files, thumbnails, and generated PDFs. It releases them at the corresponding lifecycle boundary:

  • Remove an image: revoke source and thumbnail URLs.
  • Replace a thumbnail after editing: revoke the old thumbnail URL.
  • Change image order, edits, or PDF settings: invalidate and revoke obsolete PDF download URLs.
  • Clear the session: release image resources and invalidate downloads.

The focused image-cleanup excerpt is:

function disposeImage(item) {
  URL.revokeObjectURL(item.sourceUrl);
  URL.revokeObjectURL(item.thumbnailUrl);
}
Enter fullscreen mode Exit fullscreen mode

Do not revoke a download URL immediately after displaying a link the user may click later. Its lifetime should match the availability of that result.

The page lifecycle has another wrinkle: a page entering the browser's back/forward cache may return with its active session. The implementation avoids clearing that session on a persisted pagehide event. This also means “leaving the page” does not always mean its in-memory resources disappear immediately.

9. Lazy-load the expensive path

The PDF module is imported inside the conversion handler:

const { generatePDF } = await import('./pdf');
Enter fullscreen mode Exit fullscreen mode

The editor is also imported on first use rather than on initial navigation. That keeps those paths out of the initial eager dependency graph, subject to the bundler's output and loading behavior.

Lazy loading changes when code is fetched. It does not reduce the eventual cost of conversion, eliminate initialization failures, or prove a particular performance score. The UI still needs a loading state, an error path, and protection against duplicate conversion requests.

10. Define privacy at the file-processing boundary

“Selected images are processed locally” is a testable implementation claim. “Nothing ever leaves your browser” is a much broader statement and may be false for an otherwise browser-only converter.

A page can still request HTML, scripts, fonts, or optional analytics. A responsible review separates ordinary website traffic from transmission of selected file contents, filenames, or generated PDF bytes.

When checking a file-processing workflow, use a non-sensitive sample and inspect the entire sequence: import, edit, preview, generate, download, and clear. Check request URLs, query parameters, bodies, and telemetry payloads rather than looking only for a multipart upload.

This article describes source-level design and reproducible calculations. It is not an exhaustive network-security audit, a measured heap benchmark, or a guarantee that every browser can process every accepted batch.

The takeaway

The PDF download is the end of the pipeline, not the center of the design.

A maintainable browser-side converter needs explicit answers to four questions:

  1. What is retained? Keep file references and edit metadata; decide deliberately whether to retain decoded pixels.
  2. What is bounded? Input bytes, source dimensions, render dimensions, and batch size are separate budgets.
  3. What becomes obsolete? Preview results, thumbnails, and downloads need defined invalidation and cleanup rules.
  4. What is promised? Local processing, lossless encoding, responsive UI, and memory safety are different claims.

Those distinctions make the design easier to explain and make failures easier to diagnose without hiding the trade-offs.

References

Top comments (1)

Collapse
 
locitra profile image
Sunil Kumar Uikey •

The privacy angle is especially important here. For tools handling work documents or other sensitive files, processing locally can remove an entire layer of upload and server-storage risk.

I also like that you’re discussing the trade-offs rather than presenting browser-only processing as universally better. Memory usage and rendering reliability are important considerations when deciding between client-side and server-side processing.