Skip to content
getnextpdf.com

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 exhausted from 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.
    1. Bound the image cache. NextPDF\Core\Config exposes imageCacheBytes (default 52428800, that is 50 MB). Lower it with the instance wither $config->withImageCacheBytes($bytes) (signature withImageCacheBytes(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.
    2. 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).
    3. Keep compression on. A fresh Config has compress set to true. 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.
    4. Raise memory_limit deliberately, per worker. This is a standard PHP setting, not a NextPDF key. Set it in the pool config or with ini_set('memory_limit', '256M') for the CLI/queue process, and size it against a profiled peak, not a guess.
  • 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.
    1. 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.
    2. 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 at MAX_NESTING_DEPTH = 100 and rejects documents over MAX_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.
  • 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.
    1. Share registries, recreate documents. Build the FontRegistry and ImageRegistry once at boot and pass them to a DocumentFactory; create a fresh Document per 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. Follow examples/14-worker-factory.php.
    2. Bound the shared image cache with new ImageRegistry(maxCacheBytes: ...) so it cannot grow without limit across jobs.
    3. Recycle the worker — process control, not an engine guarantee. In PHP-FPM, set pm.max_requests so each child respawns after a fixed number of requests. In Laravel queues use queue:work --max-jobs / --max-time / --memory; in Symfony Messenger use messenger:consume --limit / --time-limit / --memory-limit.
  • 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 FontRegistry parses each font face the first time it is used.
  • Resolution.
    1. Enable opcache (and JIT where it helps). Set opcache.enable=1 and a generous opcache.memory_consumption; in production set opcache.validate_timestamps=0 so 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.
    2. Warm and lock the font registry at boot. On a FontRegistry instance, $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 warmed FontRegistry is 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.
    3. 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 Document should be created in the request.
  • 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.
    1. Size pm.max_children from a profiled peak. Use the standard formula:

      pm.max_children = (total RAM - OS/other overhead) / per-worker peak memory

      Measure 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.

    2. 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.

    3. Set pm.max_requests alongside pm.max_children so 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.
    1. Lean on the engine’s bounds. The native writeHtml() HTML parser enforces MAX_NESTING_DEPTH = 100 and MAX_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.)
    2. Treat caller-supplied fonts as untrusted. A malformed font raises NextPDF\Exception\FontParsingException rather than corrupting output, so catch the specific exception and reject the input instead of retrying.
    3. Validate and size inputs at your boundary, and apply request-level limits on document size for caller-influenced content.
  • Related. Troubleshooting: fonts and tagging.
SymptomMost likely lever
Allowed memory size … exhausted on a single renderLower $config->withImageCacheBytes(); shrink images before embed; raise per-worker memory_limit
Peak memory rises with page countUse the documented streaming write path
Worker memory climbs over many jobsShare FontRegistry/ImageRegistry via DocumentFactory; set pm.max_requests / --max-jobs
First requests slow, per-request parse costEnable opcache; $fontRegistry->warmup() then ->lock() at boot
Host swaps / latency spikes under loadSize pm.max_children = (RAM − overhead) / per-worker peak
Slow or heavy on large/untrusted inputRely on ADR-001 caps; reject malformed fonts on FontParsingException
  • imageCacheBytes is 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, and pm.max_requests are standard PHP / PHP-FPM settings. NextPDF does not expose its own keys for them; configure them in your runtime, not in Config.

Glossary: streaming writer · font subsetting