The economics of PDF file size
Spec: ISO 32000-2, §7.5.7ISO 32000-2 §7.5.7
At a glance
Section titled “At a glance”Two PDFs can look pixel-for-pixel identical on screen and differ tenfold on disk. The difference is almost never the content you see; it is how the file was assembled underneath. This page is a size-economics tour: where a PDF’s bytes actually go, and the four levers an author spends a byte budget on.
It is the why files are big companion to Streams and filters, which covers how a filter decodes. This one stays on the budget.
Why this matters
Section titled “Why this matters”File size is rarely a vanity metric. It is bandwidth on every download, storage on every archive, and latency on every preview. A 12 MB invoice that should have been 400 KB is not a cosmetic problem when you generate a million of them a month — it is a thirtyfold bill.
The frustrating part is that bloat is usually invisible. The document renders correctly, opens fine, prints fine. Nothing tells you that the same artifact could have been a fraction of the size, because the wasted bytes are structural, not visual. To find them you have to look at the budget, not the page.
The short version
Section titled “The short version”Think of a PDF as a budget you spend on four line items.
- Per-object overhead. Every indirect object carries an
N G obj/endobjenvelope, and is tracked by one cross-reference entry. A page-heavy document has thousands of tiny objects, and the envelopes add up. Object streams drop that envelope for a whole group of objects; each packed object still keeps its own cross-reference entry, but as a compact type-2 entry. - Image bytes. For any document with photographs or scans, the images dominate, and the single biggest lever is the filter choice — a lossless codec versus a lossy image codec is the difference between megabytes and kilobytes.
- Font bytes. A full embedded font is hundreds of kilobytes of glyphs you never use. Subsetting keeps only the glyphs the document actually draws.
- The index. The cross-reference table that lets a reader find every object can itself be a compressed stream rather than plaintext.
Get all four right and the file is small. Miss one and it dominates everything else you did well.
How NextPDF approaches it
Section titled “How NextPDF approaches it”NextPDF’s writer is a single-pass streaming serializer: it appends each object’s bytes as they are produced and records a classic in-use cross-reference entry for each. That default is fast, predictable, and produces a byte-stable file.
Lever 1 — object streams (ObjStm)
Section titled “Lever 1 — object streams (ObjStm)”A PDF is a graph of indirect objects. Most of them are small dictionaries: page
nodes, annotation dictionaries, structure-tree elements, outline entries. Each
one pays a fixed tax — the obj / endobj keywords, the object and generation
numbers, and a cross-reference entry that locates it. On a document with
thousands of small objects, that tax is a meaningful slice of the file.
An object stream collects many of those small non-stream objects into one
stream and compresses them together
(Spec: ISO 32000-2, §7.5.7ISO 32000-2 §7.5.7). The obj / endobj
envelope is dropped for the whole group; the values inside are stored
back-to-back with no per-object keywords, then deflated as a single block —
which also compresses better, because the deduplicating compressor now sees
all those similar dictionaries at once. The cross-reference entry does not
disappear — each packed object still needs one — but it shrinks to a compact
binary type-2 entry in the cross-reference stream (more on that in Lever 2).
In NextPDF this is delivered by ObjectStreamPacker, a self-contained
post-processor that takes a finished cross-reference-stream PDF and rewrites the
eligible objects into a single /Type /ObjStm. The rules it follows come
straight from the standard: an eligible object is a generation-zero, non-stream
object, after excluding the special objects that must remain directly
addressable. §7.5.7 forbids storing a stream object inside an object stream, so
stream objects — content, fonts, images — keep their own entries; and
ObjectStreamPacker additionally declines the document’s own cross-reference
stream object (it is rewritten) and the /Encrypt dictionary, both of which
must stay directly addressable. Everything else generation-zero and non-stream
is packed.
| Edition | Availability |
|---|---|
| Core | Full support in the open-source core via ObjectStreamPacker. It is opt-in: the single-pass writer’s default emits classic in-use entries, so output stays byte-identical unless you enable packing. The packer is deterministic, with its own reproducible golden baseline. |
| Pro | Not in this edition |
| Enterprise | Not in this edition |
Opt-in is a deliberate stance. The default output is byte-stable and matches the existing golden baselines; turning on packing opts into a different, smaller, equally deterministic layout. You choose the trade, and the engine never makes it behind your back.
Lever 2 — the index can be a stream too
Section titled “Lever 2 — the index can be a stream too”Once objects live inside an object stream, the index that points at them changes
shape. A reader finds a packed object through a compressed cross-reference
entry — a type-2 entry that names the object stream and the index within it
(Spec: ISO 32000-2, §7.5.8.3ISO 32000-2 §7.5.8.3). Because the whole
cross-reference is itself a /Type /XRef stream, the index for thousands of
objects is binary-packed and deflated rather than written as plaintext rows.
The map shrinks alongside the territory.
ObjectStreamPacker rebuilds exactly this: it emits the single object stream,
then a rewritten cross-reference stream carrying a compact type-1 entry for each
retained object and a type-2 entry for each packed one, preserving every object
number so existing references stay valid.
Lever 3 — image filter choice
Section titled “Lever 3 — image filter choice”For any document with real images, this lever dwarfs the others. The bytes are the same picture; the codec is the budget. The filter names live in the standard filter set (Spec: ISO 32000-2, §7.4ISO 32000-2 §7.4), and the choice between them is a size decision:
- FlateDecode is lossless. Perfect for line art, screenshots, and anything with flat colour — and ruinous for a photograph, where lossless means every byte of the original.
- DCTDecode is JPEG: lossy, and for photographs the right call by a wide margin, often a tenfold reduction for a quality drop nobody notices.
- JPXDecode is JPEG 2000: wavelet compression with a different quality/size curve, but uneven support and disallowed by some archival profiles.
This is exactly where how a filter decodes matters, and that is the Streams and filters article’s job. The economics point is narrower: a photograph stored lossless is the most common single cause of a needlessly huge PDF, and no amount of object-stream packing will rescue a file whose real weight is one un-JPEG’d scan.
Lever 4 — font subsetting
Section titled “Lever 4 — font subsetting”An embedded font is a program. A full one can be several hundred kilobytes, because it carries every glyph the typeface designer ever drew — thousands of characters across scripts you will never use in this document. Subsetting embeds only the glyphs the document actually draws, turning that program into a small fraction of itself. A one-page letter does not need the whole of a CJK-capable font; it needs the few dozen glyphs it sets. The mechanics of how glyphs are selected and re-indexed are their own subject — see Fonts: the hard part.
- Image bytesFor media-heavy files, the largest line item. The filter choice — lossless Flate vs a lossy image codec like DCT (or JPX in a lossy mode) — is the dominant size lever.
- Font bytesA full embedded font is mostly glyphs you never draw. Subsetting keeps only the used glyphs, often shrinking the program by an order of magnitude.
- Per-object overheadThousands of small dictionaries each pay an obj/endobj envelope plus a cross-reference entry. Object streams (ObjStm) drop the envelope for the whole group; each object keeps a compact type-2 cross-reference entry.
- The indexA compressed cross-reference stream with type-2 entries binary-packs and deflates the map of every object, replacing plaintext index rows.
Practical example
Section titled “Practical example”The most important size decisions are made before the bytes reach the writer, and the one structural lever NextPDF exposes is a single opt-in. Conceptually, the budget reads like this:
- Feed photographs as JPEG so they land under
DCTDecode, not re-encoded losslessly. The biggest win is a choice about the source data, not a writer flag. - Let the writer subset embedded fonts so only drawn glyphs ship.
- For a document with many small objects, opt into object-stream packing, which
routes the finished file through
ObjectStreamPackerto group the small non-stream objects and rewrite the cross-reference stream.
The default path — classic in-use entries, no ObjStm — is the right baseline: deterministic, byte-stable, and easy to verify. Packing is the considered upgrade when object count, not image weight, is what is inflating the file.
Common misconception
Section titled “Common misconception”The trap is reaching for a “compress the PDF” button and expecting it to fix
everything. Compression is not one lever; it is four, and they do not substitute
for each other. Object-stream packing cannot shrink a photograph — that is the
image filter’s job. A perfect JPEG cannot offset a font you forgot to subset.
And none of it helps if the real bloat is a 10 MB scan stored losslessly
because nobody chose DCTDecode for it.
The second misconception is that smaller is always strictly better. It is not. Object streams are incompatible with linearization — the fast-web-view layout that pins absolute object placement so the first page streams in early. And some archival profiles restrict which image filters are even allowed. Size is one axis. It trades against streaming, archival conformance, and reproducibility, and the right point on the curve depends on what the document is for.
Limits and boundaries
Section titled “Limits and boundaries”The four levers are the structural economics of file size.
ObjectStreamPacker is a best-effort optimisation that never risks
correctness. It declines — returning the input unchanged — when the file is not
a cross-reference-stream PDF, when it is encrypted, when it carries a digital
signature (re-laying objects would shift the byte ranges a signature protects),
when it already contains object streams, or when there is no eligible object to
pack. It emits exactly one object stream for the whole document; it does not
split into an /Extends collection, which is scoped out and conformant for the
document sizes NextPDF produces.
The image and font levers are largely decisions about the input. NextPDF’s
writer does not implicitly transcode a lossless bitmap into a lossy image
stream — re-encoding a photograph to DCTDecode or JPXDecode is an explicit
upstream image-encoding decision, not something the serializer does behind your
back — nor does it reclaim glyphs from a font the caller asked to embed in full.
The biggest size wins are made upstream of the byte serializer; the engine’s job
is to not waste the budget you bring it.
Related docs
Section titled “Related docs”- Streams and filters — the how a filter decodes companion; this page deliberately complements it, not duplicates it.
- What a PDF actually is — the indirect- object model whose per-object overhead the object-stream lever reduces.
- The anatomy of a PDF file — the cross-reference structure that becomes a compressed stream.
- Fonts: the hard part — how subsetting selects and re-indexes the glyphs a document draws.
Glossary
Section titled “Glossary”- Object stream (ObjStm) — a single stream that holds many small non-stream
indirect objects, compressed together, so the
obj/endobjenvelope is dropped for the group. Each packed object still keeps its own cross-reference entry, as a compact type-2 entry. Opt-in in NextPDF. - Indirect object — a numbered object in a PDF’s graph, wrapped in an
obj/endobjenvelope and tracked by the cross-reference index. The envelope is the per-object overhead. - Cross-reference stream — the
/Type /XRefstream that indexes every object, binary-packed and deflated rather than written as plaintext rows. - Compressed (type-2) entry — a cross-reference entry that points at an object living inside an object stream, naming the stream and the index within it.
- Font subsetting — embedding only the glyphs a document actually draws, rather than the full typeface, shrinking the embedded font program.
- Lossless vs lossy filter — a lossless codec (FlateDecode) reproduces every byte; a lossy image codec (DCTDecode, or JPXDecode in a lossy mode) discards imperceptible detail for a far smaller result. (JPEG 2000 / JPX can also be configured lossless.) The choice is the dominant lever on a media-heavy file.