●NATIVE — Rork Max reaches AR and LiDAR scanning, Metal-backed 3D, Dynamic Island, Siri Intents, HealthKit, NFC, App Clips, and on-device Core ML●IOS27 — iOS 27 Developer Beta 4 extends Siri AI to iPhone 15 Pro and 15 Pro Max plus the 16 and 17 lines, with noticeably faster responses than the first beta●DESIGN — Apple's own design kits for iOS, iPadOS, and macOS 27 are now available for Figma and Sketch, ready for when you lay out UI for the new OS●CANARY — Android 17, codenamed Cinnamon Bun, retires Developer Previews in favor of continuously updated Canary builds, which changes when you schedule testing●SPARK — Gemini Spark in Android 17 drives apps directly to automate multi-step errands like booking a ride or placing an order●SCALE — Rork raised $2.8M from a16z and now draws roughly 743,000 visits a month●NATIVE — Rork Max reaches AR and LiDAR scanning, Metal-backed 3D, Dynamic Island, Siri Intents, HealthKit, NFC, App Clips, and on-device Core ML●IOS27 — iOS 27 Developer Beta 4 extends Siri AI to iPhone 15 Pro and 15 Pro Max plus the 16 and 17 lines, with noticeably faster responses than the first beta●DESIGN — Apple's own design kits for iOS, iPadOS, and macOS 27 are now available for Figma and Sketch, ready for when you lay out UI for the new OS●CANARY — Android 17, codenamed Cinnamon Bun, retires Developer Previews in favor of continuously updated Canary builds, which changes when you schedule testing●SPARK — Gemini Spark in Android 17 drives apps directly to automate multi-step errands like booking a ride or placing an order●SCALE — Rork raised $2.8M from a16z and now draws roughly 743,000 visits a month
A JSON File 19% Smaller That Ships Only 3% Smaller — Measuring Catalog Payloads Against gzip
Five payload formats measured on a 20,000-item wallpaper catalog. Key shortening saves just 3.2% after gzip, while a columnar layout cuts 26.8%. Real numbers for Brotli, paging, and JSON.parse time.
The catalog for a wallpaper app I operate had quietly grown past 20,000 items. The catalog JSON fetched right after launch weighed about 7MB uncompressed — close to 1MB even with gzip — and on slower connections the delay before first paint had become something you could feel.
The first thing I reached for was key shortening: imageUrl becomes u, fileSize becomes f. The file shrank by 19%, and for a moment I felt I had made progress. Then I measured what actually travels over the wire — the gzipped size — and the saving was 3.2%. A poor return for giving up readable field names.
So where do the real savings live? This is a record of testing each assumption until the numbers answered.
The Setup — a 20,000-Item Synthetic Catalog
Everything was measured with Node v22.22.3 and the built-in zlib module, nothing else. Since I cannot publish the production catalog, I generated synthetic data with the same field structure, using a seeded random generator. The seed is fixed, so running the script reproduces the exact byte counts below.
Each record has twelve fields typical of a wallpaper catalog: ID, title, category, a tags array, resolution, file size, two image URLs, a premium flag, a creation timestamp, and a sort score. Twenty thousand of them.
// gen.js — Node v22.22.3, no dependencies (zlib is built in)const zlib = require('zlib');const fs = require('fs');// Seeded PRNG (mulberry32) so every run reproduces the same catalogfunction mulberry32(a) { return function () { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}const rand = mulberry32(20260805);const cats = ['nature','city','space','abstract','animal','flower','sea','sky','night','minimal'];const words = ['sakura','yozora','umi','yama','hikari','kaze','ame','yuki','hoshi','tsuki','asa','yugure','mori','kawa','sora','kumo'];const pick = (a) => a[Math.floor(rand() * a.length)];const N = 20000;const items = [];for (let i = 0; i < N; i++) { const id = i + 1; const cat = pick(cats); items.push({ id, title: `${pick(words)}-${pick(words)}-${String(id).padStart(5, '0')}`, category: cat, tags: [pick(words), pick(words), pick(words)], width: [1080, 1170, 1290][Math.floor(rand() * 3)], height: [1920, 2532, 2796][Math.floor(rand() * 3)], fileSize: Math.floor(rand() * 4000000) + 200000, imageUrl: `https://cdn.example.com/wallpapers/${cat}/${id}/original.jpg`, thumbUrl: `https://cdn.example.com/wallpapers/${cat}/${id}/thumb.jpg`, premium: rand() < 0.2, createdAt: new Date(1600000000000 + Math.floor(rand() * 150000000000)).toISOString(), sortScore: Math.round(rand() * 100000) / 100, });}
Against this baseline I prepared four transformations:
// B: shorten every key to one letter (imageUrl → u, etc.)const KM = { id:'i', title:'t', category:'c', tags:'g', width:'w', height:'h', fileSize:'f', imageUrl:'u', thumbUrl:'v', premium:'p', createdAt:'d', sortScore:'s' };const B = items.map((o) => { const r = {}; for (const k in o) r[KM[k]] = o[k]; return r; });// C: drop both URLs (the client can rebuild them from category and id)const C = items.map(({ imageUrl, thumbUrl, ...rest }) => rest);// D: columnar — transpose the array of objects into one array per fieldconst keys = Object.keys(items[0]);const D = {};for (const k of keys) D[k] = items.map((o) => o[k]);// E: columnar + URL removal combinedconst E = {};for (const k of keys.filter((k) => k !== 'imageUrl' && k !== 'thumbUrl')) E[k] = items.map((o) => o[k]);// Measure raw / gzip(level 6, 9) / Brotli(quality 11) for eachfor (const [name, v] of Object.entries({ A: items, B, C, D, E })) { const s = JSON.stringify(v); console.log(name, { raw: Buffer.byteLength(s), gz6: zlib.gzipSync(s, { level: 6 }).length, gz9: zlib.gzipSync(s, { level: 9 }).length, br11: zlib.brotliCompressSync(s, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 } }).length, });}
gzip level 6 matches the default of most CDNs and reverse proxies. Brotli runs at quality 11, the setting you would use for precompressed static files.
Five Formats, Measured
The results first. Every reduction percentage is relative to baseline A in the same column.
Format
raw
gzip(6)
gzip(9)
Brotli(11)
gzip(6) reduction
A baseline (array of objects)
7,035,702
943,796
891,579
691,526
—
B short keys
5,695,702
913,189
883,867
664,434
−3.2%
C URLs removed
4,185,850
745,072
704,625
559,871
−21.1%
D columnar
4,695,841
691,065
648,090
556,131
−26.8%
E columnar + no URLs
2,285,963
534,016
523,630
451,554
−43.4%
Notice how the raw reductions and the gzipped reductions refuse to line up. B cuts raw size by 19.0% but ships only 3.2% smaller. C cuts raw by 40.5% yet ships 21.1% smaller. D, meanwhile, cuts raw by 33.3% and keeps most of it — 26.8% — after compression.
The intuition that a smaller file means proportionally lighter delivery simply does not hold once compression sits in the path.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Five payload formats measured on a 20,000-item catalog, with a full comparison table for raw, gzip, Brotli, and JSON.parse time
✦Why key-name shortening saves only 3.2% after gzip, while transposing to a columnar layout reaches a 26.8% reduction
✦Splitting into 40 pages costs just 2.9% in size — measured evidence that pagination is nearly free
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
gzip (DEFLATE) replaces any byte sequence that already appeared within the previous 32KB window with a short back-reference — a distance and a length. In an array of objects, a key fragment like "imageUrl":"https://cdn... repeats 20,000 times, and repetition of this kind is exactly what gzip handles best. From the second occurrence on, each key costs only a few bits.
In other words, your key names are already nearly free. Shortening them turns a few bits into a few bits, and the shipped size barely moves. That is why variant B removes 1.34MB of raw bytes yet saves only about 30KB on the wire.
Until I ran this, I had filed key shortening under "unglamorous but reliable." It is actually a trade of readability and debuggability for 3%. At least for my catalog, that trade does not clear.
The Columnar Transpose — gzip Reaching Brotli Territory
Variant D merely transposes the array of objects into one array per field. Not a single byte of data is removed. Yet it ships 26.8% smaller — the strongest single technique in this test.
The reason is how the gzip window gets used. In row order, similar values — the same category names, numbers in the same range, timestamps in the same format — sit far apart, separated by all the other fields. Transposed into columns, similar values become neighbors, and back-references keep hitting at short distances. It is a layout that meets the compressor halfway.
The absolute numbers held a surprise. Columnar gzip(6) lands at 691,065 bytes — almost exactly the 691,526 bytes you get from compressing the baseline with Brotli quality 11. A pure layout change took plain gzip to Brotli-grade output. If your delivery path cannot serve Brotli, the payload itself can make up the difference.
I also timed parsing: median of 11 runs of JSON.parse gives 37.2ms for A and 19.8ms for D. The columnar form needs to be rehydrated into objects on the device, but that step measured 12.7ms, for a total of 32.5ms — still faster than parsing the row-ordered JSON directly. That inverted my expectation as well. Parse time tracks raw size closely, so the smaller columnar payload wins even with the extra step.
Move URLs Out Into a Template
Removing URLs (variant C) ships 21.1% smaller — far better than short keys, but a long way down from its 40.5% raw reduction. URLs share long common prefixes like https://cdn.example.com/wallpapers/, which makes them some of the most compressible data in the file.
Still, if every URL can be rebuilt mechanically from a path convention ({category}/{id}/original.jpg), moving them out is worth it. Variant E — columnar plus URL removal — ships at 534,016 bytes, a 43.4% cut. Decompression time drops from 12.9ms to 4.8ms, and JSON.parse takes 11.9ms.
One caution: templating URLs bakes your delivery convention into client code. If you later restructure CDN paths, old app versions will keep building URLs the old way. Whether you can live with that constraint decides whether C and E are available to you. My own approach is to carry a schema version in the payload, so the server can fall back to serving full URLs to older clients if the convention ever changes.
The Paging Tax Is 2.9%
I also measured the worry that splitting hurts compression. Splitting the 20,000 items into 40 pages of 500 and gzipping each page separately totals 970,897 bytes, against 943,796 bytes for the single blob: a 2.9% tax.
The smaller window does cost something, but that is the whole bill. Fetching only the first page for initial render and loading the rest in the background costs almost nothing in total size. The state-management side of paged fetching is a separate topic I covered in cursor pagination and refetch state design; here I only wanted the size numbers on record.
The Client Side — Rehydration With a Schema Guard
Here is the receiving end for a Rork-generated Expo (React Native) app: fetching, validating, rehydrating, and URL building in one function. It checks the schema version v first and throws on anything unexpected, so the caller can fall back to a cached catalog.
const CDN = 'https://cdn.example.com';const CATALOG_SCHEMA_VERSION = 2;type WallpaperItem = { id: number; title: string; category: string; tags: string[]; width: number; height: number; fileSize: number; premium: boolean; createdAt: string; sortScore: number; imageUrl: string; thumbUrl: string;};export async function fetchCatalog(url: string, timeoutMs = 10000): Promise<WallpaperItem[]> { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { // fetch decompresses gzip / Brotli transparently; no manual Accept-Encoding needed const res = await fetch(url, { signal: controller.signal }); if (!res.ok) throw new Error(`catalog fetch failed: ${res.status}`); const cols = await res.json(); // Schema guard. A columnar payload can be structurally broken yet still // "readable", so verify the version and every column's presence and length if (cols?.v !== CATALOG_SCHEMA_VERSION || !Array.isArray(cols.id)) { throw new Error('unexpected catalog schema'); } const n = cols.id.length; for (const k of ['title','category','tags','width','height','fileSize','premium','createdAt','sortScore']) { if (!Array.isArray(cols[k]) || cols[k].length !== n) { throw new Error(`catalog column broken: ${k}`); } } // Rehydrate + rebuild URLs (measured at 12.7ms for 20,000 items) const items: WallpaperItem[] = new Array(n); for (let i = 0; i < n; i++) { const id = cols.id[i]; const category = cols.category[i]; items[i] = { id, category, title: cols.title[i], tags: cols.tags[i], width: cols.width[i], height: cols.height[i], fileSize: cols.fileSize[i], premium: cols.premium[i], createdAt: cols.createdAt[i], sortScore: cols.sortScore[i], imageUrl: `${CDN}/wallpapers/${category}/${id}/original.jpg`, thumbUrl: `${CDN}/wallpapers/${category}/${id}/thumb.jpg`, }; } return items; } finally { clearTimeout(timer); }}
One operational note for the serving side: Brotli quality 11 took 14.4 seconds to compress the 7MB input (9.5 seconds even for the columnar variant). Do not compress per request — generate .json.br and .json.gz at catalog build time and serve them statically. Decompression, by contrast, is cheap: 12.9ms for gzip on the device side.
The Order I Now Follow
Based on these measurements, this is the order in which I make the calls:
Confirm compression on the delivery path first. The gap between uncompressed 7MB and gzipped 944KB (−86.6%) dwarfs everything else in this article. Check your CDN settings and the content-encoding response header before touching the payload
Transpose to columnar. A 26.8% cut without deleting any data, plus faster parsing. The cost is a rehydration function and a schema guard
Remove derivable fields. Anything rebuildable from a convention, like URLs, can move out — but always carry a schema version so you can retreat from the convention later
Paginate freely. The size tax is 2.9%. Fetch what the first screen needs and defer the rest
Skip key shortening. Trading readability for 3.2% is not a deal worth taking, at least not for catalog delivery
If you take one thing from this piece, let it be the habit of measuring formats A, D, and E on your own catalog. Your numbers will differ, but the shape of the result — raw reduction and shipped reduction are different things — will almost certainly hold. I hope these tables save you the afternoon it took me to build them.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.