Engineering · Performance

46 MB for Three Pages: How We Cut Cereby's PDF Exports by 98%

5 min read

A sparse note, a canvas that was mostly white, and the three settings that inflated it


A user downloaded a three-page note as a PDF, got a file of over 46 MB, and told us. The note was mostly headings and short paragraphs. No images, no unusual fonts: the kind of content that should fit in a few hundred kilobytes.

Cereby's PDF export turned a note into a PDF by taking a picture of it. html2canvas (a library that draws a web page onto a canvas) captured the note's editor, the canvas was encoded as an image, and jsPDF (a library that builds PDFs in the browser) tiled that image across pages. The shape was fine. Three settings were wrong at once.

Every setting at its worst

Here is that note through the old export:

Scale came first. The capture ran at a scale of 2, which doubles the resolution on each axis and quadruples the pixels: an 850 px wide note became a 1700 px wide canvas.

Height came second. The capture took the editor's scrollHeight (an element's full height, scrolled-out parts included), which counts what the editor's CSS adds for comfort: a minimum height of the viewport less 200 px, and 384 px of bottom padding. On a 900 px viewport, that is roughly 700 px plus 384 px before any real content. A sparse three-page note could sit inside a canvas 5,000 to 8,000 px tall, mostly white.

Format came third. PNG is always lossless, whatever quality is asked for. A 1700 by 6000 px canvas is around 40 MB of raw pixels, jsPDF stored them as a raw stream, and DEFLATE (the PDF's lossless compression) was all that worked against that number.

Three levers, pulled together

We kept the capture-then-tile pipeline and changed the three settings that decide the size:

Before:
  html2canvas  →  scale: 2, height: scrollHeight
  toDataURL    →  "image/png"           (lossless, ~40 MB raw)
  addImage     →  "PNG"

After:
  html2canvas  →  scale: 1.5, height: contentHeight (clipped to last child)
  toDataURL    →  "image/jpeg", 0.85    (lossy, 80-90% smaller)
  addImage     →  "JPEG"

The same note, through the new export:

Dropping the scale from 2 to 1.5 cut the pixel count by 44%. JPEG at 0.85 made text on white 80 to 90 percent smaller; it has no transparency, so the opaque white background the capture already set was required. Scale and format were independent levers, both at their maximum, and together they account for nearly all of the 98%.

The height clip took the rest: 4,000 px of white padding below the last paragraph. Instead of trusting scrollHeight, the export measured the bounding box of the editor's last child:

height = bottom of the editor's last child + 48 px
use it only when it is smaller than scrollHeight

Long notes still used their full height. Short sparse notes stopped capturing dead space:

Two ways out, and a format string

A note with subpages left by a different door: each subpage was rendered off-screen, captured, and assembled into one PDF. That path needed the same two changes and one more. The call that added each page still named the format as PNG, and jsPDF reads that string to interpret the data, so "PNG" with JPEG data can corrupt the file in some versions. Drawing canvases stayed PNG, because semi-transparent, hard-edged strokes are where JPEG artifacts show:

In Preview, Adobe Reader and browser PDF viewers, at normal reading zoom, headers, code blocks and tables looked the same as the old 2x PNG.

What this taught us

A canvas's quality argument does nothing for PNG: 0.5 and 1.0 give identical output, silently. Lossy compression from a canvas means asking for JPEG or WebP.

scrollHeight is a number for the interface, not the content. An export wants where the content ends.

When a bug has two doors, close both. Fixing only the export we traced first would have left notes with subpages broken.

All engineering posts