●MAX — Rork Max builds native Swift apps instead of React Native, targeting the whole Apple ecosystem●DEVICES — From one description it spans iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage●NATIVE — It unlocks AR and LiDAR scanning, 3D games with Metal, Home Screen widgets, Dynamic Island, and on-device Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, joined by Peak XV and a16z Speedrun●PAPERLINE — Rork acquired the app builder Paperline and plans to stay acquisitive for engineering talent●MARKET — With 743,000+ monthly visits, and Gartner expects 75% of new apps to be low-code or no-code by end of 2026●MAX — Rork Max builds native Swift apps instead of React Native, targeting the whole Apple ecosystem●DEVICES — From one description it spans iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage●NATIVE — It unlocks AR and LiDAR scanning, 3D games with Metal, Home Screen widgets, Dynamic Island, and on-device Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, joined by Peak XV and a16z Speedrun●PAPERLINE — Rork acquired the app builder Paperline and plans to stay acquisitive for engineering talent●MARKET — With 743,000+ monthly visits, and Gartner expects 75% of new apps to be low-code or no-code by end of 2026
Half the Bytes, the Same Wait: What AVIF Actually Cost My Wallpaper Apps
Switching to AVIF cut my transfer bill in half and did nothing for perceived speed. Here is the transfer-versus-decode breakdown, the Accept-negotiation trade-off, per-device fallback tiers, and the staged migration order that finally worked across six apps.
I run six wallpaper apps as an indie developer. The images do not ship inside the binary — they come from Cloudflare R2 on demand. One month the transfer line item came in higher than I had planned for, and nine tenths of it was thumbnails and full-size images.
AVIF halves your bytes at the same visual quality. That claim is everywhere, and in my case it held up. I re-encoded 400 wallpapers at 2,796×1,290 from JPEG quality 82 to AVIF quality 50, and 1.41 GB became 0.68 GB. A 52% reduction.
Then I shipped it to real devices and scrolled the gallery. Nothing felt faster. On a Pixel 4a it felt distinctly worse.
I had been staring at a single number — bytes transferred — and assuming that shrinking it would shrink the wait. It does not, and the reason turned out to be worth writing down.
Perceived speed is transfer plus decode
An image reaches the screen through at least three stages: pulling it over the network, unpacking the compression into a bitmap, and drawing it. Change the format and the first stage shrinks while the second one grows.
onLoadStart and onLoad from expo-image collapse all three into one number, so I had to split them apart. Transfer time comes from R2 logs; decode time is the gap between the bytes arriving and the bitmap being ready on device.
Here are the medians from 20 representative images, three runs each, over Wi-Fi measured at 92 Mbps.
Format
Avg size
Transfer
Decode
Total
JPEG q82
3.6 MB
312 ms
48 ms
360 ms
WebP q80
2.4 MB
208 ms
71 ms
279 ms
AVIF q50
1.7 MB
147 ms
194 ms
341 ms
AVIF saved 165 ms on the wire and handed back 146 ms at the decoder. Net gain: 19 ms. My bill dropped by half; my readers waited exactly as long as before.
The faster the connection, the worse this trade looks. Repeating the measurement throttled to 14 Mbps flipped it — AVIF totalled 1,340 ms against JPEG's 2,105 ms, a clear win. Format rankings invert depending on the device and the network. Pick one based on a single average and you are quietly taxing one half of your audience to pay for the other.
On older hardware, decode hurts twice
The Pixel 4a decoded AVIF in roughly 380 ms per image, about 1.9× the 194 ms I measured on an iPhone 15 Pro. Hardware without an AVIF decoder falls back to the CPU.
During a scroll, several images decode at once, so a slow decoder does not just delay one tile — it drags the UI thread with it. Frame drops on the Pixel 4a went from 3.1% to 11.7% after the AVIF rollout. I had made the experience worse in exchange for a cheaper invoice.
The memory side does not improve either. A decoded bitmap is the same size regardless of the source format, which is the same wall I described in the wallpaper cache memory bloat notes. Changing formats reclaims zero bytes of RAM.
✦
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
✦Why a 52% drop in transfer bytes produced only a 19ms improvement, shown with the transfer and decode times measured separately
✦Accept negotiation versus a manifest with per-format URLs, compared on CDN hit rate, rollback cost, and what each can express
✦Device-tier fallback logic and the 5-step migration order that cut transfer 38% without regressing frame drops on older hardware
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.
Device support sets the ceiling on what you can choose
"Supports AVIF" and "decodes AVIF fast enough to ship" are different statements. Here is what I settled on after measuring on hardware.
Environment
JPEG
WebP
AVIF
Practical call
iOS 17+ / A15+
Yes
Yes
Yes (HW)
AVIF first
iOS 16 / A12–A14
Yes
Yes
Yes (SW)
Prefer WebP
iOS 15 and older
Yes
Yes
No
Fall back to WebP
Android 13+ / flagship
Yes
Yes
Yes
AVIF first
Android 12 / budget
Yes
Yes
Slow
Prefer WebP
Android 11 and older
Yes
Yes
No
Fall back to WebP
The Android 12 row is the interesting one. ImageDecoder accepts AVIF there, so a capability check passes cleanly — and then you ship it and inherit the frame drops. A support matrix built on yes/no hides that step. I now draw the line on measured decode time instead of capability.
I looked at HEIC too. It decodes quickly on iOS, but the Android decoder story and the patent situation are both murky. Since the same asset pack goes to both the App Store and Google Play builds, an asset that only one platform can open would fork my encoding pipeline in two. At the scale one person can maintain, that is a cost I would rather not carry.
Negotiate on one URL, or list every URL in the manifest?
There are two ways to serve the right format: content-negotiate on a single URL via the Accept header, or publish per-format URLs in a manifest and let the client choose.
Concern
Accept negotiation
Per-format URLs in manifest
CDN caching
Vary splits the cache
Independent, high hit rate
Client work
Just send a header
Needs selection logic
Rollback
One line in the Worker
Regenerate the manifest
Decode speed
Cannot be expressed
Reflects measured reality
Debugging
URL hides what was served
URL tells you everything
The Accept header can say "I can decode AVIF." It has no way to say "I can decode AVIF, badly." That single gap is what decided it — the Android 12 problem is invisible to a server doing pure Accept negotiation.
I ended up running both. The manifest makes the real decision; the Worker stays as a safety net for devices holding a stale manifest.
// worker/image-negotiate.js — the net under a stale manifest.// The manifest decides; this exists so nothing ever serves a format the device cannot open.const FALLBACK_CHAIN = ["avif", "webp", "jpeg"];function pickFormat(accept, clientHint) { // clientHint: X-Decode-Tier sent by the app (fast / slow / legacy) if (clientHint === "legacy") return "jpeg"; if (clientHint === "slow") return accept.includes("image/webp") ? "webp" : "jpeg"; for (const fmt of FALLBACK_CHAIN) { if (accept.includes(`image/${fmt}`)) return fmt; } return "jpeg";}export default { async fetch(request, env) { const url = new URL(request.url); const accept = request.headers.get("Accept") || ""; const tier = request.headers.get("X-Decode-Tier") || "fast"; const fmt = pickFormat(accept, tier); const key = url.pathname.replace(/\.\w+$/, "") + "." + fmt; const object = await env.WALLPAPERS.get(key.slice(1)); if (!object) return new Response("Not found", { status: 404 }); return new Response(object.body, { headers: { "Content-Type": `image/${fmt}`, // Keep Vary narrow or the edge cache fragments without bound Vary: "Accept, X-Decode-Tier", "Cache-Control": "public, max-age=31536000, immutable", }, }); },};
Be careful with Vary. Put anything device-specific in there and your edge cache splits once per device variant. My first week shipped with User-Agent in Vary and the hit rate fell from 94% to 61%. Swapping it for a three-value X-Decode-Tier brought it back to 96%.
Detect "decodes fast," not "decodes at all"
The app classifies itself into a tier exactly once. Running a benchmark on every launch would trade startup time for image speed, which defeats the point.
// src/media/decodeTier.tsimport * as Device from "expo-device";import { Platform } from "react-native";import AsyncStorage from "@react-native-async-storage/async-storage";export type DecodeTier = "fast" | "slow" | "legacy";const CACHE_KEY = "media:decodeTier:v2";async function probe(): Promise<DecodeTier> { const major = parseInt(String(Device.osVersion ?? "0").split(".")[0], 10); if (Platform.OS === "ios") { if (major >= 17) return "fast"; // A15+ is the floor here; HW decode assumed if (major >= 16) return "slow"; // decodes, but on the CPU return "legacy"; } // On Android, API level alone does not separate budget from flagship if (major >= 13 && (Device.totalMemory ?? 0) > 5.5e9) return "fast"; if (major >= 12) return "slow"; return "legacy";}export async function getDecodeTier(): Promise<DecodeTier> { const cached = await AsyncStorage.getItem(CACHE_KEY); if (cached) return cached as DecodeTier; const tier = await probe(); await AsyncStorage.setItem(CACHE_KEY, tier); return tier;}export function formatFor(tier: DecodeTier): "avif" | "webp" | "jpeg" { return tier === "fast" ? "avif" : tier === "slow" ? "webp" : "jpeg";}
Device.totalMemory is in there because API level alone failed me. Two Android 13 phones, one with 4 GB of RAM, and the smaller one decoded AVIF 2.4× slower. It is a crude proxy, but across six apps it disagrees with measured decode time only about 5% of the time.
The v2 in the cache key earned its place too. When I corrected the thresholds, the old verdict sat in storage and would not budge. Bumping the version forces every device to re-evaluate.
bytes is there so a data-saver setting can decide before fetching anything. This is the same manifest I described in shipping wallpaper packs without an app review, with src and bytes added. Widening a structure I already operate beat inventing a second one.
Do not convert all 400 images at once
Converting everything and shipping it leaves you nothing to bisect when something breaks. The order I use now:
Thumbnails only. They dominate the gallery's first paint, and a failure there cannot corrupt the save-to-photos path. This alone moves 34% of transfer.
One app, two weeks. Compare crash rate and frame drops against the equivalent window before the change. No regression, no reason to stop.
Full-size images, new packs only. Leave the existing 400 alone. If something surfaces, rolling the manifest back one generation is the entire fix.
Roll out to the other five apps. Only now does the conversion step go into CI.
Re-encode legacy assets when — and if — it pays. It did not.
Step five was supposed to be step one in my original plan. Then I looked at the breakdown: 81% of the last 90 days of transfer went to packs added in the previous 30 days. Re-encoding old assets is effort with almost no invoice attached. Billing broken out per pack is what let me skip it.
There was a faint revenue echo as well. Gallery first paint got shorter, sessions started marginally sooner, and AdMob app-open impressions on Day 1 rose about 4%. The causal link is weak and I would not build a plan on it, but it is a reminder that asset delivery sits upstream of the funnel.
Where the six apps sit today
Asset
fast tier
slow tier
legacy tier
Thumbnails (560px)
AVIF q45
WebP q78
JPEG q80
Full size (new packs)
AVIF q50
WebP q80
JPEG q82
Full size (legacy)
JPEG q82
JPEG q82
JPEG q82
Monthly transfer is down 38% — short of the 52% I originally modelled, because the legacy assets are untouched. In exchange, frame drops on the Pixel 4a are still 3.1%, unchanged, and no tier of reader got a worse experience.
Given 38% or 52%, I took 38%. The invoice is something only I ever see. A stuttering scroll is something every reader sees.
The next thing I want to fix is the tier probe itself. Guessing from OS version and RAM works, but decoding one small AVIF at first launch and timing it would be honest. I have not decided which part of the startup budget pays for that, so it is sitting on the shelf for now.
If you are looking at your own asset delivery costs, measure transfer and decode separately before you change anything. The version of me reading that first invoice believed every byte saved was a millisecond earned. It is not, and finding out cost me two weeks.
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.