#3756·dompdf

Quadratic render time with many forced page breaks: `split()` resets not-yet-reflowed sibling frames

Author: SHJordanCreated Sep 4, 2026Updated Sep 10, 2026

Summary

AbstractFrameDecorator::split() calls reset() on every following sibling of the split point:

php
// Add $child and all following siblings to the new split node
$iter = $child;
while ($iter) {
    $frame = $iter;
    $iter = $iter->get_next_sibling();
    $frame->reset();
    $split->append_child($frame);
}

On documents that use one forced page break per page (page-break-after: always on many top-level blocks — a very common pattern for generated official gazettes, invoices, certificates…), the layout is strictly sequential, so at split time all those siblings have not been reflowed yet. reset() on a never-reflowed frame is a semantic no-op (positions/containing block are already null, line boxes/counters are empty, the style object is unmutated) — but it is executed anyway, making pagination O(N²) in the number of pages.

Real-world impact: a 412-page A4 document (cover + 411 identical header/footer pages) took 272 s to render with dompdf 1.2.2 and timed out on the production web server.

Related

  • #3738 documents the same reset-the-tail mechanism through a different trigger (large tables with page-break-inside: avoid, confirmed on 3.1.5). This report covers the simpler and very common forced-break case, which needs no tables at all, and shows that skipping the reset for never-reflowed frames on forced breaks is provably output-identical and turns paging linear.
  • Code inspected on master today (2026-09-04): the sweep loop above is unchanged, so current 3.x is affected as well (consistent with #3738).

Minimal reproduction (self-contained, no dependencies besides dompdf)

php
<?php
// gen.php — generates repro.html with N forced-break pages
$N = (int)($argv[1] ?? 200);
$body = '';
for ($i = 1; $i <= $N; $i++) {
    $body .= '<div style="page-break-after: always;"></div>'
        . '<div style="margin: 20px 0 0 0;"><table><tr>'
        . '<td style="font-size: 12px;"><b>Sexta-feira, 04 de Setembro de 2026<br>Ano: 09<br>Edição: 576</b></td>'
        . '<td style="text-align: center;"><span style="letter-spacing: .35rem">DIÁRIO</span></td>'
        . '</tr></table><hr style="border:1px black solid; height:4px;"></div>'
        . '<div>&nbsp;</div><div>&nbsp;</div>'
        . '<div style="position: absolute; bottom: 0; height: 80px;"><table><tr>'
        . '<td style="padding: 0.5rem;"><table><tr style="text-align: right;"><td>Página ' . $i . '</td></tr>'
        . '<tr><td style="width: 65%;"><a href="https://example.org">example.org</a></td>'
        . '<td style="text-align: right; font-size: 9pt;">04 de Setembro de 2026</td></tr></table></td>'
        . '</tr></table></div>';
}
$html = '<!DOCTYPE html><html><head><meta charset="utf-8">'
      . '<style>*{box-sizing:border-box} @page{margin:2mm 2mm 5mm 2mm} body{margin:0}</style>'
      . '</head><body>' . $body . '</body></html>';
file_put_contents('repro.html', $html);
php
<?php
// bench.php
require 'vendor/autoload.php';
use Dompdf\Dompdf;
use Dompdf\Options;
$o = new Options();
$o->setChroot(__DIR__);
$t0 = microtime(true);
$dompdf = new Dompdf($o);
$dompdf->loadHtmlFile(__DIR__ . '/repro.html');
$dompdf->setPaper('a4');
$dompdf->render();
echo 'RENDER=' . round(microtime(true) - $t0, 2) . "s\n";
file_put_contents('out.pdf', $dompdf->output());

Benchmarks

Stock dompdf 1.2.2, PHP 8.2 (minimal repro above):

Pages Stock 1.2.2 Patched (see below) Stock 2.0.8
100 2.10 s 1.21 s
200 6.06 s 2.50 s 4.93 s
400 18.53 s 5.99 s 15.65 s

Marginal per-page cost on stock 1.2.2 grows 0.021 → 0.030 → 0.046 s/page (quadratic); patched it stays ~linear (0.012 → 0.013 → 0.015 s/page). A larger real-world case (412-page document, barryvdh/laravel-dompdf, PHP 8.2) went from 272 s → 31.6 s.

Output correctness: with the proposed patch, generated PDFs are byte-for-byte identical to stock at 100/200/400 pages in the repro (same file sizes and contents) and identical (byte size + rasterized page-by-page comparison) in the real-world application.

xdebug profiling of the unpatched render attributes the growth almost entirely to AbstractFrameDecorator::reset / Block::reset / AbstractFrameDecorator::split self-time (their share more than doubles between a 50-page and a 200-page render), while Stylesheet::apply_styles stays strictly linear.

Root cause and proposed fix

Layout is strictly sequential, so on a forced break every moved sibling after the split point has never been reflowed. For such frames reset() is a no-op:

  • Frame::reset() nulls positions/containing-block that are still null (they are only set when Page::reflow() reaches each child, after the split);
  • block/line-box state, counters and generated content are still empty;
  • the live style object has not been mutated since decoration, so re-cloning from get_original_style() yields the same values.

Note this is not true for unforced (overflow) splits: there, split() writes $child->get_original_style()->margin_top = 0 and relies on the subsequent reset() to propagate that into the live style (and moved frames may have been partially laid out). So the reset must be kept for unforced splits — the skip applies to forced breaks only.

Patch (against 1.2.2; the same idea applies to master, where the loop is unchanged):

diff
--- a/vendor/dompdf/dompdf/src/FrameDecorator/AbstractFrameDecorator.php
+++ b/vendor/dompdf/dompdf/src/FrameDecorator/AbstractFrameDecorator.php
@@ -108,6 +108,13 @@
     public $is_split = false;
 
+    /**
+     * Whether the frame has gone through reflow at least once
+     *
+     * @var bool
+     */
+    public $_was_reflowed = false;
+
     /**
      * Class constructor
@@ -885,6 +897,7 @@
     final function reflow(Block $block = null)
     {
+        $this->_was_reflowed = true;
         // Uncomment this to see the frames before they're laid out, instead of
         // during rendering.
         //echo $this->_frame; flush();
@@ -727,7 +734,12 @@
         while ($iter) {
             $frame = $iter;
             $iter = $iter->get_next_sibling();
-            $frame->reset();
+            // On a forced page break the following frames were never laid
+            // out, so resetting them is a no-op; skipping it keeps paging
+            // linear on documents with many top-level page blocks.
+            if (!$forced || $frame->_was_reflowed) {
+                $frame->reset();
+            }
             $split->append_child($frame);
         }

(For master, the hunk line numbers differ and is_split_off/style handling changed slightly, but the sweep loop and the fix are the same. An alternative that also helps #3738 would be a finer-grained reset that only invalidates position-dependent used values.)

Environment

  • dompdf 1.2.2 (via barryvdh/laravel-dompdf 1.0) and dompdf 2.0.8, both affected
  • PHP 8.2.33, Linux
  • master inspected 2026-09-04: sweep loop unchanged
  • Related: #3738 (same mechanism, tables + page-break-inside: avoid, 3.1.5, milestone 4.0.0)