Beating the 6MB Apex heap limit natively, e-signatures included
The synchronous Apex heap limit of 6MB, and the 12MB asynchronous one, is where high-volume server-side document generation dies the moment binary assets like images enter the picture. The received wisdom is that base64-encoded images or a decompressed DOCX template eat the heap immediately, so you buy external middleware or a paid AppExchange package.
What follows is an open-source, fully native Salesforce architecture that generates complex documents with a lot of high-resolution images and hundreds of child records, inside Apex governor limits.
Where the heap actually goes
Two things burn it when you generate documents with stock Apex techniques:
- Template decompression. DOCX files are ZIP archives. Decompressing the archive and manipulating the internal
document.xmlcosts several megabytes even for a moderately sized template. - Image loading. Holding an image blob in memory, usually as a base64 string, is expensive: a 1MB image costs roughly 1.3MB of heap.
Take ZIP manipulation out of runtime and keep images off the Apex heap entirely, and both constraints stop mattering.
Two shifts in the pipeline
Pre-process the templates, and let the platform resolve asset URLs instead of loading the assets yourself.
1. Pre-decomposed template storage
Rather than decompressing and manipulating a ZIP synchronously while a document is being generated, move that work into the admin setup phase.
When an admin saves a DOCX template version, the system extracts every constituent part right then: document.xml, the relationship files, headers, footers. Each one, embedded image assets included, goes into its own ContentVersion record. At generation time Apex only loads the raw, string-based XML, and the merge work (string substitution, conditional logic) happens entirely on those strings. The estimate for that step alone was a 75% heap reduction.
2. Zero-heap image embedding via Blob.toPdf()
The images get deferred until the final PDF render, which Salesforce runs internally through the Blob.toPdf() method (this needs the Spring '26 Release Update).
When the template hits an image merge tag such as {%Description:600x400}, the code never loads the ContentVersion blob. Instead:
- Query only the
ContentVersionIdand the file extension, which barely touches the heap. - Build a relative URL to embed in the target HTML structure:
/sfc/servlet.shepherd/version/download/{contentVersionId} - Drop that relative URL into an
<img>tag in the document's generated HTML. - When
Blob.toPdf()executes, the native PDF engine resolves the internal Salesforce URL on the server side and writes the image data straight into the final PDF stream. Apex never allocates heap for the image blob.
The dual-URL problem with guest previews
Public previews for e-signature flows break this, because a guest user has no session context to resolve a relative URL.
- PDF generation on the server keeps using the relative
/sfc/servlet.shepherd/version/download/{cvId}URL. - The browser preview needs a public, unauthenticated URL, generated with
ContentDistributionbefore you dispatch the signature request:
The guest user's browser resolves those absolute public URLs directly and renders the whole document without authenticating to Salesforce.ContentDistribution cd = new ContentDistribution(); cd.ContentVersionId = cvId; // ... set appropriate sharing/expiration settings insert cd; // Use cd.ContentDownloadUrl for the public preview HTML
3. Template-based e-signature stamping
The older method generated a DOCX, sent it, then re-processed the DOCX to stamp signatures, which meant several costly decompression and recompression cycles.
The optimized flow takes the DOCX out of the signing path entirely. The system prepares the pre-decomposed XML parts, signatures go in as raw DrawingML XML fragments inserted with plain String.replace(), and a single Queueable job loads the stamped XML and produces the final PDF through Blob.toPdf(), resolving the image URLs along the way. One asynchronous transaction, low heap throughout.
4. The content sharing model gets in the way
Guest users and the Automated Process User cannot query ContentVersion records. The Salesforce content sharing model blocks it, and WITH SYSTEM_MODE does not rescue you.
The workaround is to compute everything up front. Cache the public ContentDistribution URLs you need in a field on the primary request record, a JSON blob for instance. The signing Visualforce page then reads that pre-computed map off the request record and never touches a Content object during the guest user flow.
Stress test
The test template built an Account header, 500 Contact rows with 20 of them carrying unique 1.3MB images, Opportunity data and two signature placeholders. Total image data came to roughly 27MB. It produced a fully signed PDF with every image and the audit trail, inside synchronous governor limits, because none of that image data ever landed on the heap.
The stack
Apex does the merging logic, XML manipulation, signature stamping and the ZIP writing utilities used during template setup. Blob.toPdf() renders the PDF server side from the referenced assets. LWC is the management interface for template configuration and for kicking off a run, and Visualforce serves the public signing endpoint. Platform Events and Queueable orchestrate the asynchronous, high-volume generation. Pure JS shows up in one place only: client-side assembly of the initial DOCX structure while a template is being created.
Key takeaways
Decouple template parsing. Never decompress and manipulate ZIPs at runtime; pre-decompose the DOCX into its XML parts and store them as ContentVersion records.
Hand the images to Blob.toPdf(). Embed them as relative /sfc/servlet.shepherd/version/download/{cvId} URLs so the rendering engine loads the blob and Apex does not.
Prefer string operations to binary ones. Merges and signature stamping as pure XML string manipulation, DrawingML insertion included, avoids repeated ZIP cycles.
Pre-calculate guest access assets. Cache the public ContentDistribution URLs ahead of time so content sharing restrictions do not bite during an unauthenticated signature flow.
Leave a Comment