{}JSONLint.app

JSON vs XML: A Migration Guide for SOAP-to-Mobile APIs

10 min read
Share:

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:

  1. Bytes on the wire. Mobile users pay for data and wait on latency. Smaller payloads ship faster and cost less.
  2. 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 through DOMParser or a JS-level library, which is slower and allocates more.
  3. 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 id to 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:

  1. Repeated-element arity. Order A-1001 has one <line>, so lines.line became an object; A-1002 has two, so it became an array. A mobile client iterating order.lines.line.map(...) crashes on the first order. Two records of the same type now have incompatible shapes.
  2. 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.
  3. The namespace vanished. meta:source became plain source. If another namespace also defined source, 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 / #text collisions. If an element has both an attribute named text and a chosen text key of text, or an attribute and a child element that map to the same JSON key, one silently overwrites the other. Pick distinct conventions (@attr prefix, #text for content) and validate that no source XML uses those literal key names.
  • Lost namespaces. Stripping prefixes (meta:source to source) 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.

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

Related Articles