.length is not your payload size
Most of us check JSON size with JSON.stringify(data).length. That number is a character count, not bytes. It also tells you nothing about what actually travels over the network.
To see how far off it can be, I generated an API response with 1,000 user records. Each record looks like this:
{"id":"usr_ebcb82034fba","name":"Inés Schmidt","email":"ines.541e@example.com","city":"Berlin","isActive":true,"roles":["viewer"],"lastLoginAt":"2026-09-15T01:24:53.584Z","preferences":{"theme":"system","language":"tr","notifications":{"email":true,"push":true}},"bio":null}
Then I measured the same payload five ways in Node.js 22:
| Measurement | Size |
|---|---|
JSON.stringify(payload).length |
285,466 characters |
| Minified, real UTF-8 bytes | 287,743 bytes |
| Pretty-printed with 2 spaces | 457,027 bytes |
| Minified + gzip | 36,639 bytes |
| Minified + Brotli | 26,918 bytes |
Three things stand out. The character count undercounts the bytes. Pretty-printing adds 59% on disk. And after gzip, the pretty version is only 8% larger than the minified one (39,621 vs 36,639 bytes), because compression eats most of the whitespace.
Let's go through how to measure each of these properly.
Characters vs bytes in JavaScript
JavaScript strings are UTF-16, so .length counts UTF-16 code units. JSON on the wire is UTF-8, where anything outside ASCII takes 2 to 4 bytes:
"ş".length; // 1, but 2 bytes in UTF-8
"🚀".length; // 2, but 4 bytes in UTF-8
In my sample, names like "Inés", "Çelik" and "İstanbul" plus a few emoji added 2,277 bytes that .length never showed. For English-only data the gap is zero; for user-generated content in other languages it grows quickly.
To get the real byte size:
// Browser and Node
const bytes = new TextEncoder().encode(JSON.stringify(data)).length;
// Browser alternative
const bytes2 = new Blob([JSON.stringify(data)]).size;
// Node only
const bytes3 = Buffer.byteLength(JSON.stringify(data), "utf8");
All three return 287,743 for my payload.
One more trap: "287 KB" is ambiguous. 287,743 bytes is 287.7 KB if a kilobyte is 1,000 bytes, or 281.0 KiB if it is 1,024. Browser DevTools, cloud consoles and size limits don't all use the same unit, so check which one a limit means before you compare.
Python adds two hidden size bumps
Python's json.dumps() makes the output bigger than you might expect, for two reasons.
1. ensure_ascii=True by default. Every non-ASCII character is escaped as \uXXXX, and emoji become two escapes:
json.dumps({"city": "İstanbul", "bio": "🚀"})
# {"city": "\u0130stanbul", "bio": "\ud83d\ude80"} -> 48 bytes
json.dumps({"city": "İstanbul", "bio": "🚀"}, ensure_ascii=False)
# {"city": "İstanbul", "bio": "🚀"} -> 36 bytes
2. The default separators include spaces. Without indent, Python uses ", " and ": ", so even "compact" output has a space after every comma and colon.
On the same 1,000-user payload:
| Call | UTF-8 bytes |
|---|---|
json.dumps(p) |
322,382 |
json.dumps(p, separators=(",", ":")) |
296,236 |
json.dumps(p, separators=(",", ":"), ensure_ascii=False) |
287,743 |
The default call is 12% larger than truly minified JSON. To measure and send the smallest version:
import json
body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
size_in_bytes = len(body.encode("utf-8"))
If you use ensure_ascii=False, make sure the response is sent with Content-Type: application/json; charset=utf-8 and encoded as UTF-8. Most frameworks already do this.
Measure what the network actually sends
If your server or CDN compresses responses (check for a Content-Encoding: gzip or br response header), the bytes on the wire are the compressed size. In my sample that is 36,639 bytes with gzip, about 13% of the raw payload.
Measure it in the browser with the built-in CompressionStream:
async function gzipSize(value) {
const stream = new Blob([JSON.stringify(value)])
.stream()
.pipeThrough(new CompressionStream("gzip"));
const buffer = await new Response(stream).arrayBuffer();
return buffer.byteLength;
}
In Node.js:
const zlib = require("node:zlib");
const gzipBytes = zlib.gzipSync(JSON.stringify(data)).length;
const brotliBytes = zlib.brotliCompressSync(JSON.stringify(data)).length;
In Python:
import gzip
gzip_bytes = len(gzip.compress(body.encode("utf-8")))
Expect small differences between tools. Python's gzip.compress uses compression level 9 by default and gave 34,287 bytes; Node's default level gave 36,639. Your server's level decides the real number.
In Chrome DevTools, the Network panel shows both values: turn on Big request rows and the Size column lists the transferred size above the resource size.
So which number matters? The compressed size drives bandwidth and download time. The uncompressed size drives JSON.parse time, memory use and storage limits, such as MongoDB's 16 MB per document or DynamoDB's 400 KB per item. You usually care about both.
Find out what makes the payload heavy
A total size tells you there is a problem. A size per field tells you where it is. This helper adds up the serialized size of each key across an array of objects:
const sizeOf = (v) => new TextEncoder().encode(JSON.stringify(v)).length;
function heaviestKeys(items) {
const totals = {};
for (const item of items) {
for (const [key, value] of Object.entries(item)) {
totals[key] = (totals[key] ?? 0) + sizeOf(value);
}
}
return Object.entries(totals).sort((a, b) => b[1] - a[1]);
}
console.table(heaviestKeys(payload.data));
For my 1,000 users, the top of the list was:
preferences 76,957 bytes
lastLoginAt 26,000 bytes
email 23,469 bytes
id 18,000 bytes
name 13,882 bytes
Two findings surprised me. First, key names alone take 130,043 bytes, or 45% of the minified payload, because every record repeats "preferences", "notifications" and the rest. Second, preferences is the heaviest field, yet a typical user-list screen never displays it.
That suggests two fixes, and I measured both:
| Change | Raw size | Gzip size |
|---|---|---|
| Shorten every key to 1 letter | −25.7% | −5.0% |
Drop preferences from the list response |
−32.0% | −13.5% |
Short keys look tempting on the raw numbers, but gzip already compresses repeated key names well. You lose readability for a 5% gain on the wire. Removing a field the client doesn't use cuts the transferred size by almost three times as much, and keeps the API readable.
Quick checklist
- Measure bytes, not characters:
TextEncoder,Blob.sizeorBuffer.byteLength. - In Python, use
separators=(",", ":")andensure_ascii=Falsewhen size matters. - Check whether responses are compressed, and look at the transferred size in DevTools.
- Track both numbers: compressed for the network, uncompressed for parsing, memory and storage limits.
- Find the heaviest fields and remove what the client doesn't use before reaching for short key names.
If you'd rather not paste a helper into the console every time, I built a free JSON size calculator that shows formatted, minified and estimated gzip size, plus the heaviest paths in a payload. It runs entirely in the browser, so the JSON never leaves your machine. For production minification strategies, I also wrote a longer guide to JSON minification.
What's the biggest JSON payload you've had to ship, and what did you cut first? Let me know in the comments.
Top comments (0)