The same small object takes 237 bytes as RTON, the binary format used by Plants vs. Zombies 2, and about 337 bytes as compact JSON. That is a saving of 100 bytes. The JSON figure is a hand count and has not been executed. Is the saving worth losing cat, jq and your browser's network tab?
This piece uses RTON as one worked example of a tag-length-value binary format. The format notes come from the community project RETON, and the sample comes from a write-up of a small RTON decoder. The goal is to help you decide when a binary format earns its complexity and when it doesn't.
The three tricks that make binary smaller
JSON spells everything as text. A tag-based binary format spells each value as a one-byte type tag followed by a payload. RTON starts with the bytes "RTON" and a 32-bit little-endian version, then holds the entries, then an FF end marker and the footer "DONE" (per the write-up above).
Type tags. 01 means true, 00 means false, 24 means an unsigned varint, 81 means a length-prefixed string, 85 opens an object and FF closes it. The reader never guesses, because the tag says how many bytes come next. There are no quotes, colons or commas to scan for.
Varints. Each byte carries 7 payload bits, and the top bit says whether another byte follows. The lowest group comes first.
Hand decode of FE 01: the first byte, FE, has its top bit set, so more bytes follow. Its low 7 bits are 0x7E = 126. The second byte, 01, has a clear top bit, so it is the last, and it contributes 1 shifted left by 7 = 128. The total is 126 + 128 = 254. Any value under 128 fits in one byte.
Signed varints. Tag 25 reads an unsigned varint n, then maps it zigzag-style: even n becomes n/2, odd n becomes -(n+1)/2. So 25 03 gives n = 3, which is odd, so -(3+1)/2 = -2.
A longer one: 25 95 01 gives 0x15 + (1 << 7) = 21 + 128 = 149. That is odd, so the value is -(149+1)/2 = -75. Small negatives stay short.
The string cache. The first time a string appears as a key, it is written with tag 90, a varint length and the bytes, and it joins a numbered table. Later uses are 91 plus the index.
In the sample, 90 08 6f 62 6a 63 6c 61 73 73 defines "objclass" as cache entry 4. The second object recalls it with 91 04. That is 2 bytes instead of 10 in compact JSON (8 letters plus 2 quotes).
Size: where it helps and where it doesn't
The sample is a synthetic object with a version and two items that share keys. By hand count, the compact JSON is 337 bytes. The RTON is 237 bytes, which the write-up's annotated dump confirms. The JSON count was computed by hand and has not been executed. Check it with len(json.dumps(obj, separators=(',', ':'))) before quoting it anywhere.
The split is the interesting part. By the same hand count, the first object takes 130 bytes in RTON against 156 in JSON, because it has to define every string. The second takes 70 bytes against 154, because it mostly recalls keys and values. Nearly all the savings come from repetition.
So the tricks help much less in these cases (an inference from the mechanics, not a measurement): This pairs well with how to shut down dev vms at night: tags, schedule, alerts.
- Data with few repeated keys, such as one big record.
- Mostly unique long strings, which cost their raw bytes either way.
- Large numbers, where a varint can reach 5 bytes for a 32-bit value.
Neither the RETON notes nor the write-up compare RTON against gzipped JSON, so this article makes no claim about how far compression closes the gap. Test it on your own payload.
Readability, tooling and debugging
JSON wins this round easily. You can read it in a log, diff it in git, edit it in any editor and paste it into a bug report. Nearly every language has a mature parser for it.
A tag-based binary format costs you all of that. Here is the start of the second object from the sample dump:
00A1 85 object start
00A2 91 02 key, recall cache[2] = "uid"
00A4 90 05 31 2e 30 2e 32 define cache[19] = "1.0.2"
Without the annotator, you see only hex. Worse, the bytes 91 02 mean nothing on their own. A decoder has to have read every earlier 90 to know that index 2 is "uid". The cache makes the stream stateful, so you can't start reading in the middle.
You also need a spec. The RETON notes mark several tags as uncertain, including tag 21 (an int32 zero with no payload), which the write-up's decoder supports only provisionally. Self-describing JSON has no such gap. Plan on writing your own dump tool, because you'll want it on day one. See full coverage of ai agent strategy for hidden-move games: lobster kombat for additional background.
Parsing safety
Binary parsers do arithmetic on lengths that come from the file, and that is where bugs tend to hide. JSON parsers have their own risks, but a hand-written binary reader has more places to go wrong. The decoder in the write-up shows a short list of safeguards worth copying:
- Bounds check in one place. Every read goes through a function that checks position plus length against the data size, and raises on truncation.
- Varint length limit. Stop after 63 bits so a run of
FFbytes can't loop forever. - Depth limit. Cap nesting at 64 levels so a hostile file can't exhaust the stack.
- Cache index check. Reject a recall whose index is outside the table.
- Strict end of file. After the footer, the position must equal the file length, so trailing bytes are an error.
- Refuse unknown tags. Raise with an offset rather than guessing.
One more is an inference, not something the write-up states: don't preallocate memory from a declared array count or string length until the bounds check passes. Otherwise a tiny file can claim a huge size.
Here is a minimal varint reader with those checks (unexecuted, expected results derived by hand):
def read_uvarint(data, pos):
result, shift = 0, 0
while True:
if pos >= len(data):
raise ValueError('truncated')
b = data[pos]
pos += 1
result |= (b & 0x7F) << shift
if b < 0x80:
return result, pos
shift += 7
if shift > 63:
raise ValueError('varint too long')
## read_uvarint(bytes([0xFE, 0x01]), 0) -> (254, 2)
## read_uvarint(bytes([0xFE]), 0) -> ValueError: truncated
The failure case matters as much as the success case. If FE is the last byte in the file, the loop wants another byte that doesn't exist. The reader must raise instead of returning a wrong number.
Binary is not security
A file that looks like noise is not protected. RTON itself is only an encoding, and anyone with the format notes can decode it. The game this format comes from also wraps some files in encryption, but that is a separate layer, and this article doesn't cover it.
The write-up makes the key point. A client app that decrypts its own data has to hold the key somewhere on the device. Client-side encryption deters casual reading, but it is not strong protection, and anything that really matters belongs on a server. Choose a binary format for size and speed, not to hide data.
Also read: related topic: how to make layered box-shadow css (plus glass cards)
Side by side
| | JSON | Tag-based binary (RTON as example) | |---|---|---| | Size | Text for everything, keys repeated | Compact numbers and cached keys; biggest wins on repeated structure | | Readability | Human readable | Needs a dump tool and the spec | | Schema | Self-describing | Tags are self-describing, but the cache makes the stream stateful | | Parsing risk | Mature parsers | Hand-written readers need bounds, depth and end-of-file checks | | Tooling | Everywhere | Mostly build your own |
Decision checklist
Stay with JSON if people read or edit the data, if it crosses a public API, if payloads are small or rarely repeat keys, or if you haven't measured a size or parse-time problem. Most config files and web APIs fit here.
Consider binary if you ship large volumes of similar records, such as thousands of objects sharing the same keys, and you control both the writer and the reader. Weigh an existing format with maintained libraries before inventing one. A custom format means you own the spec, the dump tool and the fuzzing.
If you do go binary, build the safeguards in from the first commit. Write hand-computed byte fixtures before the decoder, and keep a text export path for debugging.
For most readers the answer is JSON until a measurement says otherwise. For a closed pipeline with repetitive data, a tag-and-varint format with a string cache can pay off. Just accept the tooling bill, and never mistake the encoding for protection.



