●GPT6.1 — GPT-6.1 Sol joined the Rork model menu (Sep 29). It is the latest entry on the changelog●10/12 — 7 days left until React Native 0.88.x is due. Expo SDK 58 stable is only described as early October●NEW — Multiseat Purchases Are Already On: Decide Whether to Keep Them Before October 22●SDK 58 — A fix PR (#50998) for npm install failing in new projects is under review. No stable date has been given yet●SONNET — Claude Sonnet 5.5 is now in Rork (Sep 28). It is described as over 30% faster than Sonnet 5●iOS 27 — From April 2027, App Store uploads require the iOS 27 SDK. There is time to update your build environment calmly●GPT6.1 — GPT-6.1 Sol joined the Rork model menu (Sep 29). It is the latest entry on the changelog●10/12 — 7 days left until React Native 0.88.x is due. Expo SDK 58 stable is only described as early October●NEW — Multiseat Purchases Are Already On: Decide Whether to Keep Them Before October 22●SDK 58 — A fix PR (#50998) for npm install failing in new projects is under review. No stable date has been given yet●SONNET — Claude Sonnet 5.5 is now in Rork (Sep 28). It is described as over 30% faster than Sonnet 5●iOS 27 — From April 2027, App Store uploads require the iOS 27 SDK. There is time to update your build environment calmly
Switching to Signed URLs Killed My Image Cache — Decoupling expo-image Keys from the URL
Signed URLs rewrite their query string on every expiry, so a URL-keyed cache never hits. Here is how to pin the key with source.cacheKey, what the cache read and write APIs actually return, and what changed when I re-ran the measurements.
The week after I moved image delivery behind signed URLs, one graph moved: bandwidth. Same catalog, same resolutions, same screens. The only thing that changed was the shape of the URL.
The culprit was the cache key. A signed URL rewrites X-Amz-Signature and X-Amz-Date every time the expiry rolls over. To the library, that is not yesterday's image. It is an image it has never seen.
As an indie developer running image-heavy wallpaper apps, I spent an embarrassing amount of time raising the disk cache ceiling before I understood this. Raising the ceiling does nothing. The cache was not full. It was never being hit.
Updated 2026-09-20. I went back through this article against the Expo API reference and found two things I had gotten wrong: the call shape of writeToCacheAsync, the return type of readFromCacheAsync, and — separately — the skew exponent I quoted alongside my own measurements. All of it is rewritten below, and I have left the corrections visible rather than editing them out of existence.
Every new signature makes the same image a different image
A signed URL attaches proof to an object path: this key, this operation, valid until this moment. Because the proof is bound to an expiry, it has to be regenerated once that expiry passes.
So the same wallpaper, w042.webp, shows up like this across sessions:
The path is identical. The string is not. That gap is the whole problem.
Component
Behavior across sessions
Safe to key on?
Host
Usually stable, but changes when you move CDNs
Not on its own
Path (/wallpapers/w042.webp)
Stable. Expresses object identity
Yes
X-Amz-Date / X-Amz-Signature
Changes on every expiry, by design
Never
Transform params (?w=1080&fm=webp)
Changes when the request changes
Yes. Different variant, different key
The key should encode which object, in what shape — never who authorized it, and when.
A URL is the delivery slip. The key is the shelf number. The slip gets reprinted on every pickup; there is no reason to renumber the shelf along with it.
Where the default cache key actually comes from
expo-image derives its on-disk location from the source.uri string. cachePolicy selects which layers store the bytes, memory or disk. It does not change how the key is built. Conflating the two is how you end up with cachePolicy="memory-disk" set correctly and nothing being reused.
// This picks a storage layer. It has zero influence on key stability.<Image source={{ uri: signedUrl }} // the string changes every session cachePolicy="memory-disk" // bytes get stored, then never looked up again style={styles.thumb}/>
The bytes do land on disk. They simply become entries nobody will ever request again, consuming your disk budget for nothing. The bloat side of that story is covered in Your Rork App's "Documents & Data" Keeps Growing, but when signed URLs are involved, tuning the ceiling will not help. The problem is the key, not the capacity.
✦
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
✦You can pinpoint why your bandwidth climbed after moving to signed URLs, and decide exactly which layer to fix
✦You get the real signatures of source.cacheKey, writeToCacheAsync, readFromCacheAsync and getCachePathAsync, and know which one to reach for
✦You can estimate how much of your hit rate a key redesign would recover, by fitting the skew exponent from your own view logs
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.
Before anything else, I want to say this: the library already gives you the dial. ImageSource has a cacheKey field, documented as "the cache key used to query and store this specific image. If not provided, the uri is used also as the cache key" (Expo Image API reference).
Which means you can solve this declaratively, before you touch the cache API at all.
// The URL is free to change. Pin the key, and reads and writes land on the same shelf.<Image source={{ uri: signedUrl, cacheKey: "v1:/wallpapers/w042.webp?w=1080&fm=webp" }} cachePolicy="memory-disk" style={styles.thumb}/>
When I first wrote this article I had skipped past that field and hand-rolled the read and write path instead. The detour was not wasted — it taught me where the library's responsibility ends and mine begins — but it was a detour. Check whether the declarative option covers you, then hand-write only the remainder. Skip that order and the code always grows.
You choose the key string yourself, so the helper only has to normalize.
// lib/imageKey.ts/** Params that belong in the key. Anything not listed here cannot affect identity. */const VARIANT_PARAMS = ["w", "h", "fm", "q", "dpr"] as const;/** Key scheme version. Bump this on the day you change schemes. */const KEY_VERSION = "v1";/** * Derives a signature-independent key from a signed URL. * Synchronous. Same object plus same transform yields the same string, whatever the signature. */export function stableImageKey(url: string): string { const u = new URL(url); // Pull only the transform params, in the array's fixed order const variant = VARIANT_PARAMS .map((p) => { const v = u.searchParams.get(p); return v === null ? null : `${p}=${v}`; }) .filter((s): s is string => s !== null) .join("&"); // Host is deliberately excluded so a CDN migration does not nuke the cache const path = u.pathname.replace(/\/+$/, "").toLowerCase(); return variant ? `${KEY_VERSION}:${path}?${variant}` : `${KEY_VERSION}:${path}`;}// Expected output (two URLs differing only in signature)// stableImageKey(".../w042.webp?w=1080&X-Amz-Signature=6f1c...")// -> "v1:/wallpapers/w042.webp?w=1080" same// stableImageKey(".../w042.webp?w=1080&X-Amz-Signature=a93e...")// -> "v1:/wallpapers/w042.webp?w=1080" same// stableImageKey(".../w042.webp?w=540&X-Amz-Signature=a93e...")// -> "v1:/wallpapers/w042.webp?w=540" different transform, different key
The first version of this function ran the canonical string through expo-crypto and returned a SHA-256 digest. It no longer does, and the reason is not cost. digestStringAsync returns a Promise, so a list puts one async hop per row in front of rendering — which drags useEffect and useState along with it. Making a synchronous decision asynchronous is how a simple key turns into a loading state.
The hash itself stayed cheap. On Node v22.23.2, hashing a canonical string of the same length 100,000 times took 132.8 ms, about 1.33 microseconds per call. That is nothing. But cheap is not the same as necessary, and this one was not necessary.
Leaving the host out pays off later. If flipping your delivery origin invalidates every cached byte, your migration day shows up as a bandwidth spike. Object identity is determined by the path, not by where the bytes happen to be served from, so the key should reflect that.
The allowlist in VARIANT_PARAMS follows the same logic. A denylist breaks the moment your CDN appends one analytics parameter. Naming what you include is the safer direction.
What writeToCacheAsync and readFromCacheAsync actually take and return
There is still a case source.cacheKey does not cover: seeding the cache with a file that is already on the device — one returned by expo-image-picker, or downloaded with expo-file-system — without a network round trip. That is what the write API is for.
I had both of these APIs wrong in the first version. Here they are as documented.
API
Arguments
Returns
Easy to get wrong
writeToCacheAsync(source, cacheKey)
Two positional args. source is a local file URI or ImageRef
Promise<void>
A remote URL will not do, and there is no options object. You cannot read a path back from the return value
readFromCacheAsync(cacheKey)
Key string only
Promise<ImageRef | null>
Not a local path. Pass it straight to source, without wrapping it in a uri object
getCachePathAsync(cacheKey)
Key string only
Promise<string | null>
This is the one that gives you a path, and doubles as an existence check
One documented caveat on the write side: seeding an animated image (GIF, APNG, animated WebP) from an ImageRef flattens it to a single frame, because the reference holds the decoded image rather than the original bytes. To keep the animation, pass the local file URI instead.
Corrected, the read-and-write path looks like this.
// lib/imageCache.tsimport { Image, type ImageRef } from "expo-image";import * as FileSystem from "expo-file-system";import { stableImageKey } from "./imageKey";type Resolver = (objectPath: string) => Promise<string>; // issues a signed URL/** * Looks up the stable key first, and only signs and fetches on a miss. * Returns an ImageRef you can hand straight to <Image source={ref} />. */export async function resolveCachedImage( objectPath: string, // e.g. "/wallpapers/w042.webp" variantQuery: string, // e.g. "w=1080&fm=webp" sign: Resolver): Promise<ImageRef | null> { // Key derivation needs no signature, and finishes synchronously const key = stableImageKey( `https://placeholder.invalid${objectPath}?${variantQuery}` ); const cached = await Image.readFromCacheAsync(key); if (cached) { return cached; // served without issuing a single signature } // Only now do we sign. The number of signing calls drops with it. const signed = await sign(`${objectPath}?${variantQuery}`); // writeToCacheAsync wants a local file, so land it on disk first const tmp = `${FileSystem.cacheDirectory}${encodeURIComponent(key)}`; const { uri: localUri } = await FileSystem.downloadAsync(signed, tmp); await Image.writeToCacheAsync(localUri, key); // returns void return Image.readFromCacheAsync(key);}
Order matters more than anything else here. You do not sign and then check the cache; you check the cache and sign only when you must. If signing means an API round trip or an edge function invocation, that ordering alone visibly reduces your call volume.
That said, this path is only worth taking when the bytes are already on the device. If you are fetching over the network anyway, source.cacheKey from the previous section removes the download-then-write-back round trip entirely.
The call site becomes much smaller:
// components/WallpaperThumb.tsximport { Image } from "expo-image";import { stableImageKey } from "../lib/imageKey";export function WallpaperThumb({ objectPath, signedUrl,}: { objectPath: string; signedUrl: string | undefined;}) { const cacheKey = stableImageKey( `https://placeholder.invalid${objectPath}?w=1080&fm=webp` ); return ( <Image // never undefined, even before the URL arrives (see pitfall 3) source={{ uri: signedUrl ?? "", cacheKey }} cachePolicy="memory-disk" recyclingKey={cacheKey} // stops the previous row's image showing in FlashList style={{ width: 120, height: 213 }} transition={120} /> );}
recyclingKey is there to blank the view before the new source resolves, which is what kills the flash of the previous row in a recycling list. For how this interacts with prefetching during scroll, see When Rork-Built Lists Stutter.
The numbers, and the exponent I got wrong
How much you recover depends entirely on your access distribution, so guessing is pointless. I wrote a simulator matching the shape of my own catalog and ran it.
The figures I published originally came from these parameters: 300 objects in the catalog, 60 viewed per session, 20 sessions, 1.8 MB average per image, a 256 MB disk budget, LRU eviction. Signatures are reissued every session.
Key strategy
Hit rate
Bytes transferred over 20 sessions
LRU evictions
Full URL as key (the default)
17.7%
1.74 GB
846
Normalized path plus transform
59.3%
0.86 GB
346
That is a 50.6% reduction in total bytes transferred. The eviction count is the more revealing figure: 846 down to 346. Dead entries were crowding out the budget, which meant genuinely popular wallpapers were being pushed out to make room for URLs that would never be requested again. The damage compounded.
Re-running it, all of that still holds. What did not hold was the sentence I attached to it: "a popularity-skewed access pattern (power law, exponent 2.2)."
Sweeping the exponent with everything else fixed, the run that reproduces those numbers sits at exponent 0.6. At 2.2 the top handful of images absorb roughly seventy percent of all views, and under that much skew even a URL key clears an 86% hit rate. I had simply written down the wrong parameter.
Here is the sweep, averaged over ten seeds.
Skew exponent
URL key hit rate
Stable key hit rate
URL key transfer
Stable key transfer
Reduction
0.6 (reproduces the published figures)
17.1%
58.7%
1.75 GB
0.87 GB
50.2%
0.8
26.2%
68.1%
1.55 GB
0.67 GB
56.7%
1.0
38.7%
78.0%
1.29 GB
0.46 GB
64.1%
1.2
52.0%
85.1%
1.01 GB
0.31 GB
69.2%
1.5
68.7%
91.1%
0.66 GB
0.19 GB
71.7%
2.0
83.5%
96.3%
0.35 GB
0.08 GB
77.8%
2.2
86.6%
97.3%
0.28 GB
0.06 GB
80.4%
This table also retracts my original guidance that "relaxing the exponent to 1.5 narrows the gap and pushing it to 2.5 widens it." For one thing, 1.5 is not a relaxation of 0.6. For another, more skew narrows the hit-rate gap, not widens it: 41.6 points at 0.6, down to 10.7 points at 2.2.
Meanwhile the transfer reduction moves the other way, climbing from 50.2% to 80.4%. With heavy skew the popular handful simply stays resident, so a larger share of wasted re-downloads disappears. Two metrics moving in opposite directions, and I had compressed them into one sentence pointing the wrong way.
Fit the exponent from your own logs before you copy anyone's. Skip that and the numbers stay correct while the reading of them goes wrong.
The bench is short enough to include, so you can run it yourself:
// cache_sim.mjs — node cache_sim.mjs// Compares cache key strategies under signed URLs (LRU with a disk ceiling)const CATALOG = 300, VIEWS = 60, SESSIONS = 20;const AVG = 1.8 * 1024 * 1024, CAP = 256 * 1024 * 1024;// Reproducible PRNG (mulberry32)function rng(seed) { return function () { seed |= 0; seed = (seed + 0x6D2B79F5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}// Cumulative table for a power-law popularity distributionfunction zipf(n, s) { const w = []; let sum = 0; for (let i = 1; i <= n; i++) { const x = 1 / Math.pow(i, s); w.push(x); sum += x; } const c = []; let a = 0; for (const x of w) { a += x / sum; c.push(a); } return c;}function pick(c, r) { let lo = 0, hi = c.length - 1; while (lo < hi) { const m = (lo + hi) >> 1; if (r <= c[m]) hi = m; else lo = m + 1; } return lo;}function run(mode, s, seed) { const c = zipf(CATALOG, s), rand = rng(seed), lru = new Map(); let used = 0, hit = 0, miss = 0, dl = 0, ev = 0; for (let ss = 0; ss < SESSIONS; ss++) { const sig = `sig${ss}`; // the signature rotates each session for (let v = 0; v < VIEWS; v++) { const id = pick(c, rand()); const size = Math.round(AVG * (0.6 + 0.8 * rand())); const key = mode === "url" ? `/img/${id}.webp?w=1080&${sig}` // default: whole URL : `/img/${id}.webp?w=1080`; // stable key if (lru.has(key)) { // on a hit, move to the MRU end hit++; const b = lru.get(key); lru.delete(key); lru.set(key, b); continue; } miss++; dl += size; lru.set(key, size); used += size; while (used > CAP) { // evict oldest until under the ceiling const o = lru.keys().next().value; used -= lru.get(o); lru.delete(o); ev++; } } } return { h: hit / (hit + miss), g: dl / 1073741824, e: ev };}const seeds = [1,2,3,4,5,6,7,8,9,10];for (const s of [0.6, 0.8, 1.0, 1.2, 1.5, 2.0, 2.2]) { const agg = (m) => { let h = 0, g = 0, e = 0; for (const sd of seeds) { const r = run(m, s, sd); h += r.h; g += r.g; e += r.e; } return { h: h / seeds.length, g: g / seeds.length, e: e / seeds.length }; }; const a = agg("url"), b = agg("stable"); console.log( `s=${s}\tURL ${(a.h*100).toFixed(1)}% ${a.g.toFixed(2)}GB\tstable ${(b.h*100).toFixed(1)}% ${b.g.toFixed(2)}GB\tsaved ${((1-b.g/a.g)*100).toFixed(1)}%` );}
These are simulated figures, not device measurements. Swap in your own CATALOG, VIEWS and CAP, then fit the exponent: take the share of total views your top N images absorb, and read back the s that produces the same share from the table above. That number, not mine, tells you whether the work pays for itself.
The re-fetch path when a signature has expired
Once the cache is decoupled, you will eventually hold a URL whose signature has lapsed. Prefetching a URL and using it hours later is the classic route there.
// lib/fetchWithResign.tsconst RESIGNABLE = new Set([401, 403]);/** * Re-signs and retries exactly once, and only for expiry-shaped failures. * Re-signing a 404 or a 5xx accomplishes nothing, so we narrow the set. */export async function fetchWithResign( objectPath: string, variantQuery: string, sign: (p: string) => Promise<string>): Promise<Response> { const target = `${objectPath}?${variantQuery}`; let res = await fetch(await sign(target)); if (RESIGNABLE.has(res.status)) { res = await fetch(await sign(target)); // one re-sign, no more } return res;}// Expected behavior// 200 -> returned as is (1 signing call)// 403 -> re-signed, then 200 (2 signing calls)// 404 -> returned as is, no re-sign (no wasted calls)
Capping the retry at one matters because a misconfigured key returns 403 permanently. Without the cap you retry forever and the only thing that grows is your bill. Persistent 403s usually trace back to bucket policy rather than to expiry; Supabase Storage Returns 403 After Image Upload walks through that diagnosis.
Four things that bit me
Pitfall 1 — query order splits the key. Depending on the CDN or SDK, you will see both ?w=1080&fm=webp and ?fm=webp&w=1080. Concatenating URLSearchParams in enumeration order gives one image two keys. Fixing the order to the VARIANT_PARAMS array is what prevents that.
Pitfall 2 — case and trailing slashes. Anywhere a path is assembled by hand, /Wallpapers/ and /wallpapers/ eventually coexist. Either normalize before deriving, or make it structurally impossible by generating paths in exactly one place. I moved path construction into a single catalog-layer function and banned string concatenation in components.
Pitfall 3 — an undefined uri blocks the read, even with a valid key. This one is specific to signed URLs. If you fetch the signature in a useEffect, then before it resolves — or after it fails offline — your uri is undefined. The cacheKey is right there, so the cached bytes ought to render. They do not. A minimal reproduction of exactly this setup is filed as expo/expo issue #40442, along with the workaround: pass an empty string "" instead of undefined and it loads. It fails on the offline first paint, which is the worst place to discover it, so the component above uses signedUrl ?? "".
Pitfall 4 — changing key schemes spikes bandwidth once. Everything cached under the old scheme becomes a miss. Ship that on release day with no staging and it registers as a bandwidth incident. Hold it at 5% rollout for a day and watch both disk usage and transfer before widening. The v1: prefix on the key exists partly so you can trigger that reset deliberately rather than by accident.
Deciding whether this is worth it
This is not a change everyone should make. Four conditions decide it.
Condition
Worth the work?
Why
Signature TTL is shorter than a typical session
High
Keys split even within one session
Users revisit the same images across sessions
High
Recovered hit rate translates directly
Weak popularity skew (exponent under 1.0)
High
The default key cannot reach a 40% hit rate
Images are viewed once (generated per request)
Low
The cache has no work to do
Public bucket, no signing
None
The default key is already stable
If all you do is pass source.cacheKey, the addition is the twenty-line normalizer and nothing else. Take over the reads and writes and you take over invalidation, migration, and recovery from corruption too — worth weighing before you start. Either way, build the escape hatch into the key from day one. On the day you want to change schemes, one character becomes v2: and every entry regenerates itself.
Format selection and decode cost live in the same layer, so reading Half the Bytes, the Same Wait alongside this makes it easier to sequence which layer to touch first.
Wrapping up
Start by logging two sessions' worth of signed URLs for the same image and putting them side by side. A minute of reading tells you exactly which components move. Once you can see that, what belongs in the key stops being a design question.
Cache work is invisible when it succeeds, and watching a bandwidth graph settle back down is a quiet kind of satisfaction. Re-measuring my own article and finding the exponent wrong was rather less comfortable — but I would rather be uncomfortable here than send you off with a parameter that does not hold.
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.