Performance & Scaling
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
Baseline date: 2026-10-13. This page was written against Apache Tika 4.1.0 (the latest release on Maven Central on that
date) with the 3.3.x differences noted, Java 21 and later for the examples (Tika 4.x needs Java 17, 3.x needs Java 11),
Spring Boot 4.x on Spring Framework 7 for the integration pointers and the Docker image apache/tika:4.1.0 (pin
apache/tika:4.1.0-1). Tika’s speed is rarely limited by one parser. It is limited by the shape of the deployment:
how many JVMs parse, how much CPU and heap each one sees, how much the documents spill to disk, and how the callers react
when the parsers are busy. Tika 4.x moved almost every surface onto forked JVMs (Tika Pipes), which bought crash
isolation and a real backpressure signal, and made sizing your job.
This page covers the deployment shapes and how to choose one, numClients versus cores, memory per fork, temp files and
spooling (including the batch-throughput fix in 4.1.0), parser reuse, write limits and the cost of each output format,
horizontal scaling with 429 as the signal, and the metrics to watch. It pulls together material that the other pages
describe in depth:
Tika Pipes (the forked-JVM model and the pipes configuration),
Tika Server (REST API) (endpoints, 429 and 503, the fork pool),
Robustness, Limits & Timeouts (limits, timeouts and the
fork sizing rules), Docker & Kubernetes Deployment
(container limits, probes, autoscaling manifests) and
Parser API & AutoDetectParser (spooling and thread
safety).
How this page was checked. The CHANGES entries were read in the Apache Tika CHANGES.txt on GitHub (section
"Release 4.1.0 - 9/26/2026"). The sizing formulas, the shared-server trade-offs, the temp-file analysis and the benchmark
figures attributed to the Tika project come from the 4.1.x documentation pages listed in References; this page does
not reproduce that benchmark. The Java examples were compiled and run against Tika 4.1.0 on Java 25 (8 cores, 24 GB of
RAM, macOS), and the tika-server runs used the tika-server-standard-4.1.0.zip distribution bound to 127.0.0.1. The
small measurements in Indicative local measurements are indicative only: they were taken on a busy developer laptop, on a
tiny synthetic corpus, with run-to-run spreads of up to 2x. Use them to see the direction of an effect, never as a
capacity figure. The section What was verified and what was not lists the limits of the checks.
Where the time and memory go
A parse spends time and memory in a handful of places, and each deployment shape moves some of them around:
| Cost | What happens | Who pays it |
|---|---|---|
Start-up |
Building an |
Once per JVM if you reuse the parser; per document if you do not |
Detection |
Magic bytes, the file name and, for containers, a look inside (ZIP central directory, OLE2 directory). 4.1.0 made magic detection about 35% faster on unmatched input (part of the combined TIKA-4868 PERFORMANCE entry). |
Every document |
Parsing |
The concrete parser: PDFBox, POI, jsoup and so on. PDF and Office files with many embedded objects dominate, and OCR or a VLM call dwarfs everything else. |
Every document |
Random access and spooling |
ZIP-family, OLE2 and PDF parsers want random access. A plain |
Documents that arrive as streams, and embedded documents |
Content handler |
Turning SAX events into text, Markdown, HTML or XHTML, and holding the result as a string. |
Every document; grows with output size |
IPC |
In 4.x, the request and the result cross a socket between the caller and a forked worker, and the result is serialized. |
Forked shapes only |
Garbage collection |
Large intermediate objects (images, fonts, XFA, huge strings) and many concurrent parses. |
All shapes, worst with many small heaps |
Two consequences follow. First, for small and medium documents the fixed costs (start-up, IPC, one more JVM) matter as much as parser speed, so reuse everything that can be reused. Second, for a large corpus the dominant cost is often not CPU at all but disk: temp files, the corpus reads and the output writes competing for the same storage. That is what the 4.1.0 fix in Temp files and spooling: the 4.1.0 batch-throughput fix was about.
The deployment shapes
| Shape | How it parses | Isolation and blast radius | Memory | Throughput | Backpressure |
|---|---|---|---|---|---|
In-process |
In your own JVM, on your own threads |
None. An OOM, a native crash or an endless loop hits your application. |
One heap, shared with your application |
Highest: no IPC, one JIT, one GC |
None built in; you get slower |
|
|
One document. Only that fork restarts. |
Your heap plus |
Close to in-process for large documents; the IPC and extra JVMs cost a fixed share |
A caller blocks waiting for a free fork; |
tika-server, per-client (4.x default) |
An HTTP front end routes to |
One request. The HTTP front end stays up. |
Front-end heap plus |
As above, plus HTTP |
|
tika-server, shared server ( |
One forked JVM with |
Every in-flight request. The front end stays up and respawns the worker. |
Front-end heap plus one large fork heap |
Higher than per-client at the same concurrency |
|
Tika Pipes batch ( |
A pipes iterator feeds tuples to |
One document per fork, recorded by the reporter |
Driver heap plus fork heaps |
Highest of the isolated shapes for file-system batch work; storage-bound |
An internal queue ( |
tika-grpc |
gRPC front end over the same pipes machinery; fetchers name the input |
As per-client tika-server |
As per-client tika-server |
Not measured here; same forks as tika-server |
In-band |
The 3.x shapes are useful context when you migrate. tika-server 3.x ran a watchdog parent and one forked child that
did all the parsing, so one bad document dropped every in-flight request and briefly took the port down while the child
restarted; --noFork (or -noFork) parsed in the server process itself with no recovery at all. In 4.x the per-client
default narrows the blast radius to one request, and both pipes modes keep the HTTP front end serving while a worker
restarts. The official performance page frames the 4.x choice as "between per-client (strongest isolation) and
shared-server (highest throughput)".
Source: Performance and Isolation Trade-offs, Apache Tika 4.1.x and Shared Server Mode, Apache Tika 4.1.x.
Per-client versus shared server
The two 4.x modes differ only in how many JVMs parse:
| Aspect | Per-client (default) | Shared server (experimental) |
|---|---|---|
JVMs parsing |
|
1, with |
Failure takes down |
The one request on that fork |
Every in-flight request on the shared worker |
Emit status after a crash |
Unambiguous: the request that crashed is the one that failed |
Ambiguous with |
Heap sizing |
Each fork for the worst single document |
One heap for the worst concurrent load (4 clients at 500 MB each needs at least 2 GB) |
CPU auto-cap |
|
Not applied: one JVM, no thread blow-up |
Thread-safety bugs in parsers |
Hidden: one thread per JVM |
Exposed: parses run concurrently in one JVM |
Throughput |
Lower: several JITs and GCs competing for cores and cache |
Higher: one warm JIT and GC; the project measured it within about 5-15% of a single in-JVM parser on mixed and large documents, per-client about 20-35% slower in that benchmark |
Enable it only when the documents are well behaved, memory is tight, and the application already retries lost requests:
{
"pipes": {
"numClients": 4,
"useSharedServer": true,
"forkedJvmArgs": ["-Xmx4g"],
"emitStrategy": { "type": "PASSBACK_ALL" }
},
"parse-context": {
"timeout-limits": { "progressTimeoutMillis": 60000 }
}
}
Source: Shared Server Mode, Apache Tika 4.1.x and Pipes Configuration, Apache Tika 4.1.x (Emit Strategy).
From Java, PipesForkParserConfig has no setter of its own for this flag, but it exposes the underlying PipesConfig,
which has setUseSharedServer(boolean) (checked against the 4.1.0 jar):
PipesForkParserConfig config = new PipesForkParserConfig()
.setHandlerType(BasicContentHandlerFactory.HANDLER_TYPE.MARKDOWN)
.setNumClients(3)
.addJvmArg("-Xmx2g");
config.getPipesConfig().setUseSharedServer(true); // one fork, three worker threads
Which deployment shape?
Start from two questions: can a bad document be allowed to hurt the caller? and who calls Tika? Untrusted input (uploads, mail attachments, crawled files) rules out in-process parsing on the request path, whatever the throughput gain. The flow below is the decision this section uses for the Bookshelf intake service.
and well behaved?} B -- "yes: your own generated files" --> C{Is it a JVM application
that parses a few files?} C -- yes --> D[In-process AutoDetectParser
one shared instance] C -- no --> F B -- "no: uploads, mail, crawls" --> E{Who calls Tika?} E -- "one JVM application" --> G[PipesForkParser
per-client forks] E -- "many services, other languages" --> F{Interactive requests
or a corpus?} F -- "interactive, per request" --> H[tika-server per-client
behind a load balancer] F -- "a corpus: files, S3, Kafka" --> I[Tika Pipes batch
tika-app -i/-o or tika-async-cli] H --> J{Memory-bound, inputs
tested and well behaved?} I --> J G --> J J -- yes --> K[Consider useSharedServer
and retry lost requests] J -- no --> L[Keep per-client mode]
The same decision as a table, with the reason each row wins:
| Situation | Shape | Why |
|---|---|---|
A build tool or test suite extracting text from files you generated |
In-process |
No hostile input; the fastest path; no extra JVM |
A Spring Boot service accepting uploads, one team, Java only |
|
A poison file kills a fork, not the service. See Spring Boot Integration |
Several services (Java, Python, Node) need extraction |
tika-server per-client, several replicas behind a load balancer |
One shared, language-neutral service; |
Nightly re-index of millions of files to Solr or Elasticsearch |
Tika Pipes batch with a file-system or S3 fetcher and a search emitter |
Isolation per document, a reporter that records every failure, no HTTP hop. See Search Indexing with Lucene, Solr & Elasticsearch |
RAG ingestion from a document store |
Tika Pipes batch, or tika-server with Markdown output |
Markdown is the default and what chunkers want. See RAG & LLM Ingestion |
A memory-tight pod with a vetted, homogeneous corpus |
Shared server |
One heap instead of |
Scanned documents that need OCR |
Any isolated shape with the |
OCR is CPU-heavy and |
What the table does not say: do not mix shapes inside one JVM without thinking about cores. Two PipesForkParser
instances, or tika-server with /async enabled beside the sync endpoints, each size their forks as if they owned the
whole host (see numClients versus cores).
numClients versus cores
numClients is the number of forks in a pool, so it is both the concurrency limit and the divisor for each fork’s CPU
and heap. Each fork is a full JVM that sizes its GC, JIT and common ForkJoinPool from the CPU count it sees: without
help, four forks on a 16-core host would each start about 16 GC threads. Tika Pipes therefore injects a fair CPU slice
into every fork in per-client mode, unless you set -XX:ActiveProcessorCount yourself:
slice = (hostCores - 2) / numClients 2 cores are reserved for the parent JVM
slice < 2 is clamped to 2 1 core for the parser thread, 1 for GC, JIT, heartbeat and socket I/O
Before 4.1.0 a share below 2 switched the cap off instead of clamping it, so an over-provisioned pool (8 forks on 16 logical cores) let every fork size itself for the whole host. The 4.1.0 CHANGES entry reads "The forked-worker CPU cap (-XX:ActiveProcessorCount) is clamped at its floor of 2 (TIKA-4905)", and the project’s performance page names this as one of the reasons its 4.0.0 regression run was slow.
The rule to size by hand is the largest numClients for which numClients × 2 + 2 ≤ hostCores. Beyond that, more
forks make throughput worse, and the parent logs a WARN.
| Host cores | numClients |
Slice | Notes (from the 4.1.x sizing page, plus the 8-core line observed here) |
|---|---|---|---|
2 |
1 |
1, clamped to 2 |
Tight; acceptable for low throughput |
4 |
1 |
2 |
Comfortable single-fork deployment |
4 |
2 |
1, clamped to 2 |
Over-provisioned; use 1 |
8 |
3 |
2 |
Sweet spot for medium pods (observed: |
16 |
4 |
3 |
The project’s measured winner on 16-core hosts |
16 |
6 |
2 |
Higher concurrency, tighter breathing room |
16 |
8 |
1, clamped to 2 |
Does not fit; keep 4 or 6 |
32 |
8 |
3 |
Same shape as 16 and 4 |
When you leave numClients unset, the 4.1.0 default is max(1, min(4, (cores - 2) / 2)) for tika-server and the
pipes configuration; the batch CLIs use 2 and PipesForkParser uses 1 (as read from the 4.1.0 sources by the
Tika Pipes page). Every pipes start-up logs its decision once; grep for
pipes-cpu-sizing. From the tika-server runs for this page on the 8-core laptop:
INFO [main] 23:02:01,156 org.apache.tika.pipes.core.PerClientServerManager pipes-cpu-sizing: hostCores=8, numClients=1, parentReserved=2, autoCap=slice=6, heap=user-set in forkedJvmArgs
INFO [main] 23:02:58,486 org.apache.tika.pipes.core.PerClientServerManager pipes-cpu-sizing: hostCores=8, numClients=3, parentReserved=2, autoCap=slice=2, heap=user-set in forkedJvmArgs
The autoCap field reads slice=N (the cap fired), slice=2 (clamped; fair share would be N) (over-provisioned:
lower numClients) or user-set in forkedJvmArgs.
Further rules:
-
numClientsis a ceiling, not a resident count. Forks start lazily on the first request that needs one, the pool hands out the most recently used fork first (LIFO), and a fork idle forsocketTimeoutMillis(60 s) exits and is restarted on next use. Over-sizing costs little at idle, but the CPU and heap slices are still computed fromnumClients, so a lone warm fork does not get its idle siblings' share. -
Several pools in one JVM do not see each other. tika-server’s sync endpoints and
/asyncare two independent groups; twoPipesForkParserinstances are two groups. Each computes its slice as if it were alone. Scopeserver.endpointsto what you use, or set-XX:ActiveProcessorCountinforkedJvmArgswith the combined total in mind. -
Containers: on JDK 17 and later
Runtime.availableProcessors()honours cgroup CPU limits. A pod withoutresources.limits.cpusees the whole node, so the formula sizes for cores the pod cannot have. Always set CPU limits on pods that run forks. -
OCR: Tesseract is a separate native process per page, and OpenMP threads inside it multiply the load. The 4.1.0
-fullDocker images setOMP_THREAD_LIMIT=1"to avoid oversubscribing the CPU under forked parse workers" (TIKA-4910, TIKA-4866, TIKA-4863). Set it yourself when you install Tesseract outside those images.
Overriding the cap, for a host you know better than the formula (here a pod running both sync and /async pools of 2 on
12 cores, so 4 forks in all):
{
"pipes": {
"numClients": 2,
"forkedJvmArgs": ["-Xmx2g", "-XX:ActiveProcessorCount=2"]
}
}
Source: Forked-JVM CPU and Heap Sizing, Apache Tika 4.1.x,
Tika Server, Apache Tika 4.1.x (Endpoints and
Forked-Process Groups) and CHANGES.txt (Release 4.1.0).
Memory per fork
CPU decides numClients; memory is then divided among however many forks that produced. Check both constraints:
CPU: numClients × 2 + 2 ≤ hostCores
Memory: numClients × per-fork -Xmx + parent heap ≤ 75% of host or container memory
What Tika does when you do not set a heap:
-
It injects
-XX:MaxRAMPercentage=75/numClientsinto each fork (60 for a single fork) whenforkedJvmArgshas no-Xmx,-XX:MaxRAMPercentageor-XX:MaxRAMFraction. The parent keeps the JVM default (about 25% of the container) on top, and metaspace, thread stacks, direct buffers and the page cache come out of what is left. -
Each fork checks its own heap at start-up and logs a WARN below 256 MB, "the point below which ordinary documents, not just pathological ones, begin to fail".
-
With an explicit
-Xmx, the parent warns whennumClients × -Xmxexceeds 75% of memory. It warns rather than fails.
A reasonable starting point is about 2 GB per fork (-Xmx2g). Three constraints drive it: a few pathological PDFs in
any large corpus allocate hundreds of megabytes each; embedded-heavy Office files multiply per-document allocation by the
embedding count; and G1 behaves poorly above about 85% occupancy. To right-size for your corpus, log GC with
-Xlog:gc* in forkedJvmArgs (the forks do not inherit the parent’s flags), read the post-GC working set under real
concurrency, and set -Xmx to about 1.5 times the peak.
The cache memory budget is a quarter of the fork heap
Since 4.1.0, each fork keeps a process-wide CacheMemoryBudget for rewind buffers and in-memory random-access views, so
small embedded objects stay in RAM instead of spilling at the per-object 1 MB threshold. The default is a quarter of the
fork’s maximum heap; the fork logs it at start-up. From the PipesForkParser benchmark runs (-Xmx512m):
INFO [main] 10:07:53,245 [fork] pipesClientId=0 org.apache.tika.pipes.core.server.PipesServer Cache memory budget: 134217728 bytes (default, a quarter of max heap)
Size -Xmx so that three quarters of it covers the parsing working set; the remaining quarter is what the budget may
hold. It is a ceiling on live cached bytes, not a preallocation. A value set with -Dtika.pipes.cacheMemoryBudgetBytes
(plain bytes, in forkedJvmArgs) is clamped to the same quarter-heap ceiling, and a value of 0 or less disables the
budget and restores the per-object threshold. The project’s regression box ran -Xmx4g per fork (a 1 GB budget each)
and found that "archive-heavy corpora do better with -Xmx6g (1.5 GB budget)".
{
"pipes": {
"numClients": 4,
"forkedJvmArgs": ["-Xmx6g", "-Dtika.pipes.cacheMemoryBudgetBytes=1610612736"]
}
}
Other memory you are paying for
| Item | Cost |
|---|---|
Inline bodies ( |
A body at or below this size travels inside the IPC request and touches no disk. The parent holds the payload and a frame containing a copy, and the fork holds it again: budget about |
Results ( |
The fork sends the whole extract back unless it emits it itself. A huge |
The result string |
|
Shared server |
One heap for the worst concurrent load, not the worst single document; no per-document limit contains a hungry document. |
Page cache |
Not heap, but on a cold-cache corpus it matters more: the project’s 62 GB box left about 30 GB for the page cache with 7 forks at 4 GB each. |
A worked budget for a 16-core, 16 GB pod: numClients 4 (4 × 2 + 2 = 10 ≤ 16), -Xmx2g per fork, about 1 GB for
the parent: 4 × 2 GB + 1 GB ≈ 9 GB, under the 75% line (12 GB), leaving room for the kernel, I/O buffers and native
memory. Set the container memory limit to the full 16 GB and the parent heap explicitly (see
Docker & Kubernetes Deployment for the
JAVA_TOOL_OPTIONS trap, which also reaches the forks).
Source: Forked-JVM CPU and Heap Sizing, Apache Tika 4.1.x (Heap per fork) and Pipes Configuration, Apache Tika 4.1.x (Embedded-object cache memory budget, IPC and Inline Payload Limits).
Temp files and spooling: the 4.1.0 batch-throughput fix
Spooling means writing a stream to a temporary file so that a parser can read it with random access. In 4.x, nothing
spools until something asks: a TikaInputStream wrapping a plain InputStream starts in pass-through mode, and only
getFile()/getPath() or enableRewind() make it cache (in memory, spilling to a file past 1 MB). That design is
explained on Parser API & AutoDetectParser. What matters
for throughput is how many bytes end up on disk, and in 4.0.0 that number was far too high.
What happened in 4.0.0
The Tika project’s own regression run, tika-app in batch mode over a 1.2-million-file corpus from file system to file system on spinning disks, took about 4 hours on 3.x and about 7.5 hours on 4.0.0. The diagnosis, as published on the 4.1.x performance page:
-
4.0.0 wrote 8 to 30 times more temp bytes than 3.x for the same documents.
-
Digesting an embedded document (MD5 or SHA-256 per embedded object) buffered a rewindable copy that spilled to a temp file past 1 MB: one temp file per embedded object.
-
Several parsers and detectors asked for a
java.io.Fileeven when the bytes were already in memory: the JPEG, TIFF and WebP metadata extractors, the OLE2 container detector, OpenDocument inline pictures, the PDF incremental-update scan, and the PDF parser’s main load and renderer. -
With temp files, the corpus and the outputs on the same spindles, wall-clock time tracked temp volume almost linearly.
The page also lists what it was not, measured and ruled out: pipes IPC and result passback (about 1% of worker time), the driver’s emit path, the second read of each container for the digest, the parsers themselves, and 4.x extracting about 3% more embedded objects.
What 4.1.0 changed
The CHANGES entries for Release 4.1.0, under PERFORMANCE:
| Issue | CHANGES entry (4.1.0) |
|---|---|
TIKA-4835 |
"Improve spooling/decrease number of spills to disk. The pipes cache memory budget defaults to a quarter of the fork heap (-Dtika.pipes.cacheMemoryBudgetBytes overrides)." |
TIKA-4828 |
"Digesting embedded documents no longer spools each to a temp file; a process-wide CacheMemoryBudget keeps them in memory. Zip-family parsing uses seekable channels, so hasFile() may be false afterward." |
TIKA-4878 |
"Embedded documents in Office files, PDFs, PST and the zip-family containers are re-opened from their container on rewind instead of cached." |
TIKA-4898 |
"PDF incremental-update scanning reads in blocks; ~4x less CPU." |
TIKA-4905 |
The forked-worker CPU cap is clamped at its floor of 2 (listed under fixes). |
TIKA-4877 |
"Temp files follow -Djava.io.tmpdir on the parent JVM (Tika, its libraries and forks); pipes.tempDirectory is deprecated." (breaking changes) |
The project then measured the result as a controlled study: 100,000 documents sampled from the corpus, cold page cache, plain-text extraction with SHA-256 digests, seven workers at 4 GB each, on 4.1.0-SNAPSHOT in August 2026. Its medians:
| Version and shape (the project’s figures) | Wall time (median) | Temp written |
|---|---|---|
Tika 3.x |
25.2 min |
3.3 GB |
4.1.0, shared server |
27.2 min |
1.4 GB |
4.1.0, per-client (default) |
29.0 min |
8.2 GB |
4.0.0, shared server |
41.1 min |
57 GB |
4.0.0, per-client |
42.0 min |
57 GB |
So 4.0.0 to 4.1.0 per-client is about 1.45 times faster, with temp writes falling from 57 GB to 8 GB, and 4.1.0 per-client lands within about 15% of 3.x. The page splits that 15% into about 7% for crossing the process boundary (present in both shapes, inherent to isolation) and up to about 8% more for running several worker JVMs on one box (separate GCs and JIT caches, contention for cores, memory bandwidth and cache). The page’s own caveats: one 100k subset on one host, a snapshot build, remote emitters not measured, and "read these as a snapshot, not a final characterization". This page did not reproduce the study.
Source: Restoring batch
throughput in 4.1.0, Apache Tika 4.1.x and CHANGES.txt
(Release 4.1.0).
What to do about temp files
-
Upgrade to 4.1.x if you run 4.0.0 batch jobs; nothing else recovers the lost throughput as cheaply.
-
Hand Tika a
Pathor abyte[], not an opaque stream.TikaInputStream.get(path)is file-backed and never spools;TikaInputStream.get(bytes)is memory-backed;TikaInputStream.get(new FileInputStream(file))may spool a copy of a file you already had. -
Point
java.io.tmpdirat fast, disk-backed storage on the parent JVM. It has to be a launch flag: POI, PDFBox and the JDK read it once at start-up. Each fork gets a privatepipes-server-*subdirectory under it, which the parent deletes.pipes.tempDirectoryis deprecated since 4.1 and planned for removal in 5.0. -
Never use tmpfs (
/dev/shm, any RAM-backed file system) for it. A RAM disk does recover the 4.0.0 loss, and it is a good diagnostic (if moving temp to tmpfs makes a run fast, temp volume is your bottleneck), but spool size is bounded only by the input: one large archive can exhaust memory for every process, or push a pod over its memory limit. -
Separate the spindles when you can: temp, corpus and output on the same disks turn every temp byte into a seek stolen from a read or a write. On cloud volumes, check the IOPS budget of the temp volume.
-
Give archive-heavy corpora more heap, which also raises the cache budget.
To find which code path writes temp files, record jdk.FileWrite with JFR in the fork (flags go in forkedJvmArgs;
a recording on the parent shows near-zero bytes) and lower the event threshold, because temp spills finish well under
the default 20 ms:
{
"pipes": {
"forkedJvmArgs": [
"-Xmx4g",
"-XX:FlightRecorderOptions=maxchunksize=1m",
"-XX:StartFlightRecording=settings=profile,jdk.FileWrite#threshold=0ms,maxsize=500M,filename=/var/tmp/spill.jfr,dumponexit=true"
]
}
}
Then group the events by path and by the top org.apache.tika frame with jfr print --events jdk.FileWrite
(jfr print --json on a large recording expands to tens of gigabytes). Forks are force-killed on teardown, so the last
chunk can be lost; maxchunksize bounds the loss. This recipe was not run for this page.
Source: Diagnosing temp-file volume in your own run, Apache Tika 4.1.x, TikaInputStream and Spooling, Apache Tika 4.1.x and Pipes Configuration, Apache Tika 4.1.x (Where temporary files go).
Parser reuse
The single biggest avoidable cost is building parsers per document. On the test laptop, constructing an
AutoDetectParser took about four seconds each time (the class path holds the full standard parser package, CharSoup and
the junk detector models):
import org.apache.tika.parser.AutoDetectParser;
public class Ctor {
public static void main(String[] args) {
for (int i = 0; i < 5; i++) {
long t = System.nanoTime();
new AutoDetectParser();
System.out.printf("new AutoDetectParser() #%d: %d ms%n", i + 1, (System.nanoTime() - t) / 1_000_000);
}
}
}
new AutoDetectParser() #1: 6192 ms
new AutoDetectParser() #2: 4806 ms
new AutoDetectParser() #3: 4265 ms
new AutoDetectParser() #4: 4056 ms
new AutoDetectParser() #5: 4325 ms
Indicative, on a laptop under load from other processes; a dedicated server will be faster, but the order of magnitude (seconds, not milliseconds) is the point. In the benchmark below, a fresh parser per document turned about 70 documents per second into 0.1 to 0.2.
Source: Getting Started with the Java API, Apache Tika 4.1.x; the timing was measured against Tika 4.1.0 for this page.
The reuse rules for each shape:
| Object | Rule |
|---|---|
|
One per application, shared by all threads. Do not call its setters once shared. See Thread safety and parser reuse. |
|
One instance; it wraps one parser and one detector. |
|
New per document, closed with try-with-resources. |
|
One per application (it is documented as thread-safe), closed on shutdown. Creating one per request pays a fork start-up (seconds) every time. |
Forks |
Reused automatically; recycled after |
tika-server client |
One |
A fork’s first parse includes its JVM start and parser loading: lazy start means the first request after start-up, after
a crash and after an idle shutdown is slow. In the server runs below, three cold forks took most of the first 14 seconds.
For latency-sensitive services, send a small warm-up document to each fork after start-up (concurrency equal to
numClients), and consider raising socketTimeoutMillis so that forks do not idle out between bursts. 4.2.0 (unreleased)
adds PipesParser.start() and PipesForkParser.start() to bring forks up before the first parse (TIKA-4953).
Write limits and the cost of the output format
Write limits
Every extracted character is held at least once as a string, crosses IPC in the forked shapes, and is written to an index. Most applications need far less than the whole document: a search snippet, a classifier input, the first pages for RAG. Bound it:
{
"parse-context": {
"output-limits": {
"writeLimit": 2000000,
"throwOnWriteLimit": false
},
"embedded-limits": {
"maxCount": 1000
}
}
}
With throwOnWriteLimit false (the default), extraction stops at the limit and the metadata carries
tk:exception:write-limit-reached=true, so the index can mark the document as truncated. The default writeLimit is
-1 (unlimited). A write limit caps output, not parse time: a parser may keep working past the limit, so pair it with
timeout-limits. The full set of limits is on Robustness,
Limits & Timeouts.
Source: Setting Limits, Apache Tika 4.1.x.
Markdown versus XHTML versus text
Markdown is the 4.x default everywhere (tika-app, tika-server, the pipes CLI). Its cost relative to the other handlers depends on the release:
-
4.1.0 made Markdown output "~30x faster" (CHANGES, PERFORMANCE: one combined TIKA-4868 entry that also covers faster detection and a stricter
Content-Typeoverride). If you measured Markdown as expensive on 4.0.0, measure again. -
XHTML and HTML carry more characters: every element, attribute and namespace is serialized. On the synthetic corpus below, XML output was about 14% larger than Markdown and about 26% larger than plain text, and HTML was the slowest handler in the single-thread runs.
-
Plain text is the cheapest to produce and store, and loses headings, lists and links.
-
IGNORE(or/rmeta/ignore) skips content entirely when you need only metadata, and the parse still runs.
Choose by what consumes the output, not by handler speed: the handler is a small share of a parse. Use Markdown for RAG
and chunking (tk:chunks and the chunkers split on headings), text for a plain full-text index, XHTML only when you post-
process the structure. The handler choices are on
Content Handlers & Output Formats.
Parse mode and emit strategy in batch work
-
RMETA(one metadata object per document, embedded ones included) costs more thanCONCATENATE(one object, all text concatenated) because every embedded document gets its own metadata and content; useCONCATENATEfor a plain full-text index andRMETAwhen embedded documents become their own search hits or chunks. -
emitStrategyDYNAMIC(default, threshold 100,000 bytes) lets forks write large extracts themselves; the project found that more emitter threads (numEmitters) andEMIT_ALLdid not move file-system batch numbers. Change it only for a measured memory or throughput problem. -
For search emitters, batch the emits (
emitMaxEstimatedBytes,emitWithinMillis) rather than one request per document; see Tika Pipes.
Indicative local measurements
To see the direction of the effects above, a small Java probe parsed a synthetic Bookshelf corpus of 11 files 20 times
(220 parses per run) after one warm-up pass: the small sample files (manuscript.docx, catalogue.pdf,
publisher-email.eml, samples.zip, novel.epub, reviews-es.html, legacy-catalogue.txt, renamed-invoice.png)
plus three larger generated ones (a 60-page catalogue-large.pdf, a 1,650-paragraph manuscript-large.docx and a 660 KB
reviews-large.html). scanned-loan-form.pdf was left out because Tesseract was not installed.
|
These numbers are indicative only. They come from one 8-core laptop that was running other heavy workloads at the same time (load averages from about 9 to several hundred during the runs), a tiny corpus that fits in the page cache, and two or three repetitions per cell, with spreads of up to 2x between repetitions. They are not capacity figures and they do not compare with the project’s benchmark above. Measure your own corpus on your own hardware. |
| Configuration (Tika 4.1.0, Java 25) | Documents per second (runs) | Characters out |
|---|---|---|
In-process, shared |
70.5, 68.4, 78.7 |
27.5 million |
In-process, shared parser, 1 thread, |
69.9, 69.8, 67.3 |
30.3 million |
In-process, shared parser, 1 thread, |
67.0, 63.9, 44.3 |
34.6 million |
In-process, shared parser, 1 thread, |
52.1, 53.6, 57.2 |
34.6 million |
In-process, new parser per document, 1 thread, |
0.1, 0.2 |
1.5 million |
In-process, shared parser, 4 threads, |
101.2, 59.0, 48.4 |
30.3 million |
In-process, shared parser, 4 threads, |
80.1, 84.8 |
34.6 million |
|
33.5, 13.9 |
30.3 million |
|
72.9, 60.4 |
30.3 million |
|
60.9, 69.4 |
30.3 million |
What can be read from it, and no more:
-
A new
AutoDetectParserper document is catastrophic (two to three orders of magnitude). -
On these small documents, a single fork is about half the in-process speed: the per-request boundary (IPC, serialization) is a large share when each parse takes milliseconds. The project’s figure of about 7% for that boundary is for a real corpus of larger documents.
-
Three forks recover in-process single-thread throughput; per-client and shared server were within noise of each other at three workers here.
-
The output format changed the volume more than the speed, except that
HTMLwas consistently slowest single-threaded.
The probe (compiled and run for this page; the fork runs use CONCATENATE so that one string comes back per document):
package demo;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Stream;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.parser.AutoDetectParser;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.Parser;
import org.apache.tika.pipes.api.ParseMode;
import org.apache.tika.pipes.fork.PipesForkParser;
import org.apache.tika.pipes.fork.PipesForkParserConfig;
import org.apache.tika.pipes.fork.PipesForkResult;
import org.apache.tika.sax.BasicContentHandlerFactory;
import org.apache.tika.sax.BasicContentHandlerFactory.HANDLER_TYPE;
import org.xml.sax.ContentHandler;
/**
* Indicative throughput probe for the Bookshelf corpus.
* Bench <corpusDir> <rounds> inproc <TEXT|MARKDOWN|XML|HTML> <threads> <reuse|new>
* Bench <corpusDir> <rounds> fork <TEXT|MARKDOWN|XML> <threads> <numClients> <perclient|shared>
*/
public class Bench {
public static void main(String[] args) throws Exception {
Path corpus = Path.of(args[0]);
int rounds = Integer.parseInt(args[1]);
String mode = args[2];
HANDLER_TYPE type = HANDLER_TYPE.valueOf(args[3]);
int threads = Integer.parseInt(args[4]);
List<Path> files;
try (Stream<Path> s = Files.list(corpus)) {
files = s.filter(Files::isRegularFile).sorted().toList();
}
List<Path> work = new ArrayList<>();
for (int r = 0; r < rounds; r++) {
work.addAll(files);
}
if (mode.equals("inproc")) {
boolean reuse = args[5].equals("reuse");
Parser shared = new AutoDetectParser();
Task task = file -> {
Parser parser = reuse ? shared : new AutoDetectParser();
ContentHandler handler = new BasicContentHandlerFactory(type, -1).createHandler();
try (TikaInputStream tis = TikaInputStream.get(file)) {
parser.parse(tis, handler, new Metadata(), new ParseContext());
}
return handler.toString().length();
};
run(task, files, 1); // warm-up: one pass over the corpus
report(args, run(task, work, threads), work.size());
} else {
int numClients = Integer.parseInt(args[5]);
PipesForkParserConfig config = new PipesForkParserConfig()
.setHandlerType(type)
.setParseMode(ParseMode.CONCATENATE)
.addJvmArg("-Xmx512m")
.setNumClients(numClients);
config.getPipesConfig().setUseSharedServer(args[6].equals("shared"));
try (PipesForkParser parser = new PipesForkParser(config)) {
Task task = file -> {
PipesForkResult result = parser.parse(file);
if (!result.isSuccess()) {
throw new IllegalStateException(file + " " + result.getStatus());
}
String content = result.getContent();
return content == null ? 0 : content.length();
};
// warm-up: start every fork before timing
run(task, work.subList(0, Math.min(work.size(), files.size() * numClients)), threads);
report(args, run(task, work, threads), work.size());
}
}
}
interface Task {
long apply(Path file) throws Exception;
}
static long[] run(Task task, List<Path> work, int threads) throws Exception {
AtomicLong chars = new AtomicLong();
long start = System.nanoTime();
try (ExecutorService pool = Executors.newFixedThreadPool(threads)) {
List<Future<?>> futures = new ArrayList<>();
for (Path file : work) {
futures.add(pool.submit(() -> {
chars.addAndGet(task.apply(file));
return null;
}));
}
for (Future<?> f : futures) {
f.get();
}
}
return new long[] {System.nanoTime() - start, chars.get()};
}
static void report(String[] args, long[] r, int docs) {
double secs = r[0] / 1e9;
System.out.printf("%-48s docs=%d wall=%.2fs docs/s=%.1f chars=%d%n",
String.join(" ", List.of(args).subList(2, args.length)), docs, secs, docs / secs, r[1]);
}
}
Run it with the dependencies of the Tika Pipes page
(tika-pipes-fork-parser, tika-pipes-core, tika-parsers-standard-package as a pom dependency, an SLF4J binding).
Sample output lines:
inproc MARKDOWN 1 reuse docs=220 wall=3.15s docs/s=69.9 chars=30321440
inproc MARKDOWN 1 new docs=11 wall=59.26s docs/s=0.2 chars=1516072
fork MARKDOWN 3 3 perclient docs=220 wall=3.02s docs/s=72.9 chars=30321440
fork MARKDOWN 3 3 shared docs=220 wall=3.17s docs/s=69.4 chars=30321440
For your own benchmark, follow the project’s advice: fix concurrency equal to the worker count when comparing, exclude a warm-up phase, hold the output format constant, benchmark cold when the corpus is larger than RAM (evict the page cache, write outputs to the real destination), and watch peak RSS across the whole process tree, not one JVM.
Horizontal scaling with 429 as the signal
A 4.x tika-server knows when it is full. When every fork in a pool is busy and none frees up within
pipes.maxWaitForClientMillis (default 60 s), the request fails fast with 429 Too Many Requests, a Retry-After
header and {"status":"CLIENT_UNAVAILABLE_WITHIN_MS"}. That is deliberately different from 503, which means a fork
crashed, ran out of memory or timed out on this document. The official server page says the distinction "is worth
building on": clients back off on 429, load balancers shed load, queue consumers slow their fetch rate, autoscalers get
a signal. In its words, "a 429 spike means raise numClients or add capacity, a 503 spike means something is actually
crashing".
or upload API] --> C[Bookshelf extract client
retry with Retry-After] C --> LB[Load balancer
least outstanding requests] LB --> S1[tika-server pod 1
numClients forks] LB --> S2[tika-server pod 2
numClients forks] LB --> S3[tika-server pod N] S1 -- "429 busy" --> C S1 -- "503 fork died" --> DLQ[Quarantine
dead-letter] P[Prometheus] -. scrapes metricsPort .-> S1 P -. scrapes .-> S2 P --> HPA[HPA or KEDA
busy ratio, 429 rate] HPA -. adds or removes pods .-> S3
The client side
The client must treat 429 as "try again later, maybe elsewhere", honour Retry-After, add jitter so that many clients
do not retry in lockstep, and cap the retries. 503 gets one retry at most, then quarantine. Everything else is final.
A JDK HttpClient version for the Bookshelf intake:
package demo;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
/** Sends Bookshelf files to tika-server and treats 429 as backpressure, 503 as a poison document. */
public class BackpressureClient {
sealed interface Outcome permits Extracted, Quarantined, GaveUp { }
record Extracted(String markdown) implements Outcome { }
record Quarantined(int status, String body) implements Outcome { }
record GaveUp(int attempts) implements Outcome { }
private static final int MAX_BUSY_RETRIES = 6;
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
private final URI endpoint;
BackpressureClient(URI endpoint) {
this.endpoint = endpoint;
}
Outcome extract(Path file) throws IOException, InterruptedException {
boolean retriedCrash = false;
for (int attempt = 1; attempt <= MAX_BUSY_RETRIES; attempt++) {
HttpRequest request = HttpRequest.newBuilder(endpoint)
.timeout(Duration.ofMinutes(5))
.PUT(HttpRequest.BodyPublishers.ofFile(file))
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
switch (response.statusCode()) {
case 200 -> {
return new Extracted(response.body());
}
case 429 -> {
// Busy, nothing failed: wait at least Retry-After, plus jitter so clients spread out.
System.out.println(file + ": 429, attempt " + attempt);
long seconds = response.headers().firstValueAsLong("Retry-After").orElse(2L);
long jitter = ThreadLocalRandom.current().nextLong(0, 1000L * attempt);
Thread.sleep(seconds * 1000L + jitter);
}
case 503 -> {
// A fork died on this document. One retry, then quarantine it.
if (retriedCrash) {
return new Quarantined(503, response.body());
}
retriedCrash = true;
long seconds = response.headers().firstValueAsLong("Retry-After").orElse(5L);
Thread.sleep(seconds * 1000L);
}
default -> {
return new Quarantined(response.statusCode(), response.body());
}
}
}
return new GaveUp(MAX_BUSY_RETRIES);
}
public static void main(String[] args) throws Exception {
BackpressureClient client = new BackpressureClient(URI.create(
args.length > 0 ? args[0] : "http://localhost:9998/tika/md"));
List<Path> files = args.length > 1
? java.util.Arrays.stream(args, 1, args.length).map(Path::of).toList()
: List.of(Path.of("manuscript.docx"), Path.of("catalogue.pdf"));
for (Path file : files) {
Outcome outcome = client.extract(file);
switch (outcome) {
case Extracted e -> System.out.println(file + ": " + e.markdown().length() + " chars");
case Quarantined q -> System.out.println(file + ": quarantined, HTTP " + q.status());
case GaveUp g -> System.out.println(file + ": still busy after " + g.attempts() + " attempts");
}
}
}
}
Do not send an Accept: text/markdown header to /tika/md: the path already chooses the format, and the first version of
this client got 406 Not Acceptable for doing both.
To see the backpressure work, tika-server 4.1.0 was started with numClients 1 and maxWaitForClientMillis 2000, and
four copies of the client were started 0.3 s apart, each sending a 99 MB text file (huge-catalogue.txt) that keeps a
fork busy for a few seconds:
{
"server": { "host": "127.0.0.1", "port": 9998, "metricsPort": 9404, "endpoints": ["tika", "rmeta", "version"] },
"pipes": { "numClients": 1, "maxWaitForClientMillis": 2000, "forkedJvmArgs": ["-Xmx1g"] }
}
huge-catalogue.txt: 98993203 chars
huge-catalogue.txt: 429, attempt 1
huge-catalogue.txt: 429, attempt 2
huge-catalogue.txt: 98993203 chars
huge-catalogue.txt: 429, attempt 1
huge-catalogue.txt: 429, attempt 2
huge-catalogue.txt: 98993203 chars
huge-catalogue.txt: 429, attempt 1
huge-catalogue.txt: 429, attempt 2
huge-catalogue.txt: 98993203 chars
Every document was extracted; the metrics port then showed tika_server_rejected_total{reason="busy_429"} 6.0 and
four 2xx requests. Restarted with numClients 3 (the log showed autoCap=slice=2), the same four clients got only two
429 answers: one client waited for one of the three forks. The total wall time was about 14 s in both runs, because
the three forks of the second run started cold on the first requests (lazy start) and one large text file is mostly
I/O. A cold pool is slower than a warm one; warm it up before you measure.
Source: Backpressure,
Tika Server, Apache Tika 4.1.x and Monitoring with
Prometheus, Apache Tika 4.1.x. In Spring, the same mapping (429 to retry, 503 to dead-letter) is on
Spring Boot Integration; for retry and circuit-breaker libraries
see the site’s Resilience section.
Tuning maxWaitForClientMillis
The wait is how long a request may sit in the server, holding a request thread and a connection, before it gets 429.
It is also the Retry-After value the server sends.
-
Short (1 to 5 s): backpressure reaches the client quickly, so the client or the load balancer can try another pod. Use it with several replicas and a retrying client.
-
Long (the default, 60 s): the server absorbs bursts by queueing. Use it with one server and clients that cannot retry, and set the client’s own request timeout above
maxWaitForClientMillisplus the parse timeout, or the client gives up before the server answers.
The load balancer
-
Prefer least-outstanding-requests (or least-connections) balancing over round robin: parse times vary by orders of magnitude, and round robin keeps sending work to a pod whose forks are all stuck on PDFs.
-
Do not let the load balancer retry non-idempotent uploads blindly, but a retry of a
429on another pod is safe: nothing was parsed. Many proxies (Envoy, NGINXproxy_next_upstream, cloud load balancers) can retry on429only. -
Set the proxy’s read timeout above the longest parse (
totalTaskTimeoutMillis, one hour by default in 4.x; set it lower), or the proxy turns slow parses into504answers while the fork keeps working. -
Size request bodies at the edge as well as with
server.maxRequestSizeBytes. -
Health checks go to
GET /versionon the parse port, never to/metrics; a full pool must not fail a liveness probe, or Kubernetes kills the pods that are doing the most work.
The autoscaler
Scale on saturation, not CPU. A pod whose forks are busy with OCR shows high CPU, but a pod whose forks wait on a slow fetcher or a remote VLM is saturated at low CPU, and the parent JVM’s CPU tells you nothing because it does not parse. The signals the monitoring page recommends:
-
Sync endpoints: the busy ratio
tika_pipes_workers{state="busy"}over all workers, and the rate oftika_server_rejected_total{reason="busy_429"}. Both "move before latency does, which CPU does not". -
/async:tika_pipes_queue_depth; there is no busy or idle gauge for that pool.
With Kubernetes, expose the busy ratio as a per-pod custom metric (prometheus-adapter) or use KEDA’s Prometheus scaler,
target an average busy ratio around 0.7, and give scale-down a long stabilization window so that pods with in-flight
parses are not removed after every burst. A pod that is removed mid-parse drops its requests; a preStop delay and a
termination grace period longer than a typical parse help. The manifests are on
Docker & Kubernetes Deployment; this autoscaling setup
was not tested for this page. For the Kubernetes mechanics see Autoscaling.
Batch work scales differently: Tika Pipes has no 429 between the iterator and the forks, only its internal queue. Scale a
batch out by partitioning the input (by prefix, by date, by Kafka partition) across several pipes processes, each with
its own numClients, not by putting one batch behind a load balancer.
Metrics to watch
Metrics are off by default. Set server.metricsPort (or --metricsPort) to a port different from the parse port, and
keep it reachable by the scraper only. The scrape listener serves only /metrics, has its own small thread pool and
caps open connections at 64, so a scrape never waits behind a slow parse.
| Meter | Type | Watch it for |
|---|---|---|
|
gauge |
Saturation: |
|
counter |
|
|
counter |
Alert on |
|
gauge |
Saturation for |
|
timer |
Latency per endpoint; twelve fixed buckets from 10 ms to 120 s, so |
|
gauge |
Sync parse and detect tasks in flight ( |
|
summary |
Input sizes as declared by |
|
various |
The parent JVM only. It routes requests and holds results; a near-idle heap here is not headroom. |
There is deliberately no GC or CPU binder, because both would describe a process that does not parse. For the forks' CPU and memory, use container metrics (cAdvisor, node-exporter), and look at the whole process tree for memory.
# Saturation per pod: share of busy forks (scale on this)
sum by (pod) (tika_pipes_workers{state="busy"}) / sum by (pod) (tika_pipes_workers)
# Backpressure: requests turned away because no fork was free in time
sum(rate(tika_server_rejected_total{reason="busy_429"}[5m]))
# Failures: forks killed by documents (alert on these)
sum by (reason) (rate(tika_pipes_worker_restarts_total{reason=~"oom|timeout|crash"}[5m]))
sum(rate(tika_server_rejected_total{reason="crash_503"}[5m]))
# Latency: p95 per endpoint
histogram_quantile(0.95, sum by (le, endpoint) (rate(tika_server_requests_seconds_bucket[5m])))
What to do when a signal moves:
| Symptom | Likely cause and action |
|---|---|
Busy ratio near 1, |
Real capacity limit: add pods. Raise |
Busy ratio near 1, CPU low |
Forks wait on I/O (fetchers, temp disk, remote OCR/VLM). Add pods or fix the slow dependency; more forks per pod may help here, since the CPU rule assumes CPU-bound parsing. |
|
Several pools in one server (sync and |
|
A few documents need more heap: raise |
|
Pathological documents or OCR on large scans. Lower |
p95 latency rising with stable busy ratio |
Larger documents, cold forks after restarts, or temp-disk contention. Check |
Parent |
Large results held in the parent ( |
For a batch run, the equivalents are the reporter’s records (file-system-jsonl-reporter records a batch run’s
crashes, new in 4.1.0), the pipes-cpu-sizing line, the fork start-up lines (heap and cache budget) and the temp
directory’s size over time.
A worked sizing for the Bookshelf intake service
The Bookshelf intake receives uploads (manuscripts, catalogues, publisher e-mail with attachments, scanned loan forms) from three services and re-indexes the archive every night. Applying the page:
-
Interactive path: tika-server per-client, three replicas of 8 cores and 12 GB each.
numClients3 (3 × 2 + 2 = 8),-Xmx2gper fork (6 GB of forks plus a 1 GB parent, under the 9 GB line),maxWaitForClientMillis3000 so that a busy pod answers429quickly and the client retries on another,writeLimit2,000,000 characters, Markdown output for the RAG pipeline. -
Scanned documents: routed by the client to a separate tika-server deployment on the
-fullimage withnumClients2 and a longertotalTaskTimeoutMillis, so OCR cannot starve ordinary uploads. -
Nightly re-index:
tika-appin pipes mode on a batch node, file-system fetcher and Solr emitter,numClientssized by the CPU rule,java.io.tmpdiron a local SSD separate from the archive volume, Tika 4.1.x. -
Autoscaling: on the busy ratio (target 0.7) with a 10-minute scale-down window; alerts on
oom,timeoutandcrashrestarts.
{
"server": {
"host": "0.0.0.0",
"port": 9998,
"metricsPort": 9404,
"endpoints": ["tika", "rmeta", "meta", "detect", "version"]
},
"pipes": {
"numClients": 3,
"maxWaitForClientMillis": 3000,
"forkedJvmArgs": ["-Xmx2g"]
},
"parse-context": {
"output-limits": { "writeLimit": 2000000 },
"embedded-limits": { "maxCount": 1000 },
"timeout-limits": { "totalTaskTimeoutMillis": 300000, "progressTimeoutMillis": 60000 }
}
}
The configuration keys were checked against the 4.1.x documentation and the configuration shown on Tika Server (REST API); this combined file was not started as is.
From 1.x to 4.x: what changed for performance
| Release | Performance-relevant change |
|---|---|
1.x (Tika in Action) |
In-process parsing as the norm; |
2.x |
tika-server gained the watchdog and forked child by default ( |
3.x |
Java 11. Pipes matured; the server’s single child still meant shared fate for in-flight requests. The project’s 1.2M-file regression run took about 4 hours. |
4.0.0 |
Every tika-server parsing endpoint, |
4.1.0 |
Spooling fixes (TIKA-4828, TIKA-4835, TIKA-4878) and the CPU-cap clamp (TIKA-4905) bring batch throughput to within about 15% of 3.x; Markdown about 30 times faster; faster detection and PDF incremental-update scanning; Micrometer metrics; |
4.2.0 (unreleased) |
|
Tika in Action (chapter 9) treats robustness and ForkParser as an option; in 4.x process isolation is the default and
throughput tuning is mostly about forks, heap and disk. The release notes are summarized on
What’s Changed & Migration.
Pitfalls
-
A new
AutoDetectParserorPipesForkParserper request. Seconds each. Build once, share, close on shutdown. -
Copying
numClientsfrom an example. The official server page warns that the value in example configurations "is not safe boilerplate". Size it with the CPU rule, then check memory. -
No CPU limit on the pod. The forks size themselves for the whole node.
-
JVM flags on the parent that were meant for the forks.
-Xmx,-Xlog:gc, JFR and-Dtika.pipes.cacheMemoryBudgetBytesbelong inforkedJvmArgs.JAVA_TOOL_OPTIONS, on the other hand, reaches every JVM, forks included. -
tmpfs for
java.io.tmpdir. Fast until one archive takes the host down. -
Benchmarking warm. A corpus in the page cache with outputs on tmpfs hid the 4.0.0 regression from the project "for weeks". Benchmark cold when the corpus is larger than RAM.
-
Benchmarking a cold pool. Lazy start puts JVM start-up into the first requests; warm the forks first.
-
Scaling on CPU. Saturation and
429move first; the parent’s CPU means nothing. -
Retrying
429without honouringRetry-Afteror without jitter. The clients synchronize and hammer the pool. -
Treating
503like429. A document that killed a fork will kill the next one too. Retry once, then quarantine. -
Unlimited output.
writeLimit-1plus/rmetaon a mailbox puts the whole mailbox’s text in two JVMs at once. -
Shared server for throughput on untrusted input. One poison file drops every in-flight request, and with fork-side emitting you cannot tell which were emitted.
-
Two pools on one host without a combined CPU cap. Each pool believes it owns the machine.
Checklist
-
The deployment shape matches the trust level of the input and the callers (Which deployment shape?).
-
One
AutoDetectParser,PipesForkParserand HTTP client per application, all reused. -
numClients × 2 + 2 ≤ coresandnumClients × -Xmx + parent heap ≤ 75%of memory; thepipes-cpu-sizingline shows no clamp. -
Pods have CPU and memory limits;
OMP_THREAD_LIMIT=1where Tesseract runs. -
java.io.tmpdiron disk-backed, fast storage, not tmpfs; running 4.1.x or later for batch work. -
writeLimit,embedded-limitsandtimeout-limitsset for the use case. -
Output format chosen by the consumer (Markdown for RAG, text for a plain index).
-
Clients retry
429withRetry-Afterand jitter, quarantine after one503. -
Load balancer uses least outstanding requests, has a read timeout above the parse timeout, and probes
/version. -
Metrics enabled on a private port; autoscaling on the busy ratio or the
429rate; alerts onoom,timeout,crashrestarts. -
Benchmarks run cold, with fixed concurrency, warm forks and the real output destination.
What was verified and what was not
Verified for this page:
-
The 4.1.0 CHANGES entries quoted above (TIKA-4828, TIKA-4835, TIKA-4868, TIKA-4877, TIKA-4878, TIKA-4898, TIKA-4905, TIKA-4910) and the 4.2.0 entries (TIKA-4950, TIKA-4953), read in
CHANGES.txton the Tikamainbranch, where 4.2.0 is marked unreleased. -
PipesConfig.setUseSharedServer,getMaxWaitForClientMillisanddefaultNumClientsexist in the 4.1.0tika-pipes-corejar;PipesForkParserConfig.getPipesConfig()and theBasicContentHandlerFactory.HANDLER_TYPEvaluesBODY,IGNORE,TEXT,HTML,XMLandMARKDOWNexist in 4.1.0. -
The
Bench,CtorandBackpressureClientprograms compiled and ran against Tika 4.1.0 on Java 25. The output shown is real; the fork log lines and the cache budget line come from those runs. -
tika-server 4.1.0 (zip distribution): the
pipes-cpu-sizinglines fornumClients1 and 3 on 8 cores, the429retries, and thebusy_429counter values shown.
Not verified:
-
The project’s benchmark figures (the 3.x, 4.0.0 and 4.1.0 table, the 15% and 7% splits, the shared-server 5-15% figure) and the regression-box configuration: quoted from the 4.1.x documentation, not reproduced.
-
The local measurements were taken on a heavily loaded laptop and are indicative only (see the warning in Indicative local measurements). No measurement on Linux, in a container or on a cold cache was made.
-
The JFR recipe, the shared-server JSON with
PASSBACK_ALL, the-XX:ActiveProcessorCountoverride, the cache-budget override and the worked Bookshelf configuration were not run as shown. -
The load balancer, HorizontalPodAutoscaler and KEDA guidance is general practice applied to Tika’s documented signals; none of it was deployed for this page.
-
Memory figures for strings (about 200 MB for a 99-million-character result) are estimates from UTF-16 sizing, not a heap measurement.
-
tika-grpc throughput was not measured; its row in the shapes table follows the Tika gRPC page (same pipes machinery, in-band
CLIENT_UNAVAILABLE_WITHIN_MS).
References
-
Apache Tika 4.1.x, Getting Started with the Java API and Tika Pipes from Java
-
Apache Tika 4.1.0 Javadoc (
org.apache.tika.pipes.core.PipesConfig,org.apache.tika.pipes.fork,org.apache.tika.sax.BasicContentHandlerFactory) -
Chris A. Mattmann and Jukka L. Zitting, Tika in Action, Manning, 2011, chapter 9 (robustness and
ForkParser, superseded by Tika Pipes in 4.x)