Performance

Copy Markdown

URP converts documents by talking directly to soffice over TCP. This page compares URP against Gotenberg, a popular LibreOffice-based conversion service.

Benchmarks

docker compose --file benchmarks/docker-compose.yml up --detach --wait
nix develop --command mix run benchmarks/bench.exs

The results below were recorded on July 14, 2026, on Apple M3 Max with Elixir 1.20.2 and Erlang/OTP 29.0.3. URP's Debian container ran LibreOffice 26.2.4.2; Gotenberg 8.32.0 bundled LibreOffice 26.2.2.2. The fixture uses Liberation fonts only — regenerate with uv run --with python-docx --with Pillow --with numpy benchmarks/generate_fixture.py (pass --size 15 for the large variant).

Results (Apple M3 Max)

2.6 MB input → 221-page PDF:

Name                         ips        average  deviation         median         99th %
URP  Debian glibc         1.31         0.76 s    ±14.66%         0.72 s         1.09 s
URP  Alpine musl          0.88         1.14 s     ±8.36%         1.16 s         1.21 s
Gotenberg (HTTP)            0.85         1.18 s     ±5.55%         1.16 s         1.36 s

15.5 MB input → 62 MB PDF:

Name                         ips        average  deviation         median         99th %
URP  Debian glibc        0.194         5.16 s     ±1.86%         5.18 s         5.24 s
URP  Alpine musl         0.143         6.98 s    ±13.31%         6.74 s         8.01 s
Gotenberg (HTTP)           0.137         7.30 s     ±2.32%         7.23 s         7.49 s

The Debian URP stack had 36% lower average latency for the small document and 29% lower average latency for the large document than Gotenberg. The absolute advantage grew from 0.42 s to 2.14 s. Alpine URP was much closer to Gotenberg and had noticeably higher variance on the large fixture. These measurements compare complete stacks with different LibreOffice builds, C libraries, and container packaging; they do not isolate dependency or runtime upgrades individually.

Process overhead sanity check

benchmarks/convert.exs compares the persistent URP connection with a cold soffice --convert-to process and Gotenberg using the 33 KB sample3.docx fixture. Across ten timed iterations, the results were:

MethodAverageMedianRange
URP45 ms45 ms42–49 ms
Gotenberg174 ms154 ms147–323 ms
LibreOffice CLI285 ms286 ms276–302 ms

This is a process-overhead check, not an apples-to-apples transport benchmark: URP reuses a live office process, while the CLI measurement starts a new process for every conversion. One Gotenberg request was a 323 ms outlier; the median remained close to the previous run.

I/O strategies

URP supports two I/O transfer strategies via the :io option, benchmarked with benchmarks/io_bench.exs:

nix develop --command mix run benchmarks/io_bench.exs

File I/O (:file, default) writes temp files on soffice's filesystem and transfers them over URP in ~6 round-trips. Stream I/O (:stream) pipes bytes over the URP socket via XInputStream/XOutputStream — no temp disk, but more round-trips.

Current results on Elixir 1.20.2 / OTP 29.0.3:

Strategy2.7 MB16.2 MB35.0 MB
File → file1.13 s7.66 s39.17 s
File → stream1.19 s8.06 s37.19 s
Stream → file1.72 s11.59 s41.53 s
Stream → stream1.64 s9.06 s46.02 s

Stream input remains the bottleneck because ZIP-based formats (docx, xlsx, pptx) require thousands of XInputStream/XSeekable random-access round-trips, but its measured penalty now ranges from roughly 12% to 52% depending on document size and output mode. Stream output remains within about 5% of file output. The 35 MB fixture completes only one iteration per scenario with the default 10-second Benchee window, so treat those figures as directional rather than statistically stable.

Soffice writes stream output in fixed 32 767-byte chunks, so the round-trip count is predictable.

StrategyInputOutputBest for
io: :filefastfastDefault — best throughput
io: {:file, :stream}fastchunkedLarge outputs without single big allocation
io: {:stream, :file}slowfastNo temp disk for input
io: :streamslowchunkedNo temp disk at all

Container image

See benchmarks/Dockerfile.soffice-debian. Minimal Debian trixie-slim with LibreOffice from trixie-backports, fonts-liberation (metric- compatible with Arial/Times/Courier), and fonts-crosextra-carlito (Calibri replacement). ~564 MB vs Gotenberg's ~1.86 GB.

PDF output

URP and Gotenberg produce identical PDFs when using the same LibreOffice version and fonts.

A single long-lived soffice instance (the normal URP deployment) produces byte-identical output across consecutive conversions — the only varying fields are timestamps and document IDs in metadata (CreationDate, ModDate, /ID, /DocChecksum).

Different soffice processes produce visually identical PDFs but assign fonts to different PDF object numbers (~32 KB of byte differences for a 221-page PDF). This is likely hash table iteration order in LO's font subsetting. Bug 160033 tracks this upstream. qpdf --deterministic-id can normalize metadata but not font object ordering.

Using libreofficedocker/alpine

The pre-built libreofficedocker/alpine image works with URP out of the box but has two structural drawbacks.

musl allocator overhead. Alpine uses musl libc, whose mallocng allocator issues mmap/munmap for most allocations — 21,432 syscalls per conversion vs 25 on glibc. This adds ~260 ms. It's inherent to musl and can be mitigated with LD_PRELOAD=/usr/lib/libjemalloc.so.2.

Image bloat. Despite Alpine's small-image reputation, libreofficedocker/alpine is 1.78 GB — nearly 3x the Debian image. It bundles OpenJDK 11, 130+ Noto font packages, and 450 packages total.

As of July 2026, Alpine ships LO 25.8.x (Still) while Debian trixie-backports has 26.2.x (Fresh). Carlito is missing from the stock Alpine image (apk add font-carlito to fix).

Setup2.6 MB15.5 MBLO versionImage size
URP → Debian glibc0.72 s5.05 s26.2.4.2~564 MB
URP → Alpine musl1.18 s7.94 s25.8.1.1~1.78 GB
Gotenberg (Debian glibc)1.16 s7.36 s26.2.2.2~1.68 GB
Reproducing the strace analysis ```sh docker compose --file benchmarks/docker-compose.yml up --detach --wait SOFFICE=benchmarks-soffice-alpine-1 docker exec $SOFFICE apk add --no-cache strace docker exec $SOFFICE pgrep -f soffice.bin # note the PID # Syscall summary during one conversion docker exec -d $SOFFICE strace -f -c -p -o /tmp/strace.txt mix run -e '{:ok, _} = URP.convert({:binary, File.read!("benchmarks/fixtures/benchmark.docx")}, filter: "writer_pdf_Export", output: :binary)' docker exec $SOFFICE sh -c 'kill -INT $(pgrep strace)' docker exec $SOFFICE cat /tmp/strace.txt ```