DEV Community

Cover image for Efficient and Effective XML Processing in Java
Robson Kades
Robson Kades

Posted on

Efficient and Effective XML Processing in Java

Extracting a value from an XML document costs, at its core, two operations: locating the span of bytes where the value lives and converting it to the type you want. Turning 219.80 into a BigDecimal is a scan to the element and a six-byte parse — nanoseconds, allocating next to nothing.

Almost no Java code pays that price. It pays a different one, orders of magnitude larger: the whole document decoded from UTF-8 to UTF-16, every tag name materialized as a String, every element promoted to an object — whether you need it or not. The classic APIs were designed to represent documents, and we use them to extract values. The difference goes unnoticed on one document; in a service handling millions a day — and XML is still the language of e-invoicing, financial messaging and all the legacy SOAP nobody is ever going to migrate — it is your entire CPU profile. That distance is not mandatory, and closing it is what this article is about.

The invisible cost of the “easy way”

DOM materializes the entire document as an object tree — every element, attribute and text node becomes a heap instance. To read three fields out of a 7 KB document, you allocate the full representation of those 7 KB, navigate to the three values, and throw the tree away. Multiply by millions of documents and the garbage collector becomes the main character of your latency graph.

JAXB (and data binding in general) hides the tree behind annotated classes, which is great for ergonomics — but it still materializes everything, and layers reflection and conversions on top. There is a quiet maintenance cost too: a typical e-invoice schema has hundreds of fields; you generate
classes for all of them to use half a dozen.

StAX is the traditional answer for performance: streaming, one event at a time, no tree. The problem is that nobody likes the code it produces. A while (reader.hasNext()) loop with a hand-rolled state switch quickly grows into something hard to read and harder to change — and every element still becomes an allocated event, every tag name still becomes a String, whether you need it or not.

Goal-directed extraction: pay for what you read

That observation — extraction, not parsing — is the premise of Fletch, a library I wrote after facing exactly the scenario above while processing electronic invoices at volume. The idea: you declare what you want, and the engine makes a single pass over the document’s bytes materializing only that. No tree, no reflection, no generated classes, no dependencies.

Installation

Maven

<dependency>
    <groupId>io.github.robsonkades</groupId>
    <artifactId>fletch</artifactId>
    <version>1.2.0</version>
</dependency>
Enter fullscreen mode Exit fullscreen mode

Gradle

implementation("io.github.robsonkades:fletch:1.2.0")
Enter fullscreen mode Exit fullscreen mode

The primary API is a cursor that navigates elements. Given this document:

<order id="1042" urgent="true">
    <customer>
        <name>Ana Souza</name>
        <address><city>Curitiba</city><zip>80000-000</zip></address>
    </customer>
    <item><sku>AB-1</sku><qty>2</qty><price>49.90</price></item>
    <item><sku>CD-2</sku><qty>1</qty><price>120.00</price></item>
    <total>219.80</total>
</order>
Enter fullscreen mode Exit fullscreen mode

extraction is a composition of functions, one per element shape:

record Item(String sku, Integer qty, BigDecimal price) {}
record Order(Long id, List<Item> items, BigDecimal total) {}

static final XmlExtractor<Item> ITEM = i -> new Item(
        i.value("sku", String.class),
        i.value("qty", Integer.class),
        i.value("price", BigDecimal.class));

static final XmlExtractor<Order> ORDER = o -> new Order(
        o.attribute("id", Long.class),
        o.children("item", ITEM),
        o.value("total", BigDecimal.class));

Order order = Xml.extract(xml, doc -> doc.child("order", ORDER));
Enter fullscreen mode Exit fullscreen mode

Notice what is not there: no event loop, no manual state, no classes beyond the records you already wanted. And one detail that matters in daily use: the cursor is order-tolerant. You read fields in the order that makes sense for your record; if the XML delivers them in a different order, the engine copes — reading in document order costs nothing, reading out of order costs a cheap re-scan of a byte range, not event materialization.

For the I want 8 fields out of a 300-field document case there is a second,
declarative style that compiles the paths once into a table and is faster
still:

static final XmlMapping<Order> ORDER = Xml.mapping(Draft::new)
        .attr("/order@id", (d, v) -> d.id = v.asLong())
        .group("/order/item", ItemDraft::new, (d, i) -> d.items.add(i.toItem()))
            .text("sku",   (i, v) -> i.sku = v.asString())
            .text("qty",   (i, v) -> i.qty = v.asInt())
            .text("price", (i, v) -> i.price = v.asDecimal())
            .endGroup()
        .text("/order/total", (d, v) -> d.total = v.asDecimal())
        .build(Draft::toOrder);

Order order = Xml.extract(bytes, ORDER); // thread-safe, reuse freely
Enter fullscreen mode Exit fullscreen mode

The mapping has one property the cursor doesn’t: over a UTF-8 InputStream it truly streams, through a 64 KB sliding window — you can process a batch larger than memory without thinking about it.

Why it’s fast

Nothing here is magic; it is the sum of small, consistent decisions:

  • One pass, one loop. Tokenization, name matching and value decoding run fused, directly over the bytes. There are no intermediate event objects.

  • Names never become Strings. Elements are compared by a hash computed with 8-byte word loads (SWAR — SIMD within a register), allocating nothing

  • Subtrees you didn’t ask for are skipped by balance counting at memchr speed, without ever reading the tag names.

  • Values are spans, not copies. A number is converted straight from the document’s bytes into a long or BigDecimal; text only becomes a String if you ask for one.

  • Extraction stops when you’re done. If the fields you need sit at the start of the document, the rest is never read.

Adding it up: the engine pays the essential cost of extraction — locating spans and converting them — and almost nothing else.

Numbers

JMH benchmark from the repository, over a real 7 KB electronic invoice (a Brazilian NF-e), varying the number of line items: 1, 50 and 500. Environment: i7–13700K, Temurin/JDK 25. The comparison baseline is a hand-written StAX/Woodstox event loop, as lean as it gets — that is, the best case of the traditional approach, not the typical one.

Throughput in ops/ms (higher is better)

Approach 1 item 50 items 500 items
Fletch mapping 283.7 31.6 3.45
Fletch cursor (document order) 224.5 29.9 3.36
Fletch cursor (out of order) 163.4 18.6 2.02
Woodstox, hand-written event loop 96.4 12.4 1.38

On the small document the mapping delivers ~3× the throughput of the Woodstox loop; on the large one, ~2.5×. Allocation per document: about 1.2 KB for the mapping and 6.5 KB for the cursor, against tens of KB per document for a typical StAX pipeline — at volume, that is the difference between the GC showing up in your p99 or not. DOM and JAXB are not in the table because the repository doesn’t benchmark them; given the nature of those approaches (full materialization plus reflection), the gap only widens.

None of this requires trust: mvn -P benchmarks package -DskipTests and java -jar target/benchmarks.jar reproduce the table on your machine.

When to use something else

Honesty is also a form of efficiency. Fletch is an extraction tool, and there are things it deliberately does not do:

  • Write or transform XML — it is read-only.
  • XPath, XSLT, schema validation — out of scope.
  • Truly namespace-aware processing — elements match by raw tag name (a deliberate performance choice; it covers the common profile of integration documents with a default namespace, but if the same prefix means different things in different documents, you will miss it).
  • Manipulating the document as a data structure — if you need the tree, use a tree library.

If your case is I receive documents at volume and need typed values out of them — which describes the overwhelming majority of systems still speaking XML — the takeaway is simple: the cost of the traditional way is not a law of physics. It is just an old default.

Fletch is open source (Apache 2.0), zero dependencies, Java 17+: github.com/robsonkades/fletch.

Top comments (0)