CDN Url Refreshing & Multi-layer Caching Design

2026, May 22    

Co-authored by Claude Sonnet 4.6

Videos, audio, and images in a client app are usually hosted on a CDN (OSS / S3). For auth and hot-link protection, the backend hands out signed, time-limited URLs — a plain GET after expiry returns 403. The tricky part is that the client has no control over when a URL is consumed: a user might tap a thumbnail right away, or scroll back to an old message a week later and only then start playback.

To handle this we introduced a resourceUrlExchange endpoint that swaps an “original URL” for “a signed URL that’s valid right now” before downloading or playing. But if every playback or render calls the endpoint, a list view easily fires dozens of concurrent requests, blocking first paint and burning bandwidth. This post walks through the five layers we built around that problem, and how they compose into a nearly transparent caching pipeline.

The snippets below happen to use a Flutter idiom, but the design itself is platform-agnostic — the same layering maps cleanly onto iOS, Android, Web, or RN clients; only the package names change.

Terminology

  • Signed URL — temporary download URL carrying expires / signature parameters; unusable once expired
  • Original URL — the signature-free, stable resource identifier, e.g. https://cdn.example.com/videos/abc.mp4
  • resourceUrlExchange — backend endpoint: takes an original URL, returns a currently valid signed URL
  • ResourceService — the app’s single entry point for resource downloads and URL refresh (anonymized)
  • UrlCacheStore — the local KV store mapping “original URL → refreshed URL” (anonymized; SQLite in our case, but any embedded KV works)

Why hammering resourceUrlExchange hurts

A video list paints N thumbnails at once; the player prefetches three videos on each side of the current one; the background audio coordinator refreshes the entire playlist at startup; a single avatar can appear simultaneously in the address book, chat page, and message list. One endpoint call per resource quickly becomes dozens of requests per second, which causes:

  • Endpoint failure → blank resources in the UI
  • Duplicate requests wasting bandwidth and backend capacity
  • First paint blocked on endpoint RTT

The goal: shrink the set of cases where we actually have to call the endpoint down to as close to zero as possible.

Layer 1: Local KV cache for the URL mapping

UrlCacheStore keeps a flat table:

original_url (UNIQUE) | refreshed_url | created_at

Step one of refreshUrl(url) is a lookup against this table — if it hits and age < 7 days, return immediately, no network. Writes use ON CONFLICT DO UPDATE so a given original URL keeps exactly one row, preventing stale rows from accumulating.

// pseudocode
final cached = await _urlCacheStore.query(originalUrl);
if (cached != null && !cached.isExpired) return cached.refreshedUrl;

Effect: every render of the same resource within a 7-day window hits local storage only.

In dev builds we shrink the window to 60 seconds, which makes it easy to force-refresh while integrating.

Layer 2: Local binary file cache

The URL mapping cache only solves “don’t re-fetch the URL”; it doesn’t solve “don’t re-download the bytes”. Layer two is a binary file cache, sized per resource type:

Cache instance Key Stale Max items
Video / audio cache video_file_cache 30 d 200
Image cache image_file_cache 7 d 300

Key design point: the file cache keys by the original URL, but the actual download uses the refreshed signed URL. Even though the signed URL’s query params change every refresh, the cache is always indexed by a stable key — old downloads keep hitting forever.

Any decent file-cache library supports this “custom key” pattern: flutter_cache_manager on Flutter, SDWebImage / Kingfisher on iOS, Glide / Coil on Android, browser HTTP cache + IndexedDB on Web. The platform doesn’t really matter; what matters is decoupling the cache key from the download URL.

Layer 3: Callers check the file first, then maybe refresh the URL

The playback coordinators (VideoPlaylistCoordinator, AudioPlaylistCoordinator) all follow the same shape:

final cached = await ResourceService.getLocalCacheFile(url);
if (cached != null) return cached.path;   // file-cache hit; skip network entirely
return ResourceService.refreshUrl(url);   // miss → refresh URL

On a file-cache hit we skip both the resourceUrlExchange call and the CDN download itself — the player just reads bytes off disk.

Layer 4: A stable cache key in the image layer to mask signature drift

Image-rendering libraries (cached_network_image on Flutter, SDWebImage on iOS, Glide on Android, <img> + browser cache on Web) default to keying by imageUrl. Since signed URLs differ on every refresh, the default behavior wipes the image cache constantly. Fix: derive a stable key from scheme://host/path and ignore the query string.

String extractStableCacheKey(String url) {
  final uri = Uri.parse(url);
  return '${uri.scheme}://${uri.host}${uri.path}';
}

// usage
final effectiveKey = cacheKey ?? extractStableCacheKey(imageUrl);
return CachedNetworkImage(imageUrl: imageUrl, cacheKey: effectiveKey, ...);

A custom file-fetcher (e.g. ImageCacheFileService) calls refreshUrl(url) only at the moment of actual network download. The UI layer keys by the stable URL; the network layer uses the signed URL — the two concerns don’t bleed into each other.

Layer 5: Prefetch + concurrency throttling + dedup queue

Prefetch warms up three videos on each side of the current one — but submitting seven downloads at once saturates the connection pool. ResourceService.syncToLocalCache keeps three pieces of in-memory state:

_maxConcurrentDownloads = 3          // global cap
_inProgress: Set<String>             // original URLs currently downloading
_queue: List<(url, refreshedUrl)>    // waiting

Rules:

  • URL already in _inProgress or _queue → skip; don’t double-enqueue
  • File cache already has it and it isn’t stale → skip; don’t download
  • Concurrency budget available → start immediately; otherwise enqueue, and each completion calls _drainQueue() to pick the next one
  • Callers are fire-and-forget; nothing blocks UI

Layer 6: Fall back to the original URL on failure

refreshUrl doesn’t throw on endpoint failure — it returns the original URL instead:

try {
  final result = await _api.resourceUrlExchange(url);
  return result.refreshedUrl;
} catch (e) {
  log.warn('refreshUrl failed, falling back to original: $e');
}
return url; // fallback

This means even when the endpoint is down, an old URL whose signature hasn’t expired yet keeps working. Callers don’t need their own try/catch, and combined with the file cache this becomes a graceful “play whatever still plays” degradation.

How the layers compose

%%{init: {'flowchart': {'nodeSpacing': 25, 'rankSpacing': 35}}}%% flowchart TD A([Caller: play / render resource]) --> B B{"① Local file cache\nhit?"} B -- "hit (file exists and fresh)" --> Z1([Play / render directly\nskip URL refresh + CDN download]) B -- miss --> C C{"② Local URL-mapping cache\nhit and age < 7 days?"} C -- "hit" --> D([Return cached refreshed URL\n→ download and populate file cache]) C -- miss / expired --> E E{"③ Dedup check\nsame URL already in\n_inProgress / _queue?"} E -- "yes (downloading)" --> F([Skip; do not re-enqueue]) E -- no --> G G{"④ Concurrency throttle\nactive downloads < 3?"} G -- yes --> H G -- no, enqueue --> I([Add to queue\nwait for scheduling]) H[Call remote resourceUrlExchange] H --> J{"⑤ Endpoint success?"} J -- "fail / exception" --> K([Fallback: return original URL\nstill downloadable while signature valid]) J -- success --> L[Write to local URL-mapping cache] L --> M([Return new signed URL\n→ download + populate file cache]) M --> N([Download done\nschedule next task]) style Z1 fill:#4CAF50,color:#fff style F fill:#9E9E9E,color:#fff style K fill:#FF9800,color:#fff style D fill:#2196F3,color:#fff style M fill:#2196F3,color:#fff style I fill:#9C27B0,color:#fff

Read end-to-end, the whole thing is three layers of fallback, outermost first:

Layer Medium What a hit skips
1. In-memory set (dedup queue) RAM duplicate download requests
2. File cache (binary blobs) Disk file URL refresh endpoint + CDN download
3. URL-mapping cache (KV store) Disk KV URL refresh endpoint (still hits CDN to download)

Wrap

The core idea of the whole stack reduces to one sentence: index by the stable original URL, and short-circuit the request chain as close to the user as possible. A file-cache hit means doing nothing at all; a URL-mapping hit at least skips an endpoint call; only when both miss do we actually invoke resourceUrlExchange. Add concurrency throttling and a graceful fallback, and the entire pipeline is essentially invisible to callers — they just pass an original URL and let ResourceService handle everything else.

Although the snippets in this post are written in a Flutter idiom, none of the six layers depend on Flutter. The same shape ports cleanly to iOS (URLCache + SDWebImage / Kingfisher), Android (OkHttp cache + Glide / Coil), Web (Service Worker + IndexedDB), or React Native — only the package names change.

TOC