Performance and Isolation Trade-offs

In Tika 4.x, tika-server’s classic endpoints (/tika`, /rmeta, /meta, /detect, /unpack) parse through Tika Pipes by default: the HTTP front-end hands each document to a pool of forked worker JVMs rather than parsing in the server process. This buys crash/OOM isolation at the cost of a per-request overhead — spooling large payloads to a temp file, a socket round-trip, and serializing the result back. This page describes that trade-off and how to tune for it.

The tables in the first half of this page were measured on the 4.0.0 release and are kept as that snapshot. After 4.0.0 shipped we traced the batch slowdown against 3.x to a specific cause — temp-file volume, not the pipes architecture — and fixed it for 4.1.0. The findings, the fix, and a worked configuration from the box where it was diagnosed are in What we found in 4.0.0, and what 4.1.0 changes and A worked configuration: the regression-test box. The upload-endpoint gap on tiny documents (Endpoint choice: uploading bytes vs fetch-and-emit (4.0.0 measurements)) is a separate, smaller effect and still applies.

Deployment shapes

Shape Description Parsing JVMs

In-process (legacy)

Tika 3.x with --noFork. The server parses in its own JVM. No isolation, no recovery. Not recommended.

1 (the server itself)

Single forked child

Tika 3.x default. A thin watchdog parent forks one child that binds the port and does all parsing; the watchdog restarts it on crash/OOM/timeout.

1 (the child)

Pipes per-client

Tika 4.x default. The HTTP front-end forks numClients worker JVMs; each handles one request at a time.

numClients (e.g. 4)

Pipes shared-server

Tika 4.x opt-in (useSharedServer=true; see Shared Server Mode). The front-end forks a single worker JVM with a numClients-sized thread pool.

1 (shared worker)

The single-forked-child (3.x) and shared-server (4.x) shapes are close cousins: one parsing JVM serving all concurrency, with the front-end/watchdog restarting it on failure. The practical 4.x default choice is between per-client (strongest isolation) and shared-server (highest throughput).

Throughput (4.0.0 measurements)

The pipes per-request overhead — temp-spool of large payloads, socket IPC, and result serialization — is roughly fixed per request. It therefore dominates when parse time is small (many tiny documents) and amortizes away as documents get larger and parsing dominates.

Relative throughput at matched concurrency (requesting threads = worker count), normalized to a single in-JVM parser of the same total heap (= 1.00; higher is faster). These are representative figures from one benchmark (16-core host, JDK 17, loopback HTTP, plain-text extraction) and are meant to show the shape of the trade-off, not to be quoted as absolutes:

Corpus Single in-JVM (8g) Shared-server (1×8g) Per-client (4×2g)

Many small files (~50 KB HTML)

1.00

~0.60

~0.47

Mixed (~350 KB avg)

1.00

~0.85

~0.65

Large (multi-MB, up to ~50 MB)

1.00

~0.95

~0.82

Two things to note:

  • The gap is widest on small files (per-request overhead is the whole cost) and nearly closes on large files (parse time dominates).

  • Shared-server recovers most of the pipes overhead relative to per-client — one warm JVM with shared JIT and one garbage collector outperforms several smaller, independently-warming worker heaps.

Endpoint choice: uploading bytes vs fetch-and-emit (4.0.0 measurements)

How a document reaches the parser matters as much as the parsing mode. The classic endpoints (/tika, /rmeta, /meta) receive the document in the HTTP request body and return the extract in the response, so every request pays to move the bytes in and the result back out — and in 4.x that now crosses the process boundary to a forked worker. The pipes endpoints (/pipes, /async) instead take only a small fetch/emit tuple: the worker reads the document straight from the configured fetcher — a file system, Amazon S3, Google Cloud Storage, Azure Blob Storage, and so on — and the configured emitter writes the result straight to its destination, which need not be a file at all: an object store, a search index (OpenSearch, Solr, Elasticsearch), a database, a queue. The bytes never travel over HTTP and the result is never passed back through the front-end.

Whenever a fetcher can reach your inputs and an emitter your destination, the fetch/emit endpoints skip the HTTP body transfer and the result passback — a saving that holds for any fetcher and emitter. What that is worth in throughput depends on the store, and the only combination measured here is local file system on both ends. Those figures, relative to a 3.x single in-JVM parser (= 1.00; higher is faster; one 16-core host, plain-text recursive metadata, concurrency = worker count, per-client isolation):

Document size 4.x sync /rmeta (HTTP upload) 4.x /pipes (fetch/emit)

Small (~50 KB)

0.50

0.80

Medium (~350 KB)

0.73

1.23

Large (multi-MB)

0.81

1.14

Two things to read from it:

  • The classic upload endpoints are slower than 3.x’s in-JVM parsing — by ~2x on tiny documents, shrinking toward ~20% as documents grow and parse time dominates. That is the crash-isolation cost, and it lands on the per-request HTTP path.

  • The fetch/emit path — still fully isolated (per-client: one forked worker per in-flight document) — matches or beats a 3.x in-JVM parser on realistic and large documents, because it drops the HTTP body transfer and the result passback. Only on very small documents does it trail. (The figures are for local file-system fetch and emit; a remote store adds its own latency and bandwidth, but the architecture — fetch, parse in an isolated worker, emit — is unchanged.)

So a file-system fetch-and-emit workload need not choose between 3.x throughput and 4.x isolation: measured file system to file system, /pipes (and /async) delivered both. With other fetchers and emitters you keep the isolation and the skipped HTTP-body/passback, and the extract can land straight in a search index or database instead of round-tripping back through your client — but the throughput then also rides on that store’s own latency and bandwidth, which we have not measured, so treat those cases as architecturally similar rather than numerically equal. The upload endpoints remain the convenient choice for interactive, single-document requests where the bytes are already in hand and isolation — not raw throughput — is what you are buying.

Latency

Pipes adds a fixed floor of roughly tens of milliseconds per request from the IPC round-trip, visible at the median on fast parses.

For the tail, isolating the parse JVM from the HTTP front-end (both pipes modes) keeps a slow or pathological document off the request-accept path. In per-client mode a single slow document occupies only one of numClients workers; in shared-server and single-child modes it occupies one of the shared thread pool’s slots. In practice shared-server can show the best worst-case latency of the shapes here, because it combines a large single heap (fewer, shorter GC stalls than several small heaps) with a front-end that is never blocked by parsing.

The output format also matters: full XHTML, Markdown, plain text, and recursive metadata JSON impose different serialization costs on the same parse. Compare like with like when benchmarking.

Memory

Per-client mode runs numClients heaps; size each for the worst-case single document. Shared-server and single-JVM modes run one heap; size it for the worst-case concurrent load (see Shared-server sizing). Per-client therefore uses more total resident memory but bounds per-document usage: a memory-hungry document can only exhaust its own worker’s heap, not the pool’s. For the per-fork -Xmx and CPU rules of thumb, see Forked-JVM CPU and Heap Sizing.

Isolation and recovery

Every shape below except 3.x --noFork recovers automatically from a crash, OutOfMemoryError, or timeout. They differ in how many in-flight requests a single failure takes down, and whether the HTTP endpoint stays up:

Shape Blast radius HTTP front-end Recovery

In-process (--noFork)

All in-flight

Dies

None — manual restart

Single forked child (3.x default)

All in-flight (shared child)

Brief outage while the child restarts (the child owns the port)

Auto — watchdog restarts child

Shared-server (4.x)

All in-flight (shared worker)

Stays up (separate front-end)

Auto — front-end respawns worker

Per-client (4.x default)

One request (1 of numClients)

Stays up

Auto — only that worker respawns

3.x already provides process isolation in its default configuration: the forked child survives a parser crash, OOM, or timeout because the watchdog restarts it. Only the legacy --noFork mode parses in the server process itself and has no recovery. So the 4.x change is a finer granularity of isolation, not isolation where there was none — per-client mode narrows the blast radius from "all in-flight" to "one request," and both pipes modes keep the HTTP front-end serving while a worker restarts.

Choosing a shape

  • Per-client (default) — hostile or heterogeneous inputs, where one bad document must not disturb the others. Strongest isolation; highest memory; lowest raw throughput.

  • Shared-server — well-behaved inputs where you want throughput close to a single in-JVM parser and a crash-resilient front-end, and can accept that one failure drops all in-flight requests. See Shared Server Mode.

  • Tune numClients and per-fork heap with Forked-JVM CPU and Heap Sizing; configure per-parse limits with Timeouts.

What we found in 4.0.0, and what 4.1.0 changes

Our own regression testing runs tika-app in batch mode over a 1.2-million-file corpus (file system in, file system out) on a box with spinning disks. That run took about 4 hours on Tika 3.x and about 7.5 hours on 4.0.0. The investigation that followed is worth summarizing, because the cause was not where the architecture suggested it would be.

The cause: temp-file volume

4.0.0 wrote 8–30 times more temp bytes than 3.x for the same documents:

  • Digesting an embedded document (MD5/SHA-256 per embedded object) buffered a rewindable copy of it that spilled to a temp file past 1 MB — one file per embedded object, hundreds of thousands of them over a large corpus.

  • Several parsers and detectors asked for a java.io.File even when the document was already in memory: the JPEG/TIFF/WebP metadata extractors, the OLE2 container detector, the OpenDocument parser’s inline pictures, the digest of translated embedded streams, and the PDF incremental-update scan each wrote the bytes out just to read them back.

On a spinning-disk host where the temp directory, the corpus, and the outputs share spindles, every temp byte is a seek taken away from a corpus read or an extract write. Wall clock tracked temp volume almost linearly.

What it was not

Each of these was measured and ruled out, so they need not be re-chased:

  • Pipes IPC and result passback — about 1% of worker time.

  • The driver’s emit path — with the default DYNAMIC strategy the workers already write nearly all extract bytes themselves; more emitter threads made no difference.

  • Reading each container twice for the digest pre-pass — the second read is served from the page cache; disk reads were equal to or lower than 3.x’s.

  • The parsers — on identical embedded objects most 4.x parsers are as fast or faster; the JPEG parser is 4x faster in isolation.

  • 4.x extracting more embedded objects (it does, about 3% more) — negligible cost.

The fix (4.1.0, unreleased at the time of writing)

  • TIKA-4828/TIKA-4829: embedded zip entries are re-read from the archive on rewind instead of being copied, and a process-wide CacheMemoryBudget (seeded by the forked worker, tunable via -Dtika.pipes.cacheMemoryBudgetBytes in forkedJvmArgs, ⇐0 disables) governs how much rewindable content stays in memory.

  • TIKA-4835: the parsers and detectors above no longer spool in-memory input to disk; they rewind or read through a seekable channel, within the same budget, and fall back to a file only past it.

Measured on the diagnosis box (20,000 randomly sampled files of the corpus, page cache evicted before each run, extracts written to the corpus disk):

Build and configuration Temp written Wall

Tika 3.x, 10 consumer threads (three runs)

0.33 GB

245 / 287 / 328 s

4.0.0, per-client, 8 workers (the 7.5 h shape)

10.5 GB

500 s

4.1.0 with TIKA-4828/29 only, corrected config

2.8 GB

341 s

4.1.0, shared server, 10 threads

0.26 GB

277 s

4.1.0, per-client, 7 workers

1.1 GB

287 s

4.1.0 writes less temp than 3.x did, and matches or beats 3.x throughput while keeping process isolation. These are subset measurements on one host; we have not re-timed the full run, and remote emitters (Solr, OpenSearch, S3) were not measured.

A worked configuration: the regression-test box

The diagnosis box is an 8-core/16-thread Ryzen with 62 GB of RAM and two spinning disks in RAID1, holding the temp directory, the 4 TB corpus, and the outputs on the same pair of spindles. The corpus is far larger than RAM, so every run is effectively cold-cache. The configuration we settled on:

  • Per-client mode, numClients = 7. Tika auto-injects -XX:ActiveProcessorCount per fork as (cores − 2) / numClients but only when that slice is at least 2. On 16 logical cores, 8 workers give 1.75 and the cap is skipped — eight JVMs each sizing GC and JIT for 16 cores, which is what the 7.5 h run did. Seven workers get 2 cores each and the cap applies. Per-client cost about 4% versus shared-server here (287 s vs 277 s) and dropped no files, where the shared worker loses the in-flight documents of every other client when one document crashes it.

  • -Xmx4g per fork. The cache budget clamps to a quarter of the fork heap, so this gives each worker 1 GB of in-memory rewind space; seven of them leave about 30 GB for the page cache, which matters more than heap on a cold-cache corpus. Archive-heavy corpora do better with -Xmx6g (1.5 GB budget) — the tar/gz subset only reached parity with the budget raised.

  • Digest MD5 (SHA-256 measured within noise), default emit strategy, temp directory left on disk.

Reading your own deployment

Three questions decided the result above, and they are cheap to answer for any box:

  • Do temp, corpus, and outputs share spindles? /proc/mdstat, lsblk, or your cloud volume layout will say. If they do, temp volume is wall clock; if temp is on separate fast storage, the 4.0.0 regression may never have shown.

  • Is the corpus larger than RAM? If so, benchmark cold — evict the page cache (or use a subset you have not touched) and write outputs to the real destination. Warm-cache runs with outputs on tmpfs hid this entire problem from us for weeks.

  • Does your numClients trip the cap skip? Check the startup log for the ActiveProcessorCount decision; if it reports skipped, lower numClients or set the cap yourself in forkedJvmArgs.

Beyond those: fix concurrency equal to the worker count when comparing, exclude a warm-up phase, hold the output format constant, and watch peak RSS across the whole process tree rather than one JVM.

Appendix: approaches considered and set aside

Levers that were tried against the isolated-mode throughput gap and do not close it, recorded here so they need not be re-litigated:

  • Class-data sharing (CDS / AppCDS). A shared archive measurably speeds worker start-up (class loading is a one-time cost), but class loading is not a steady-state cost, so parsing throughput is unchanged. CDS is still worth having for faster worker cold-start and restart — a resilience/latency benefit that is compatible with the hard-kill lifecycle, since the archive is generated offline and mapped read-only (a worker can be force-killed at any instant). It is not, however, a throughput lever.

  • Uncapping the per-fork CPU view. Raising or removing the auto-injected -XX:ActiveProcessorCount slice makes throughput worse: N forks each sizing their GC and JIT thread pools to the full host core count oversubscribes the cores. The slice (see Forked-JVM CPU and Heap Sizing) is doing its job.

  • Swapping the garbage collector. ParallelGC helped tiny documents marginally and hurt larger ones — no reliable win over the default across a mixed corpus.

  • Driver-side emitter parallelism (numEmitters) and worker-direct emit (EMIT_ALL) for file-system output. Neither moved the batch numbers: under the default DYNAMIC strategy the workers already write nearly all extract bytes directly.

  • A RAM-disk temp directory. It does recover the 4.0.0 batch loss, and it is a useful diagnostic (if moving temp to tmpfs makes a run fast, temp volume is your problem) — but do not run with it. Temp on tmpfs is bounded only by RAM: a single large archive expanding into it can exhaust memory for every process on the host, starve the page cache a cold-corpus run depends on, or count against a container’s memory limit and get the pod evicted. A slow run is recoverable; that is not. 4.1.0 removes the temp writes instead of hiding them.

What remains structural on the upload endpoints is the fixed per-request IPC
result-serialization cost and running several CPU-partitioned JVMs instead of one; the productive directions there are keeping more payloads inline, leaner serialization, and fork-pool sizing — not a single JVM flag.