JSON vs XML: A Migration Guide for SOAP-to-Mobile APIs
Your team owns a SOAP service that has served enterprise integrations reliably for years. Now you're building a React Native client, and every engineer who touches the XML envelope asks the same question: do we keep parsing this on the phone, or expose a JSON facade? This article skips the abstract pros-and-cons table and walks through the actual decision — payload size, parse cost, tooling, schema needs — and then does a real XML-to-JSON conversion, including the ambiguities a naive converter silently gets wrong.
The decision, framed concretely
The question is not "is JSON better than XML." Both are mature, both work. The real question is: for a chatty mobile client over a metered, high-latency network, parsing on a battery-powered device, what does sticking with XML cost you, and is that cost justified by anything XML gives back?
Three forces drive the answer:
- Bytes on the wire. Mobile users pay for data and wait on latency. Smaller payloads ship faster and cost less.
- Parse cost on the client. JavaScript engines parse JSON natively via
JSON.parse(effectively a C++ fast path). XML parsing in a browser or RN runtime goes throughDOMParseror a JS-level library, which is slower and allocates more. - What the format must carry. If your data is records and lists — orders, line items, customers — JSON models that directly. If your data is documents with mixed markup (formatted text, inline annotations), XML earns its keep.
For a SOAP-backed CRUD API, all three point toward JSON. Let's measure the first one instead of asserting it.
Measuring payload size on the same data
Here is the same order record in both formats. First XML:
<order id="A-1001" currency="USD">
<customer>Ada Lovelace</customer>
<items>
<item sku="PEN-01" qty="3">Gel Pen</item>
<item sku="PAD-02" qty="1">Notepad</item>
</items>
<total>14.75</total>
</order>
That's roughly 240 bytes with the indentation shown, about 190 bytes minified (whitespace removed). Now the JSON equivalent:
{
"order": {
"id": "A-1001",
"currency": "USD",
"customer": "Ada Lovelace",
"items": [
{ "sku": "PEN-01", "qty": 3, "text": "Gel Pen" },
{ "sku": "PAD-02", "qty": 1, "text": "Notepad" }
],
"total": 14.75
}
}
That's about 260 bytes pretty-printed but roughly 185 bytes minified. On this tiny record the two are nearly even, but the gap widens with repetition — and real API responses repeat. The reason is structural: every XML element pays for a closing tag. <customer>Ada Lovelace</customer> spends </customer> (11 bytes) just to say "I'm done." JSON closes a value with a single }, ], or comma. Scale the items array to 200 line items and XML's per-element tax compounds: each <item ...>...</item> carries <item> plus </item> overhead, while each JSON object reuses one set of braces.
That last point comes with a caveat: always compare gzipped sizes, because transport is almost always compressed. Repetitive XML tags compress well (the dictionary is small and repeated), which narrows the raw-byte gap — often to 10–20% rather than the 2x you'd guess from minified text. So the honest framing is: JSON is somewhat smaller, and meaningfully cheaper to parse, but if you were choosing on bytes alone after gzip the difference is modest. The parse-cost and tooling story is what tips it.
Where XML's size is justified: mixed content. Consider a CMS field like <p>See the <a href="/terms">terms</a> before <em>signing</em>.</p>. XML represents interleaved text and markup natively — text, element, text, element, text — in document order. JSON has no native concept of "text with inline children in order," so encoding that same fragment requires either an awkward array-of-mixed-nodes structure or just storing the raw HTML string. If your payloads are documents, XML's verbosity is buying you something real. If they're records, it isn't.
The technical differences that actually decide it
Keep this short — only the differences that change the migration matter.
- Attributes vs child elements. XML offers two ways to attach
idto an order: an attribute (<order id="...">) or a child (<order><id>...</id></order>). JSON has one way: a key. Every converter must pick a convention for attributes (commonly an@or$prefix), and that choice leaks into every downstream consumer. - No native array type in XML. This is the big one. JSON has
[]. XML expresses "many" by repeating an element — but a single<item>and a list of one<item>look identical in the document. The schema, not the instance, tells you which is an array. A naive converter that infers arity from the instance will produce an object for one item and an array for two. We'll hit this head-on below. - No native schema in vanilla JSON. XML has XSD and DTD. JSON has no built-in schema, which is why you reach for JSON Schema as a separate layer to validate structure, types, and required fields. If your SOAP service relied on XSD-enforced contracts, plan to port those constraints to JSON Schema — see JSON Schema validation in practice.
- Comments. XML has
<!-- -->. Standard JSON has none, which trips up teams using JSON for config; if that's your use case, read JSON comments for the workarounds. - Namespaces. XML namespaces (
soap:,xsd:, custom prefixes) have no JSON equivalent. Converters either flatten the prefix into the key name or drop it — and dropping it can collide two distinct elements into one key.
A conversion walkthrough: where naive converters go wrong
Take this response, which is realistic precisely because it's irregular:
<response xmlns:meta="urn:acme:meta">
<order id="A-1001">
<meta:source>web</meta:source>
<note>Rush <bold>priority</bold> shipping</note>
<lines>
<line sku="PEN-01">3</line>
</lines>
</order>
<order id="A-1002">
<lines>
<line sku="PAD-02">1</line>
<line sku="CUP-09">2</line>
</lines>
</order>
</response>
A careless one-liner conversion produces something like this — and three things are wrong:
{
"response": {
"order": [
{
"id": "A-1001",
"source": "web",
"note": "Rush shipping",
"lines": { "line": { "sku": "PEN-01", "text": "3" } }
},
{
"id": "A-1002",
"lines": { "line": [
{ "sku": "PAD-02", "text": "1" },
{ "sku": "CUP-09", "text": "2" }
] }
}
]
}
}
The bugs:
- Repeated-element arity. Order A-1001 has one
<line>, solines.linebecame an object; A-1002 has two, so it became an array. A mobile client iteratingorder.lines.line.map(...)crashes on the first order. Two records of the same type now have incompatible shapes. - Mixed content was destroyed.
<note>Rush <bold>priority</bold> shipping</note>lost the word "priority" entirely — the converter took the surrounding text nodes and dropped the child element's text, leaving"Rush shipping". Mixed content does not survive a flat XML-to-JSON mapping. - The namespace vanished.
meta:sourcebecame plainsource. If another namespace also definedsource, the two would silently collide into one key.
A correct conversion forces consistency. Decide up front that line is always an array, that attributes get a prefix, and that mixed content is preserved (or explicitly rejected). The honest JSON looks like this:
{
"order": [
{
"@id": "A-1001",
"meta:source": "web",
"note": "Rush priority shipping",
"lines": [ { "@sku": "PEN-01", "#text": "3" } ]
},
{
"@id": "A-1002",
"lines": [
{ "@sku": "PAD-02", "#text": "1" },
{ "@sku": "CUP-09", "#text": "2" }
]
}
]
}
Runnable conversion code
JavaScript: xml2js and the explicitArray gotcha
import { parseStringPromise } from "xml2js";
const xml = `<order id="A-1001"><line sku="PEN-01">3</line></order>`;
// Default behavior: repeated elements stay arrays, attributes go under "$".
const a = await parseStringPromise(xml);
// a.order.line is ALWAYS an array, even for one element. Good for consistency.
// Tempting "cleaner" option — and the classic bug:
const b = await parseStringPromise(xml, { explicitArray: false });
// Now one <line> is an object, many <line> is an array. Inconsistent shapes.
The default explicitArray: true is verbose but safe: a single repeated element is still an array, so consumers never special-case arity. Turning it off to get prettier output reintroduces bug #1 above. If you want clean keys without losing array-safety, configure it explicitly:
const opts = {
explicitArray: true, // keep arrays consistent
attrkey: "@", // attributes under "@"
charkey: "#text", // text content under "#text"
explicitChildren: false,
};
const result = await parseStringPromise(xml, opts);
Python: xmltodict
import xmltodict, json
xml = """<order id="A-1001"><line sku="PEN-01">3</line></order>"""
d = xmltodict.parse(xml) # attributes -> "@attr", text -> "#text"
print(json.dumps(d, indent=2))
# force_list pins the arity problem: 'line' is ALWAYS a list
d2 = xmltodict.parse(xml, force_list=("line",))
# d2["order"]["line"] is now [ {...} ] even with a single element
xmltodict uses the @/#text convention by default, and force_list is the cure for arity ambiguity — name every element that can repeat, and it's always a list.
Go: encoding/xml into a struct (and why generic maps are hard)
Go's idiomatic path is unmarshalling into a typed struct, where you declare the arity yourself via a slice. This sidesteps every ambiguity because you specify the schema:
package main
import (
"encoding/json"
"encoding/xml"
"fmt"
)
type Line struct {
SKU string `xml:"sku,attr" json:"sku"`
Qty string `xml:",chardata" json:"qty"`
}
type Order struct {
ID string `xml:"id,attr" json:"id"`
Lines []Line `xml:"line" json:"lines"` // slice = always an array
}
func main() {
data := []byte(`<order id="A-1001"><line sku="PEN-01">3</line></order>`)
var o Order
_ = xml.Unmarshal(data, &o)
out, _ := json.MarshalIndent(o, "", " ")
fmt.Println(string(out))
}
Because Lines is a []Line, one element and many elements both unmarshal to a slice — the arity bug is impossible. The cost is that you need a struct per shape. Arbitrary XML to map[string]interface{} is genuinely hard in Go: encoding/xml has no built-in "decode into a generic map" mode (unlike encoding/json), because XML's attributes, mixed content, and repeated elements don't map cleanly onto Go's map type. Teams doing fully dynamic conversion reach for third-party libraries like mxj, but those reintroduce exactly the arity and attribute-collision ambiguities the struct approach avoided. If your XML shapes are known, structs are the right call; if they're truly arbitrary, accept that Go will make you work for it.
Edge cases that break naive conversion
Three failure modes to test for before trusting any XML-to-JSON pipeline:
- Repeated-element arity. Covered above, and worth repeating because it's the most common production bug. A response that worked in testing (every order had multiple lines) crashes in production the first time a record has exactly one. Pin arrays explicitly:
explicitArray: true(xml2js),force_list(xmltodict), or a slice field (Go). - Attribute /
#textcollisions. If an element has both an attribute namedtextand a chosen text key oftext, or an attribute and a child element that map to the same JSON key, one silently overwrites the other. Pick distinct conventions (@attrprefix,#textfor content) and validate that no source XML uses those literal key names. - Lost namespaces. Stripping prefixes (
meta:sourcetosource) is convenient until two namespaces define the same local name. Either preserve the prefix in the key, or map each namespace to a documented, collision-free key prefix. Never silently drop it.
The recommendation
For a SOAP-backed records API feeding a mobile/JS client: expose a JSON facade. You gain native parsing, modestly smaller post-gzip payloads, and a data model that matches your records. You give up XSD-style enforced contracts — port those to JSON Schema deliberately rather than losing them. Keep XML only where you have genuine mixed-content documents. And whichever direction you convert, pin your array arity, fix your attribute convention, and preserve namespaces before you ship.
Related Tools & Resources
Tools
- XML to JSON — Convert XML payloads to JSON client-side and inspect exactly how attributes, text, and repeated elements map.
- JSON to XML — Go the other direction when an integration still needs XML, and see what structure is lost or added.
- JSON Validator — Paste the converted output and confirm it's well-formed JSON before wiring it into a client.
- JSON Schema Validator — Port your XSD contracts to JSON Schema and enforce required fields, types, and arity.
Learn More
- JSON vs YAML — The other format comparison that matters once you've left XML behind.
- JSON Schema Validation in Practice — How to replace XSD-style guarantees with JSON Schema in a real pipeline.
- JSON Format Rules — The exact syntax rules your converted output must satisfy.
- JSON Comments — What to do about the comments XML allowed but JSON doesn't.
Related Articles
Formatting JSON: When It Helps, When It Hurts
A practical guide to JSON beautifiers: pretty-print for humans, minify for transport, sort keys for diffs, and the lossy gotchas of round-tripping through stringify.
Common JSON Mistakes and How to Avoid Them
The 20 most common JSON syntax errors developers make, organized by category with examples and fixes.
How to Fix 'Unexpected End of JSON Input' Error
Learn what causes the 'Unexpected end of JSON input' error and how to fix it with practical solutions for JavaScript, Python, and other languages.
How to Open JSON Files: A Practical Guide
Open, view, and troubleshoot JSON files on macOS, Windows, and Linux with real commands for jq, Python, VS Code, and browsers — plus fixes for common errors.