pdfium-impl-performance
PDFium is fast at the page level but has three performance traps that dominate real pdfium-render programs. This skill addresses all three:
- Binding. Loading the PDFium library is expensive. Bind once, reuse forever.
- Parallelism. PDFium is not thread safe. The
thread_safefeature makes multi-threaded code correct but never faster. Throughput comes from process-level parallelism. - Allocation. Rebuilding a
PdfRenderConfigor a bitmap for every page wastes work in a render loop.
Default API surface: pdfium-render 0.9.x. Version notes for 0.8.x are inline.
The cost model
| Operation | Cost | Rule |
|---|---|---|
| Bind the PDFium library | high, one-time | bind once at startup, store in a static |
| Load a document | moderate, per file | unavoidable per document |
Build a PdfRenderConfig |
small | build once, reuse across pages |
| Render a page to a bitmap | the real work | the figure to optimize |
| A fresh 2000x2000 bitmap | around 11 ms on WASM (issue #35) | reuse the render config; do not rebuild per page |
Lever 1: bind PDFium once
Binding produces a Box<dyn PdfiumLibraryBindings> and is the most expensive
setup step. Pdfium::default() performs the full bind-with-fallback. Calling
it per web request or per file is the most common performance bug (issue #59).
ALWAYS bind once at process startup and reuse the Pdfium instance. Store it in
a process-wide static.
Async server (axum, tokio)
The verified examples/axum_once_cell.rs pattern stores the instance in a
tokio::sync::OnceCell wrapping a tokio::sync::Mutex:
use pdfium_render::prelude::*;
use tokio::sync::{Mutex, OnceCell};
static PDFIUM: OnceCell<Mutex<Pdfium>> = OnceCell::const_new();
async fn pdfium() -> &'static Mutex<Pdfium> {
PDFIUM
.get_or_init(|| async { Mutex::new(Pdfium::default()) })
.await
}
A request handler then does let guard = pdfium().await.lock().await; and runs
all PDFium work inside the guard scope.
Synchronous program
For a non-async program, std::sync::OnceLock (Rust standard library) holds the
instance without an extra dependency:
use pdfium_render::prelude::*;
use std::sync::OnceLock;
static PDFIUM: OnceLock<Pdfium> = OnceLock::new();
fn pdfium() -> &'static Pdfium {
PDFIUM.get_or_init(Pdfium::default)
}
The once_cell crate's Lazy is an equivalent option. The principle is the
same: one bind for the whole process.
Lever 2: the threading reality
PDFium is NOT thread safe. The crate documentation states it plainly: "Pdfium makes no guarantees about thread safety and should be assumed not to be thread safe."
The thread_safe feature (ON by default) "achieves thread safety by locking
access to Pdfium behind a mutex; each thread must acquire exclusive access to
this mutex in order to make any call to Pdfium." Every PDFium call is serialized
as if single-threaded. The documentation is explicit: "This approach offers no
performance benefit."
This produces two hard rules:
- NEVER expect multi-threading with the
thread_safefeature to speed work up. It guarantees correctness, not throughput. - NEVER call PDFium from multiple threads with the
thread_safefeature disabled. Unsynchronized concurrent calls cause random segfaults (issue #12).
For real throughput, the Pdfium authors "specifically recommend that parallel
processing, not multi-threading, be used to process multiple documents
simultaneously." Run multiple OS processes, each with its own Pdfium
instance. See references/examples.md.
Release 0.9.0 implements Send and Sync for all object instances, which makes
sharing documents, pages, and objects across threads compile cleanly. On 0.8.x,
object instances are not Send/Sync and cannot be moved across threads.
Send/Sync changes what compiles; it does not change the no-speedup fact.
Lever 3: reuse the render config
render_with_config takes &PdfRenderConfig by shared reference. Build the
config ONCE, before the page loop, and pass the same reference for every page.
let config = PdfRenderConfig::new()
.set_target_width(2000)
.set_maximum_height(2000);
for page in document.pages().iter() {
let bitmap = page.render_with_config(&config)?;
// process bitmap
}
NEVER call PdfRenderConfig::new() inside the loop body. Issue #35 shows that
per-page allocation in the render path is a measurable cost: a fresh 2000x2000
bitmap takes around 11 ms on WASM.
Render cost knobs
The output size is the dominant render cost. Tune it on PdfRenderConfig:
| Knob | Effect |
|---|---|
set_target_width / set_target_height |
scale to a preferred size, aspect kept |
set_maximum_width / set_maximum_height |
cap a dimension, never exceed it |
set_fixed_width / set_fixed_height / set_fixed_size |
force an exact size (added 0.8.37) |
scale_page_by_factor |
scale relative to the natural page size |
use_print_quality |
higher fidelity at higher cost; leave off for screen output |
ALWAYS render at the smallest size the output actually needs. Rendering at 300 DPI when the consumer shows a 96 DPI thumbnail wastes most of the work.
Decision tree
pdfium-render program is slow?
|
+-- Slow per request, or PDFium loaded on each call?
| -> bind once: OnceCell (async) or OnceLock (sync)
|
+-- Multi-threaded but no speedup?
| -> expected with thread_safe; switch to process-level parallelism
|
+-- Random crashes under concurrency?
| -> thread_safe is disabled; re-enable it, or serialize calls yourself
|
+-- Slow inside a page render loop?
| -> build PdfRenderConfig once outside the loop; reuse it
|
+-- Output larger than the consumer needs?
-> shrink target/fixed size; turn use_print_quality off for screen
Version table
| Item | 0.8.x | 0.9.x |
|---|---|---|
thread_safe feature, default ON |
present | present |
Send / Sync on object instances |
not implemented | implemented in 0.9.0 |
set_fixed_width / _height / _size |
added 0.8.37 | present |
Pdfium::default() bind-with-fallback (also tries cwd) |
extended in 0.8.12 | present |
Critical rules
- ALWAYS bind PDFium once per process and store the
Pdfiuminstance in a static (OnceCellfor async,OnceLockfor sync). NEVER rebind per request or per file. - ALWAYS build a
PdfRenderConfigonce and reuse it across pages. NEVER construct it inside the render loop. - NEVER expect the
thread_safefeature to make multi-threaded work faster. It serializes every call behind a mutex. - NEVER call PDFium from multiple threads with
thread_safedisabled. That causes segfaults. - For real throughput, use process-level parallelism: one
Pdfiumper process. - ALWAYS render at the smallest output size the consumer needs.
- On 0.8.x, object instances are not
Send/Sync; cross-thread sharing needs 0.9.0 or later.
Companion skills
pdfium-core-architecturefor the thread-safety stance and ownership tree.pdfium-core-memoryfor the lifetime model behind shared instances.pdfium-core-bindings-setupfor thethread_safefeature flag and binding.pdfium-syntax-renderingforPdfRenderConfigandrender_with_config.pdfium-impl-wasmfor the WASM heap and bitmap cost on WASM.
Reference files
references/methods.md: complete API signatures with version annotations.references/examples.md: working, verified Rust examples.references/anti-patterns.md: real failures, why they happen, and the fix.