Troubleshoot memory and performance
These entries cover two failure families you hit under load: PHP running out of memory during a render, and throughput that falls off a cliff once a process is warm or saturated. Each entry names a symptom, the most likely cause, and a fix that uses real NextPDF surface or standard PHP-FPM controls. For the underlying streaming model and a worker tutorial, read Streaming and memory; this page is the incident-side companion to it.
Measure first. Sample memory_get_peak_usage(true) before and after a render
and call memory_reset_peak_usage() between iterations, the way the engine’s
benchmark isolates per-render cost. Tuning without a baseline moves the cliff
rather than removing it.
Entry: “Allowed memory size exhausted” during generation
Section titled “Entry: “Allowed memory size exhausted” during generation”- Symptom. A render aborts with a fatal
Allowed memory size of <n> bytes exhaustedfrom the PHP runtime, often on a large or image-heavy document. - Likely cause. The default write path composes the whole document, then
serializes it, so peak memory tracks total output size. A large document, big
embedded images, or a large embedded font face can push the request past
memory_limit. - Resolution.
- Bound the image cache.
NextPDF\Core\ConfigexposesimageCacheBytes(default52428800, that is 50 MB). Lower it with the instance wither$config->withImageCacheBytes($bytes)(signaturewithImageCacheBytes(int $bytes): self) so a build that embeds many images fails fast on a known ceiling instead of swapping. This caps the in-memory image cache; it does not resample or re-encode the images themselves. - Shrink inputs before embedding. Core does not downscale or re-encode images. Resize and re-encode oversized raster art before you embed it, and embed fonts you actually use so subsetting has a small glyph set to keep (see Reduce PDF file size).
- Keep compression on. A fresh
Confighascompressset totrue. Leave it on for normal builds;withCompress(false)is not a size optimization (it usually increases output). Reach for it to debug or profile the pipeline — it shifts the CPU/memory tradeoff (skipping the compress step) rather than reducing memory. - Raise
memory_limitdeliberately, per worker. This is a standard PHP setting, not a NextPDF key. Set it in the pool config or withini_set('memory_limit', '256M')for the CLI/queue process, and size it against a profiled peak, not a guess.
- Bound the image cache.
- Related. Streaming and memory.
Entry: memory grows with page count on very large documents
Section titled “Entry: memory grows with page count on very large documents”- Symptom. A multi-thousand-page document exhausts memory even though each page is small, and peak rises roughly in step with the page count.
- Likely cause. The buffered writer holds the whole serialized document in the heap. For very large documents that is the dominant cost.
- Resolution.
- Prefer the streaming write path. Use the documented streaming write path
described in Streaming and memory:
it serializes each page as it is composed and releases the buffer, which
reduces page-buffer/output growth; small per-object metadata (offsets, page
tree) can still scale with page/object count. Follow the documented entry
point rather than copying internal classes — the underlying streaming engine
is
experimental-tier and its symbols are not the stable public surface. - For the native
writeHtml()parser, remember that input-side memory is bounded by both the nesting-depth and element-count guards: ADR-001 caps nesting atMAX_NESTING_DEPTH = 100and rejects documents overMAX_ELEMENT_COUNT = 50000. A document that hits the element cap is told so explicitly rather than silently exhausting memory. These ADR-001 caps govern the native parser only; the optional Chrome bridge (writeHtmlChrome()) renders out of process and has its own separate memory/input limits, not these caps.
- Prefer the streaming write path. Use the documented streaming write path
described in Streaming and memory:
it serializes each page as it is composed and releases the buffer, which
reduces page-buffer/output growth; small per-object metadata (offsets, page
tree) can still scale with page/object count. Follow the documented entry
point rather than copying internal classes — the underlying streaming engine
is
- Related. Streaming and memory.
Entry: a long-lived worker exhausts memory after many jobs
Section titled “Entry: a long-lived worker exhausts memory after many jobs”- Symptom. Single renders succeed, but a queue worker that renders many PDFs back-to-back exhausts memory after minutes or hours.
- Likely cause. A long-lived PHP process accumulates allocations across jobs. A slow growth that is invisible in one request compounds over thousands.
- Resolution.
- Share registries, recreate documents. Build the
FontRegistryandImageRegistryonce at boot and pass them to aDocumentFactory; create a freshDocumentper job with$factory->create($config). Font and image parsing then happens once for the process, not once per job, and the per-job document tree is collected when it goes out of scope. Followexamples/14-worker-factory.php. - Bound the shared image cache with
new ImageRegistry(maxCacheBytes: ...)so it cannot grow without limit across jobs. - Recycle the worker — process control, not an engine guarantee. In
PHP-FPM, set
pm.max_requestsso each child respawns after a fixed number of requests. In Laravel queues usequeue:work --max-jobs/--max-time/--memory; in Symfony Messenger usemessenger:consume --limit/--time-limit/--memory-limit.
- Share registries, recreate documents. Build the
- Related. Streaming and memory.
Entry: throughput cliff on a cold or under-warmed process
Section titled “Entry: throughput cliff on a cold or under-warmed process”- Symptom. The first renders in a fresh process are slow, or every request pays a parse cost that warm requests should not.
- Likely cause. Two cold-start costs stack up. PHP without opcache
recompiles every file on each request, and an unwarmed
FontRegistryparses each font face the first time it is used. - Resolution.
- Enable opcache (and JIT where it helps). Set
opcache.enable=1and a generousopcache.memory_consumption; in production setopcache.validate_timestamps=0so the cache is not re-checked per request. That setting requires a deploy process that restarts or reloads PHP-FPM (or otherwise resets opcache, e.g.opcache_reset()/cachetool) on every release — otherwise opcache keeps serving the old bytecode and stale code runs after a deploy. These are standard PHP ini settings, not NextPDF keys. - Warm and lock the font registry at boot. On a
FontRegistryinstance,$fontRegistry->warmup($fontFiles)parses faces once during boot, and$fontRegistry->lock()freezes the registry so request-time code cannot mutate shared state;$fontRegistry->isLocked()reports the state. In a genuinely long-lived worker or application server — a queue consumer or a RoadRunner/Swoole/Octane worker that keeps the same PHP process alive across many requests — a warmed, locked registry persists its parsed faces in object state, turning per-request font parsing into a one-time process-boot cost. Under the standard PHP-FPM request model that warmed object state does not survive across requests: opcache caches compiled classes and bytecode, not warmed userland object state, so a warmedFontRegistryis rebuilt per request (re-run each request from the child’s bootstrap), not held warm across requests within a child. On plain PHP-FPM, opcache mainly amortizes the bytecode recompile cost; accept that font parsing is paid per request, not eliminated. Cross-request amortization — parsing each face once for the lifetime of the process — only applies in a genuinely long-lived process such as a RoadRunner/Swoole/Octane worker or a queue consumer that keeps the same PHP process alive across many requests. - Do not re-parse the same template per request. Resolve fonts and
reusable resources once at boot through the shared registries; only the
per-job
Documentshould be created in the request.
- Enable opcache (and JIT where it helps). Set
- Related. Streaming and memory.
Entry: server saturates and latency spikes under concurrency
Section titled “Entry: server saturates and latency spikes under concurrency”- Symptom. Per-render latency is fine in isolation, but under load the box swaps, the CPU saturates, or requests queue and time out.
- Likely cause. Too many PHP-FPM workers for the available RAM, so the sum of worker peaks exceeds physical memory and the host swaps; or too few workers, so requests serialize behind a small pool.
- Resolution.
-
Size
pm.max_childrenfrom a profiled peak. Use the standard formula:pm.max_children = (total RAM - OS/other overhead) / per-worker peak memoryMeasure a worker’s real peak with a representative document (see the profiling note in Scope), reserve headroom for the OS and any colocated services, and divide. Leave a margin; do not size to 100% of RAM.
-
Pin compression cost in your budget. Flate compression can be a significant CPU cost of writing a stream and scales with the volume of compressible stream bytes, so page count and embedded-font volume influence per-render CPU; image processing, font subsetting, and input parsing can also dominate. Measure with representative documents, and account for the real driver when you choose worker count and CPU.
-
Set
pm.max_requestsalongsidepm.max_childrenso children recycle and reclaim any slow growth, as in the worker entry above.
-
- Related. Streaming and memory.
Entry: large untrusted input is slow or expensive to parse
Section titled “Entry: large untrusted input is slow or expensive to parse”- Symptom. A render is slow or memory-heavy on a large or deeply nested input, especially HTML or a font you did not produce.
- Likely cause. Parsing cost scales with input size and structure. A pathological input (deep nesting, an enormous element count, or a malformed font) can dominate the budget.
- Resolution.
- Lean on the engine’s bounds. The native
writeHtml()HTML parser enforcesMAX_NESTING_DEPTH = 100andMAX_ELEMENT_COUNT = 50000(ADR-001); inputs over those caps are rejected rather than allowed to exhaust the process. (The optional Chrome bridge,writeHtmlChrome(), is out of scope for these ADR-001 caps and enforces its own separate memory/input limits.) - Treat caller-supplied fonts as untrusted. A malformed font raises
NextPDF\Exception\FontParsingExceptionrather than corrupting output, so catch the specific exception and reject the input instead of retrying. - Validate and size inputs at your boundary, and apply request-level limits on document size for caller-influenced content.
- Lean on the engine’s bounds. The native
- Related. Troubleshooting: fonts and tagging.
Decision table: symptom to lever
Section titled “Decision table: symptom to lever”| Symptom | Most likely lever |
|---|---|
Allowed memory size … exhausted on a single render | Lower $config->withImageCacheBytes(); shrink images before embed; raise per-worker memory_limit |
| Peak memory rises with page count | Use the documented streaming write path |
| Worker memory climbs over many jobs | Share FontRegistry/ImageRegistry via DocumentFactory; set pm.max_requests / --max-jobs |
| First requests slow, per-request parse cost | Enable opcache; $fontRegistry->warmup() then ->lock() at boot |
| Host swaps / latency spikes under load | Size pm.max_children = (RAM − overhead) / per-worker peak |
| Slow or heavy on large/untrusted input | Rely on ADR-001 caps; reject malformed fonts on FontParsingException |
Edge cases & gotchas
Section titled “Edge cases & gotchas”imageCacheBytesis a memory ceiling, not a size knob. Lowering it caps the cache so a build fails fast; it never resamples or re-encodes the images you embed. Core has no image-quality control.withCompress(false)makes files larger and is a debugging/profiling aid. It is not a size optimization; it shifts the CPU/memory tradeoff (it skips the compress step) rather than reducing memory.- The streaming engine’s exact memory profile is an
experimental-tier property and may shift between minor releases. Treat any single measurement as an observation, not a portable constant. memory_limit,opcache.*,pm.max_children, andpm.max_requestsare standard PHP / PHP-FPM settings. NextPDF does not expose its own keys for them; configure them in your runtime, not inConfig.
See also
Section titled “See also”- Streaming and memory — the streaming model, ADR-001 bounds, and the full batch-worker tutorial.
- Reduce PDF file size — compression and font subsetting, the two real size controls.
- Troubleshooting: fonts and tagging — font resolution, parsing, and subsetting failures.
- Knowledge base index
Glossary: streaming writer · font subsetting