The report rendered perfectly. The upload failed anyway.
The bug was not in the report
A successful build with an unusable artifact
The job had done everything we asked of it. It queried the database, rendered charts, assembled a 126-page PDF, uploaded the artifact to storage, and marked the run green. Ten minutes later, someone tried to attach the report to a client portal and got a wonderfully unhelpful message: “File exceeds maximum size.”
The PDF was 38.7 MB. The portal allowed 20 MB.
Nothing in our pipeline considered that a failure because we had defined success too narrowly. We tested whether the file existed and whether it could be opened. We did not test whether it could complete the next step in the user’s workflow. The document was technically valid and operationally useless.
That incident changed the way I think about generated PDFs. A PDF is not finished when the renderer exits with code zero. It is finished when the intended destination accepts it and the reader can still use it. File size belongs in the acceptance criteria, just like page count, content checks, and render quality.
Treat size as an interface contract
Most document pipelines have an implicit interface at the end. It might be an email gateway, a grant application, a learning management system, a support ticket, or a customer portal. That interface has constraints even when your report generator does not know about them.
The simplest improvement is to make the size budget explicit. Instead of generating a PDF and hoping for the best, define a delivery profile with a maximum number of bytes and a small safety margin. The margin matters because platforms do not always describe units consistently, and some wrap attachments with additional metadata.
A 20 MB destination should not produce a 19.99 MB target. I would rather set the internal budget to 18 MB and leave room for disagreement about decimal megabytes, binary mebibytes, or a portal’s own processing overhead. This is not glamorous engineering, but neither is reopening a release five minutes before a deadline.
Keep the master separate from the delivery copy
Compression should create a derivative artifact. It should not overwrite the source PDF.
The master is the closest representation of what the renderer produced. It is useful for archival, future exports, and debugging. The delivery copy is optimized for one channel with one size budget. If requirements change, you should be able to create a new delivery copy from the master instead of compressing an already compressed file again.
Repeated compression is especially risky for image-heavy documents. Every generation may introduce another round of downsampling or lossy encoding. The file keeps getting smaller, but the relationship between size and visible quality becomes harder to reason about. A clean master gives the pipeline a stable starting point.
Add a small gate before adding a big system
Measure bytes, not a formatted string
The first version of a size gate can be deliberately boring. In Node.js, the filesystem already gives us the exact byte count. We can compare it with a configured budget and return a structured result that the rest of the pipeline can understand.
import { stat } from "node:fs/promises";
const MiB = 1024 * 1024;
export async function checkPdfBudget(path, budgetMiB) {
const file = await stat(path);
const limitBytes = budgetMiB * MiB;
return {
path,
bytes: file.size,
sizeMiB: Number((file.size / MiB).toFixed(2)),
budgetMiB,
withinBudget: file.size <= limitBytes,
excessBytes: Math.max(0, file.size - limitBytes),
};
}
This function avoids making presentation strings part of the decision. The comparison happens in bytes; sizeMiB exists only for logs and human-readable reports. That distinction prevents a rounded value such as 20.00 MB from hiding the fact that the file is a few kilobytes over the real threshold.
The gate should run immediately after rendering and before distribution. If the file fits, the pipeline can continue. If it does not, the result should enter a compression path or stop with an actionable message. “Generated successfully” is not actionable. “Generated 38.7 MiB; delivery budget is 18 MiB” is.
Make the result visible in CI
A command-line wrapper turns the check into something CI can enforce. The script can print machine-readable JSON and use the process exit code to express the outcome.
#!/usr/bin/env node
import { checkPdfBudget } from "./pdf-budget.js";
const [, , path, budget = "18"] = process.argv;
if (!path) {
console.error("Usage: node check-pdf.js <file.pdf> [budgetMiB]");
process.exit(2);
}
const result = await checkPdfBudget(path, Number(budget));
console.log(JSON.stringify(result, null, 2));
if (!result.withinBudget) {
process.exitCode = 1;
}
Now the document build has a clear boundary. A scheduled report can fail before anyone emails it. A pull request that changes charts or embedded assets can reveal a size regression. A local developer can run the same command that CI runs. That consistency is more valuable than a sophisticated dashboard nobody trusts.
Do not confuse a size regression with a rendering failure
The gate should classify the problem accurately. A PDF that is too large is not corrupt, and a PDF that is corrupt is not merely oversized. Keeping these outcomes separate makes retries and alerts much more useful.
For example, a renderer failure may require inspecting HTML, fonts, or browser logs. A budget failure may indicate that a new image was embedded at full camera resolution, a font subset stopped working, or a chart library started rasterizing at a larger scale. The artifact exists in both cases, but the investigation path is different.
Understand what made the PDF heavy
A PDF is a container, not a screenshot
It is tempting to describe PDF compression as turning down a single quality knob. In reality, a PDF may contain text streams, embedded fonts, vector paths, raster images, forms, metadata, and duplicated resources. Scanned documents are often dominated by page-sized images. A generated financial report may contain searchable text and vector charts but still carry several large logos or screenshots.
That structure affects what compression can achieve. Recompressing images can produce a dramatic reduction in a scanned brochure. The same operation may barely change a mostly textual report. Aggressively rasterizing every page can shrink some files, but it may also remove searchable text and make small type less readable. A smaller byte count is not automatically an optimization.
Before choosing a response, I like to gather a few cheap signals with Poppler tools:
pdfinfo build/report-master.pdf
pdffonts build/report-master.pdf
pdftotext build/report-master.pdf - | tr -d '[:space:]' | wc -m
pdfimages -list build/report-master.pdf | sed -n '1,20p'
pdfinfo gives the page count and basic metadata. pdffonts tells us whether fonts are embedded. pdftotext provides a rough signal for a usable text layer. pdfimages -list helps reveal large raster assets. None of these commands is a perfect classifier by itself, but together they narrow the problem quickly.
Sample the pages that are most likely to fail
A document can be mixed. The first fifty pages may be clean text, followed by a scanned appendix. Checking only the cover creates false confidence. A useful validation set should include a normal text page, a chart-heavy page, a table with small type, a photo-heavy page, and at least one appendix page when those layouts exist.
This is one place where a short manifest helps. The report generator already knows which templates created which sections, so it can emit representative page numbers alongside the PDF. If that is not possible, the team can maintain a small set of known-risk pages in the delivery profile.
The goal is not pixel-perfect visual testing of every page. The goal is to make quality checks intentional. Randomly opening page one after compression is not a test plan; it is a ritual.
Choose the compression path deliberately
Automate the stable path, keep an escape hatch
If your application produces the same document type every day, automated compression can make sense. You can run a known tool with versioned parameters, generate a candidate, validate it, and retain the original if the candidate is larger or fails checks. The compression policy becomes code and can evolve with the template.
Not every team needs that system on day one. Some PDFs are created manually, some failures are rare, and some deadlines arrive before a new service can pass review. In those cases, a browser workflow is a reasonable operational escape hatch, provided the output still goes through the same quality gate.
For an occasional oversized delivery, I use PDFCompress to create the derivative copy. It accepts PDFs directly in the browser, offers high, medium, and low compression modes, supports files up to 1 GB, and can process as many as five files in one batch. The result is available for immediate download without a watermark. That makes it useful when a teammate is on a locked-down machine or even a phone, but it does not remove the need to record the selected mode and validate the result.
The important design decision is not “online tool versus command line.” It is whether the workflow has a source artifact, a named compression policy, and a testable candidate. A manual step can be disciplined. An automated step can still be careless.
Start with the least destructive mode that meets the budget
When compression offers several levels, begin with the option that preserves the most quality. If the output fits the delivery budget, stop. If it does not, move to the next level and compare again. This sounds slower than selecting the strongest setting immediately, but it often avoids unnecessary damage and gives the team evidence about the document’s real compression headroom.
Store the result of each attempt as data, not folklore. Record input bytes, output bytes, selected mode, duration, page count, and validation status. Over time, the pipeline will reveal patterns. Perhaps the marketing report nearly always passes on a medium setting, while scanned field reports need stronger compression. Those patterns can later justify automation.
Reject a candidate that does not improve the artifact
Compression is not guaranteed to help every file. A PDF may already contain optimized images and compressed object streams. Processing it again can produce almost no saving, or occasionally a larger output. The pipeline should compare candidates and refuse to replace the delivery copy when there is no meaningful benefit.
A simple savings calculation is enough:
export function compressionSummary(inputBytes, outputBytes) {
const savedBytes = inputBytes - outputBytes;
const savedPercent = inputBytes === 0
? 0
: (savedBytes / inputBytes) * 100;
return {
inputBytes,
outputBytes,
savedBytes,
savedPercent: Number(savedPercent.toFixed(1)),
improved: outputBytes < inputBytes,
};
}
This protects against a surprisingly common workflow bug: naming the latest output compressed.pdf and assuming that the name proves the result.
Validate the delivery copy, not just the process
Compare invariants before looking at pixels
The first checks are structural. The output should open, retain the expected page count, and remain under the configured byte budget. If the workflow depends on text search or copy and paste, text extraction should still return plausible content. Links, bookmarks, and forms should be tested when they matter to the destination.
These are not universal requirements. A scanned handout may never have had selectable text. A print-only brochure may not need internal links. Validation should reflect the artifact’s actual contract, which is why a delivery profile is more useful than one global “PDF passed” flag.
Render representative pages for visual review
Structural tests cannot tell you whether a chart label turned muddy or a thin grid line disappeared. Render the known-risk pages to images and compare them at a consistent resolution. The first version of this check can be manual; the important part is that reviewers look at the same pages every time.
mkdir -p build/preview
pdftoppm \
-f 8 -l 8 \
-r 144 \
-png \
build/report-delivery.pdf \
build/preview/page
Automated image diffs can come later, but they need sensible thresholds. Compression changes pixels by design, so a strict byte-for-byte image comparison will produce noise. Start by checking legibility, missing content, unexpected rotation, color banding, and obvious blocking around small text.
Test the real destination once
The final integration test is the one we skipped in the opening story: submit the file to the actual destination. Local size checks cannot discover every portal rule. The receiver may impose its own filename restrictions, MIME checks, timeouts, or preview conversion.
For recurring pipelines, this may be a staging endpoint or a documented manual release check rather than an automated production upload. Either way, the destination should appear in the definition of done. A green renderer and a green compression job are still intermediate states.
The gate changes team behavior
Fail early with a useful explanation
Once the size budget became visible in our pipeline, the conversation changed. Instead of discovering the problem at the upload screen, we saw which build introduced the regression. Instead of asking someone to “make it smaller,” we could say how far over budget it was and which pages or assets had changed.
That is the real benefit of a PDF size gate. It turns a last-minute human surprise into an observable property of the artifact. Compression remains a tradeoff, but it becomes a tradeoff the team can measure, review, and repeat.
The implementation can begin with a dozen lines of Node.js and a written validation routine. Add automation only when the document volume and failure rate justify it. Whatever tools you choose, preserve the master, generate a delivery copy, and test the copy against the channel it is meant to cross.
A PDF pipeline should not celebrate merely because it produced a PDF. It should celebrate when the document arrives, opens, and still says what it was meant to say.
Top comments (0)