Rebuilding Wallpaper Image Delivery Around Resolution Buckets — Letting an Antigravity Agent Own Conversion and Validation
Every new device resolution quietly makes a wallpaper app heavier. I stopped shipping one master image to every device and rebuilt delivery around resolution buckets, WebP/AVIF, and an edge redirect — then handed conversion and validation to an Antigravity agent. Real code and thresholds included.
I'm Masaki Hirokawa, an artist and creator. I run a set of wallpaper apps with over 50 million cumulative downloads as a solo developer, and the cost that hurts the most is the dullest one: device resolutions keep multiplying a little every year. During the update that added the iPhone Air and 17 Pro, I looked at the delivery logs and froze. I was shipping the exact same enormous PNG — one master image — to a device 1080px wide and to one 1320px wide alike.
A wallpaper has a huge pixel count, and people swipe through dozens of them. If you return the master untouched, you transfer pixels that never reach the screen, every single time. When I supported three or four devices it was rounding error. The moment my supported resolutions crossed into double digits, transfer volume and the feel of thumbnail scrolling started to rot, slowly. This is the operational record of rebuilding that delivery layer into a resolution-bucket scheme and handing the tedious conversion and validation to an Antigravity agent.
When device resolutions scatter, delivery breaks quietly
Let me be honest up front: the breakage never shows up as a loud crash. Nobody writes "slow" in a review, and nothing lands in Crashlytics. Instead, the thumbnail list stutters just slightly, the load before an AdMob interstitial stretches out, and users on slow connections leave on the second image. In numbers, the average display time on the wallpaper detail screen had crept from 0.9s to 1.4s.
The cause was simple once I broke it down. My app held masters at 2160px wide and shipped them to everyone. On a 1080px device, exactly half the received pixels were thrown away at display time. For those discarded pixels, I was transferring roughly double the bytes every time. The more device types appear, the wider the gap between "the one image that fits" and "the one image I actually ship."
My grandfather, a temple carpenter, never used a timber whose dimensions did not fit. Rather than shaving a piece down to make it work, he cut each piece for the place it was going. Image delivery is the same: each device deserves an image prepared for its screen. The single obstacle was that hand-producing variants for every resolution is not realistic.
Stop shipping one master to every resolution — bucket design
Giving each device a dedicated image does not mean producing every resolution in 1px steps. Real device widths cluster discretely — 1080 / 1170 / 1206 / 1290 / 1320 — and wallpapers survive a few pixels of scaling without visible damage. So I round widths into eight buckets.
// resolution-buckets.ts// Round a device's logical width (px) to the delivered image-width bucket.// Pull neighboring devices into one bucket to keep variant count down.export const BUCKETS = [828, 1080, 1170, 1242, 1290, 1320, 1440, 1620] as const;export function pickBucket(deviceWidthPx: number): number { // Choose the nearest bucket >= device width (downscale, never upscale). for (const w of BUCKETS) { if (w >= deviceWidthPx) return w; } return BUCKETS[BUCKETS.length - 1];}
There is one deliberate choice here. I pick the nearest bucket on the larger side of the device width. Stretching a small image makes a wallpaper blurry, but shrinking a slightly larger one lands cleanly. The master stays a single 2160px file, and every bucket's WebP and AVIF derive from it. Because there is exactly one original, adding a ninth bucket for a new device later is just a re-generation away.
I settled on eight buckets by working backward from my real device distribution: that is the step count needed to cover the top 99% of my DAU devices. A ninth bucket would benefit about 0.4% of users, while each extra variant grows storage and generation time by a non-trivial ratio. I set "top 99% in 8 steps" as my baseline.
✦
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
✦How rounding device widths into 8 buckets and deriving WebP/AVIF from a single master cut transfer by about 42%
✦Before/After edge-redirect code that returns exactly one optimal file per device, plus the Accept-header handling
✦The validation gate that halts agent-generated variants before production: aspect ratio, file size, and luminance checks
Secure payment via Stripe · Cancel anytime
Generating variants with both WebP and AVIF
Generation runs on Node's sharp. AVIF gets smaller than WebP at equal quality, but it is heavy to encode and a few older devices cannot display it. So I emit both and let the delivery layer choose based on what the device accepts.
// generate-variants.mjsimport sharp from "sharp";import { BUCKETS } from "./resolution-buckets.js";// From one master (2160px wide), derive WebP and AVIF for every bucket.export async function buildVariants(srcPath, outDir, id) { const src = sharp(srcPath); const meta = await src.metadata(); const aspect = meta.height / meta.width; // preserve the tall wallpaper ratio const results = []; for (const w of BUCKETS) { const h = Math.round(w * aspect); const base = src.clone().resize(w, h, { fit: "cover" }); const webp = `${outDir}/${id}_${w}.webp`; const avif = `${outDir}/${id}_${w}.avif`; // WebP q=80 and AVIF q=50 look near-identical for wallpapers (measured). await base.clone().webp({ quality: 80, effort: 4 }).toFile(webp); await base.clone().avif({ quality: 50, effort: 4 }).toFile(avif); results.push({ width: w, webp, avif }); } return results;}
In practice, masters that averaged 3.1MB as PNG came down to about 280KB for the 1080-bucket WebP and around 180KB for AVIF. At the mid buckets most users touch, that is roughly a tenth or less of the original. I could push AVIF to q=50 only because the subject is wallpaper; text or UI screenshots would fall apart at that compression. Choosing quality parameters to match the subject is the crucial part.
A caveat: raising effort shrinks AVIF further, but generation time spikes. Since I batch-convert several thousand images across six apps, I capped it at effort: 4. That choice prioritized "the nightly batch finishes by morning" over "squeeze every last byte."
Returning exactly one optimal file at the edge
Variants are useless if the client cannot pick the right one. My old client fetched a fixed URL for the master. I moved that decision to the edge: catch the request, read the device width and the Accept header, and internally redirect to the optimal file.
Before, every device got the same master:
// Before: return the identical master to all devicesexport default { async fetch(req, env) { const id = new URL(req.url).pathname.split("/").pop(); return env.ASSETS.fetch(`https://assets/${id}_original.png`); },};
After, the device width from the query is rounded to a bucket, and if Accept includes image/avif it returns AVIF, otherwise WebP. The client only attaches its screen width and never needs to know which file is best.
// After: pick one optimal file from device width and Acceptimport { pickBucket } from "./resolution-buckets.js";export default { async fetch(req, env) { const url = new URL(req.url); const id = url.pathname.split("/").pop(); const dw = Number(url.searchParams.get("w")) || 1080; const bucket = pickBucket(dw); const accept = req.headers.get("Accept") || ""; const ext = accept.includes("image/avif") ? "avif" : "webp"; const res = await env.ASSETS.fetch(`https://assets/${id}_${bucket}.${ext}`); // Bucket selection must be part of the cache key, so set Vary explicitly. const out = new Response(res.body, res); out.headers.set("Vary", "Accept"); out.headers.set("Cache-Control", "public, max-age=31536000, immutable"); return out; },};
Forget Vary: Accept and the response meant for AVIF-capable devices gets cached and served to devices that cannot show it, leaving a scatter of broken images. I actually did this during testing. Edge caching is powerful, but if you do not declare the axis you vary on, it fails silently. I can rely on immutable because the filename includes width and extension, making URL and content one-to-one.
Where the line sits when delegating to an Antigravity agent
The machinery above runs once you build it, but operations bring "add new wallpapers" and "add a bucket for a new device" almost every week. Place a master, generate every variant, check size and appearance, push to R2, update the delivery map — I handed that routine to an Antigravity agent.
What I delegated is detecting masters, running buildVariants, collecting output metadata, and running the validation gate below. What I did not delegate is deciding new quality parameters and making the final call on variants the validation rejects. The agent is told to stop and lay out the evidence whenever it is unsure. This screen feeds AdMob revenue directly, so I cannot accept the risk of a broken wallpaper sliding into production unannounced.
Role: wallpaper variant generation operatorInput: masters (2160px-wide PNG) placed in _incoming/Steps: 1. Convert each master into all 8 buckets x WebP/AVIF via buildVariants 2. Run validate-variants.mjs and aggregate pass/fail as JSON 3. Put only passing files to R2 wallpapers/, update delivery-map.jsonOutput: a single Markdown report (passed / needs review / elapsed time)Constraints: - If any item fails validation, hold the R2 put and wait for a human - Do not change quality parameters (q / effort); only propose changes in writing
The point of this prompt design is to confine the agent's discretion to the work and withhold the power to move the standard. Hand over the standard too, and one morning quality has quietly dropped. Fixing the boundary in writing up front made the agent's output stable.
A validation gate so you never trust the output
I always run agent-generated variants through machine validation before they reach R2. The scariest failure for a wallpaper is a broken aspect ratio: a mistaken fit: "cover" or a swapped master crops a face or flips the composition. You cannot eyeball thousands of these, so I codified rules.
// validate-variants.mjsimport sharp from "sharp";// Decide whether each variant can survive production delivery.export async function validate(variant, expectAspect) { const fails = []; const meta = await sharp(variant.path).metadata(); // 1) Aspect ratio: reject if it drifts more than +/-1% from the master const aspect = meta.height / meta.width; if (Math.abs(aspect - expectAspect) / expectAspect > 0.01) { fails.push(`aspect ${aspect.toFixed(3)} != ${expectAspect.toFixed(3)}`); } // 2) File size: reject anything over the expected cap (= failed compression) const cap = variant.width <= 1080 ? 400_000 : 900_000; // bytes if (meta.size > cap) fails.push(`size ${meta.size} > ${cap}`); // 3) All-black / all-white: a classic conversion accident. Reject luminance extremes const stats = await sharp(variant.path).stats(); const mean = stats.channels[0].mean; if (mean < 4 || mean > 251) fails.push(`mean luminance ${mean.toFixed(1)}`); return { ok: fails.length === 0, fails };}
All three rules were reverse-engineered from accidents I actually caused, one each. The aspect drift came from a swapped master, the oversize came from a run where I dropped AVIF effort too far, and the luminance anomaly came from grabbing a file corrupted mid-conversion. A validation gate works best when you write a clause for each accident that happened, not each one that might. I do not try to author perfect rules up front; I add one line every time something hurts.
The agent runs validate across every variant, holds publication of any wallpaper group with even one ok: false, and lists the reasons in the report. In the morning I read only that report and decide whether to approve the held set or fix the master. The time it takes shrank to a few minutes per hundred wallpapers.
How I estimated storage and transfer
The variant approach cuts transfer at the cost of holding more storage, because one master spawns 8 buckets x 2 formats = 16 files. Start without estimating that and R2 storage fees will trip you. In my numbers, the variants per wallpaper averaged about 4.8MB. Across six apps I hold roughly 9,000 wallpapers, so storage sits around 43GB. Because R2 charges for storage but not egress, the monthly storage cost landed in the single-dollar range, while transfer dropped about 42% versus shipping masters.
I judged this "storage up, transfer down" trade worth it at indie scale because a wallpaper app is a transfer-dominated workload: one user downloads dozens of images, so investing in a lighter per-transfer payload pays off. For an app whose images are barely viewed, this would be over-engineering. Measuring which way your workload leans first looked like a detour but was the shortcut.
The client only passes one screen width
Having made delivery smart, I kept the client deliberately thin. All a device does is compute one physical pixel width and attach it to the URL query. On iOS I pass bounds.width times scale; on Android, DisplayMetrics.widthPixels.
// iOS: attach one physical pixel width. The client need not know which file is best.let scale = UIScreen.main.scale // 2.0 or 3.0let widthPx = Int(UIScreen.main.bounds.width * scale)let url = "https://api.dolice.asia/wp/\(id)?w=\(widthPx)"
I stumbled here once by confusing logical and physical width. At first I passed bounds.width (logical, e.g. 393 on an iPhone) directly, so the edge always returned the smallest bucket. The screen filled with blurry wallpapers, and it took me half a day to trace. Just a forgotten scale multiplier — an ordinary slip. The upside of a thin client is that even when such a slip happens, the edge logs alone reproduce it, so isolation is fast.
Discarding the old file when a master changes
Because I rely on immutable, I cannot swap content under the same URL. That is both a strength and a trap: you fix and re-generate a master, yet the edge and the device keep holding the old variant and your fix never shows. The fix is simple — weave a content hash into the filename so a changed master changes the URL itself.
// Mix the first 8 hex of the content hash into the name: a swap == a URL change.import { createHash } from "node:crypto";function variantName(id, width, ext, srcBuffer) { const h = createHash("sha256").update(srcBuffer).digest("hex").slice(0, 8); return `${id}_${width}_${h}.${ext}`;}
The delivery map resolves a wallpaper ID to its current hash. The client fetches the map by ID and pulls the actual file via the returned hashed URL. Re-generate a corrected master and the hash changes, the map points at the new URL, and old variants simply stop being referenced. Being freed from manually purging the edge cache mattered more to operations than I expected. Before this, every single wallpaper fix meant a cache-purge step — and in solo development, that small recurring step is the first thing to lapse. Bind the URL to the content and a fix becomes "re-generate and update the map." I prioritize this kind of "remove a step" improvement over improvements that add features.
Three operating rules I settled on the hard way
To close, the rules that crystallized over about half a year of running this layer — each one written down only after I tripped over it.
First, the master is always the single source of truth, and variants are derivatives. Start hand-editing variants directly and the master drifts out of sync with what you ship, until you cannot tell which is real. Make every edit against the master and rebuild the variants by re-generation.
Second, reconciling the delivery map against storage is the agent's last job. A wallpaper listed in delivery-map.json but missing from R2 — or the reverse — really does cause broken displays. I have the agent cross-check map against storage after the put completes and halt publication on any diff.
Third, only humans touch the quality parameters. Move q or effort and thousands of images shift at once. Automate that and you invite a quiet decay where, one day, every wallpaper across your apps looks slightly sleepier. Work to the agent, standards to me. That split is my own safety catch for letting AI run operations in solo development.
If you also run an image-heavy app on your own, I hope this gives you a reason to revisit your delivery layer.
Share
Thank You for Reading
Antigravity 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.