Click "Print" on an order and the browser sits there. Five seconds, ten seconds, sometimes a 504 from PHP-FPM — and on a big order, a memory spike that takes the admin pool down with it. Sales PDFs are one of the most quietly ignored performance problems in Magento 2: they're generated on every request, in pure PHP, with no cache, and the cost scales with the number of line items. This article shows exactly where that time goes, how to confirm it on your own store, and a practical playbook to make PDF generation fast — or get it out of the request path entirely.
Why PDF Generation Is Expensive by Default
When you print an invoice, packing slip, credit memo or order confirmation, Magento does not render HTML and convert it. It draws the PDF programmatically, page by page, using Zend_Pdf — the PDF engine from the Zend Framework 1 era that Magento still ships (via the magento/zendframework1 package). The classes that do the work live in Magento\Sales\Model\Order\Pdf:
-
AbstractPdf— the base class that lays out pages, draws the header, footer, logo and table scaffolding; -
Invoice,Shipment,Creditmemo,Order— each implements the item-by-item drawing for its document type.
The drawing loop is the hot path. For every line item, AbstractPdf measures the rendered text width with _getTextWidth(), which calls into the font object's widthForString() — per character, in PHP. Then Zend_Pdf_Page::drawText() places every string at its coordinates, wrapping lines to fit the column. Add a logo image, a translated address block, item options, SKUs, tax rows, and a footer on every page, and a single invoice for an order with 100 line items easily takes 1–5 seconds of pure CPU and 5–20 MB of memory per document — all inside the PHP-FPM worker that is also serving your admin.
The kicker: none of it is cached. Every print request calls getPdf() again, re-measures every glyph, re-draws every page, and re-renders the whole document from scratch — even if nothing about the order changed since the last print.
Where the Time Actually Goes
Profile a PDF request with Blackfire, Xdebug or a simple microtime around getPdf(), and the wall-clock time concentrates in three places:
-
Text measurement and drawing.
Zend_Pdf_Font::widthForString()andZend_Pdf_Page::drawText()dominate. The cost is linear in the number of characters, so an order with 300 line items costs roughly three times what 100 items cost — plus the extra pages. -
Document assembly in memory.
Zend_Pdfkeeps every page object and its full content stream in memory untilrender()flattens the document. A 50-page PDF is one big PHP object graph, andZend_Pdf::render()then serializes fonts and content streams — a second CPU spike right before the download starts. -
Batch multiplication. The admin "Print" action on the Sales > Orders grid (
Magento\Sales\Controller\Adminhtml\Order\Pdf) loops over every selected order in a single request, concatenating all pages into one giant PDF before sending it. Select 100 orders with 40 items each and you're generating a 4000-line-item document synchronously. This is the classic 502 / 504 / memory-limit killer.
There's also a frontend angle: customers can print their own order PDF from the account area, so a malicious or curious user can trigger expensive generation on the storefront, where the FPC does nothing for you — a downloaded PDF is dynamic by nature.
How to Diagnose It on Your Store
Before changing anything, measure:
// pdf-bench.php - time one invoice PDF from the CLI
require BP . '/app/bootstrap.php';
$bootstrap = \Magento\Framework\App\Bootstrap::create(BP, $_SERVER);
$objectManager = $bootstrap->getObjectManager();
$invoice = $objectManager->create(\Magento\Sales\Model\Order\Invoice::class)->load(10012345);
$start = microtime(true);
$pdf = $objectManager->create(\Magento\Sales\Model\Order\Pdf\Invoice::class)->getPdf([$invoice]);
printf("time: %.2fs, peak memory: %.1f MB\n", microtime(true) - $start, memory_get_peak_usage(true) / 1048576);
In practice the fastest route is profiling one print request with Blackfire or Xdebug: look for Zend_Pdf_Page::drawText and Zend_Pdf_Font::widthForString in the hot methods list, and note memory_get_peak_usage() at the end. Then scale the test: generate PDFs for orders with 10, 50 and 200 line items and plot time and memory against item count. If the curve is cleanly linear, you're textbook Zend_Pdf; if it's worse, check for a custom module or extension that adds per-item drawing work (extra columns, barcodes, images) — third-party invoice extensions frequently add the most expensive drawing of all.
The Playbook: Batch, Cache, Replace
1. Get batch printing out of the request path (biggest win)
Replace the synchronous mass-print flow with a queue consumer. The order grid mass action or custom button dispatches one message per order (or one message with an order-id batch), a consumer generates the PDFs and stores the files, and the admin sees a link or a "ready" flag once the job finishes.
<!-- communication.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Communication/etc/communication.xsd">
<topic name="sales.pdf.generate.request" request="string">
<handler name="pdf.generation.handler" type="Vendor\Module\Model\Pdf\Consumer" method="process"/>
</topic>
</config>
The consumer runs in a dedicated, monitored process with a higher memory limit than the admin pool (php -d memory_limit=1G bin/magento queue:consumers:start sales.pdf.generation), writes the finished files to a storage directory, and emits a notification with a download link. The admin request now takes milliseconds and returns instantly. This single change eliminates the 504s and the admin-pool memory crashes entirely.
2. Cache generated documents
If you keep synchronous generation (for example, the single-order print button), cache the rendered PDF keyed by document type + order/increment id + a content fingerprint, and invalidate when anything that appears on the PDF changes — a new comment, a shipment, a credit memo. The fingerprint can be as simple as a hash of updated_at plus the relevant statuses; store files under var/ or the media directory, never in the database. A plug-in on getPdf() is the clean interception point:
<!-- di.xml -->
<type name="Magento\Sales\Model\Order\Pdf\Invoice">
<plugin name="vendor_pdf_cache" type="Vendor\Module\Plugin\PdfCache" sortOrder="10"/>
</type>
The same route helps the frontend customer-print case: the file is generated once and served as a static download afterwards, which also protects you from print-happy customers hammering the storefront.
3. Generate PDFs ahead of time
For high-volume stores, flip the order of work: generate the invoice PDF right after the invoice is created (or when it's finalized), triggered by an observer or a queue message, and store it. By the time a customer or admin asks for it, the file already exists. This turns a 2-second synchronous request into zero-cost delivery and completely decouples PDF CPU from storefront traffic.
4. Make the renderer cheaper
If you can't move generation off the request path, reduce the per-document cost:
- Trim the layout. A module that overrides the PDF template classes can drop expensive columns (e.g., the extended options block, custom attributes, barcode images) or draw more items per page, cutting page count and glyph work.
- Simplify images and fonts. The logo and any embedded images are re-encoded into every PDF; keep them small. Stick to the built-in core fonts (Helvetica, Times) — embedding custom TTF fonts forces font subsetting on every render.
-
Raise limits for the right pool only. Give the admin PHP-FPM pool (or the dedicated consumer pool) a higher
memory_limitandpm.max_requestsheadroom, instead of raising limits globally for the storefront.
5. Replace Zend_Pdf entirely
When PDFs are a core part of your business (thousands of invoices a day), consider replacing the renderer. Options range from faster pure-PHP engines (TCPDF, FPDF) to HTML-to-PDF converters (Chromium headless, wkhtmltopdf, or a PDF API service). The catch: Magento's PDF classes are tightly coupled to Zend_Pdf's page object model, so a full swap means reimplementing Magento\Sales\Model\Order\Pdf\* classes with your own drawing layer while keeping the same public interface (getPdf()), and validating every document type against your templates. It's real module work — start with the batch-queue and cache steps above first, and only invest in a renderer swap if profiling shows PDF rendering is a permanent, dominant cost for your infrastructure.
Putting It Together
The default Magento 2 PDF pipeline is a pure-PHP drawing engine, running synchronously in a web request, rebuilding the same document every time, with cost proportional to line items — and the mass-print grid action multiplies that by the number of selected orders in one shot. The fixes are progressive and independent:
- Queue the batch print flow — eliminate the 502/504 class of incidents overnight and move PDF CPU to monitored consumers with headroom.
- Cache generated documents — make repeat prints and frontend customer prints instant static downloads.
- Generate ahead of time if you invoice at scale — PDFs are cheap when nobody is waiting on them.
- Trim layout and fonts to lower per-document cost, and scope memory limits to the generating pool only.
- Replace the renderer only when profiling proves PDF generation is a dominant, permanent cost.
Start by timing getPdf() on your largest orders — the numbers will tell you which of the five steps pays off first. In most stores, step 1 alone turns a dreaded admin click into an instant response, and that's usually worth more than any renderer swap.
Top comments (0)