Posts / Engineering in practice
Why I use a fresh PDF renderer for every sheet
A Go print tool, shorter renderer lifetimes, ordered workers, and the tradeoffs that remain when the final output is one PDF.
The output of a print job is one PDF. That does not mean I need one PDF renderer to stay alive for the whole job.
I built a Go tool that places ticket images onto printable sheets. A useful change in that tool was quite small: instead of reusing a renderer across every sheet in a worker’s chunk, I started a fresh renderer for each sheet and wrote the result to a temporary file.
There is more to the job than drawing an image
The tool takes a collection of images and turns them into imposed print sheets. It needs predictable ordering, a grid that fits the paper, margins, and cut marks. A preview mode helps check the arrangement before generating the full output.
Ordering matters here. A filename sort that puts “10” before “2” would be a bad starting point for numbered tickets. The tool uses natural filename ordering, calculates the sheet arrangement, and divides the resulting sheets into contiguous chunks for workers.
Memory pressure made me look at the lifetime of the rendering object. The earlier implementation created one gofpdf.Fpdf for an entire worker chunk. Each additional sheet became another page in that same document. The renderer’s lifetime was tied to the amount of work assigned to the worker.
THE IMPLEMENTED PROCESS / SIMPLIFIED
- 01 / PLANOrdered images → sheets
Work out placement and split contiguous sheet ranges between workers.
- 02 / RENDEROne renderer per sheet
Write temporary one-page PDFs, then merge the sheets for each worker.
- 03 / ASSEMBLEChunks in their original order
Collect worker results by chunk index and merge them into the final PDF.
Finish the sheet, then start another document
The revised implementation creates a new renderer for a single sheet, draws the images and print marks, and writes a one-page PDF. The next sheet starts from a new renderer. After the worker has produced its sheets, a separate PDF merge assembles its chunk.
That gives the generation stage a smaller unit of work. A renderer no longer needs to represent all the pages in the chunk at once. The completed sheet has a concrete handoff: a file path the next stage can consume.
SIMPLIFIED RENDERING EXAMPLE
function renderChunk(sheets, temporaryDirectory):
sheetPaths = []
for sheet in sheets:
renderer = newPdfDocument()
drawSheet(renderer, sheet)
path = uniqueTemporaryPath(temporaryDirectory)
writePdf(renderer, path)
sheetPaths.append(path)
return mergeSheetFiles(sheetPaths)The important change is where newPdfDocument() sits. The sketch is not the original application code, and it omits error propagation and naming details. It also does not mean the Go process returns memory to the operating system immediately after each iteration. Object lifetime, garbage collection, library allocations, and process memory are related but different things.
Parallel completion is not page order
The worker pool gives multiple chunks a chance to render concurrently. One chunk can finish before another even when it belongs later in the document. I collect the results by chunk index so that completion timing does not decide the final page order.
This is easy to overlook when every worker returns the same type of value: a path to a valid PDF. A valid file can still be in the wrong position. The work needs an identity that survives the trip through the worker pool.
The worker count is configurable and checked against available CPUs. That controls how many rendering tasks run at once. It does not make each chunk a fixed size: as the input grows, the number of sheets in a worker’s chunk can grow too.
Temporary files are part of the design
Moving completed sheets out of the renderer creates more files and more disk I/O. That is a tradeoff, not free memory management. The job needs enough temporary storage, unique filenames, useful errors when a write fails, and cleanup that also runs when later work fails.
The tool uses temporary directories with deferred cleanup. I treat those directories as job resources rather than incidental files. If a job is interrupted or a process terminates unexpectedly, ordinary deferred cleanup is not a universal recovery mechanism; operational cleanup still needs consideration.
Keeping the stages separate also makes failures easier to locate. There is a difference between failing to render a sheet, failing to merge a worker’s sheets, and failing to assemble the final document. I want the error to preserve that distinction.
A smaller renderer lifetime is not a bound on the whole pipeline
The implementation merges sheet files into chunk files, then merges the chunk files into the final PDF. Those are direct pdfcpu merge calls. The current implementation does not use a recursively bounded merge tree.
That matters because the merge stage has its own resource requirements. Rendering one sheet at a time does not prove that the final merge is constant-memory. I do not have a measured peak-memory comparison or throughput benchmark to attach to this change.
What I can show is the implementation boundary: a fresh renderer per sheet, temporary intermediates, a limited number of workers, and ordered assembly. If I were evaluating the next improvement, I would measure rendering and merging separately with representative input sizes, and record temporary-storage use as well as memory.
The habit I took from this work is to follow a resource through the whole pipeline. Finishing one stage in a smaller unit is useful. The next stage still deserves its own limits and measurements.
References
Library references for the rendering and merge operations discussed here.