Skip to main content

Command Palette

Search for a command to run...

Offline Support in Web Apps: Serving Images without the Internet

Published
12 min read
Offline Support in Web Apps: Serving Images without the Internet
T

I help product teams build quality software and lead engineering efforts. Currently working at OpenSpace as a Senior Software Engineer.

Images are heavy. When you're building offline support, storing them alongside your application data is tempting — and eventually painful.

I hit this wall building support for image attachments. The naive approach was to store images inside the persisted React Query cache in IndexedDB we have set up for our application. It worked at first, for a few items. Then errors started showing up in our logs, and iOS Safari tabs began whitescreening under memory pressure once the persisted cache grew past a few dozen megabytes.

This is the tenth and final post in my series about offline support in web applications. If you're new to the series, the earlier posts on persisting React Query to IndexedDB and structuring app state for offline cover the foundations this post builds on. Today we're tackling the storage problem that appears the moment your offline app handles images.

The Problem: Images Don't Belong in App State

The naive strategy looks like this: fetch the image, convert it to a base64 data URL, and store that string alongside your application state.

It's simple, but it has significant drawbacks.

  • Base64 encoding adds ~33% size overhead over raw bytes.

  • Image bytes get serialized into JSON alongside lightweight application state.

  • Every read pays a structured-clone deserialization cost as data crosses from IndexedDB into the JS heap.

  • IndexedDB balloons quickly. For hundreds of images, you're looking at dozens of MB of persisted state.

The result depends on the device. On desktop it's slow cold-start — load times and a storage footprint that grows with every image your user encounters. On mobile it's much worse: cold-start deserialization pulls megabytes of base64 strings into the JS heap all at once, and on memory-constrained devices it simply crashes under the pressure.

In a web application I'm working on, moving images to an appropriate storage layer gave us a 93% reduction in IndexedDB footprint and 16% faster cold-start LCP — making the solution reliable on mobile devices.

The Fix: Split Your Data Layer

Image bytes don't really belong in your application state. Application state is for the lightweight, structured data your UI reasons about — IDs, titles, timestamps, relationships. Stuffing megabytes of binary data in there is what makes IndexedDB choke.

So if not in app state, where?

The browser actually gives you a purpose-built storage layer for this: the Cache Storage API. It's designed to hold HTTP responses — bytes and all — keyed by request URL. No JSON serialization, no base64 encoding, no main-thread deserialization on startup. Just raw responses, stored natively, ready to be served back when the same URL is requested again.

What makes Cache Storage useful is pairing it with a service worker. A service worker is a script the browser runs in the background, separate from any page. It sits between your app and the network and can intercept requests from your app. That means your app code doesn't need to know caching exists — it just renders <img src={url}>, the service worker quietly catches the request, and decides whether to serve from cache or hit the network.

Put together, the split looks like this:

Layer Stores Mechanism
App state (IndexedDB) JSON data + image URL strings React Query + persistence
Service worker cache (Cache API) Image bytes Workbox runtime caching

This solves the original problem. IndexedDB stays small because it only holds strings. Cold-start is fast because there are no megabytes to deserialize into the JS heap. And mobile stops crashing because the heavy bytes never enter the JS heap at all — the browser serves them directly from Cache Storage to the rendering pipeline, exactly where binary data was always supposed to live.

The before-and-after request flow makes the shift concrete.

Before:

fetch(imageUrl) → response.blob() → convert to base64 data URL → store data URL in app state → persist to IndexedDB

After:

store imageUrl string in app state → persist to IndexedDB render → service worker intercepts → caches bytes

The app no longer touches image bytes at all. It stores a URL string, renders an image tag, and the service worker transparently catches the response on its way through. Next load — online or offline — the same render path hits the service worker first and gets the cached bytes back without a network round-trip.

Implementing the Image Cache with Workbox

Writing a service worker from scratch means hand-rolling fetch event handlers, juggling cache versions, and reinventing eviction logic every time. It's doable, but it's a lot of plumbing for what is usually the same handful of caching patterns.

That's the gap Workbox fills. It's a library from Google that wraps the service worker APIs in a higher-level vocabulary — you describe what should be cached and how, and it generates the fetch handlers for you. Most production PWAs use it, and if you're using Vite's PWA plugin or workbox-webpack-plugin, you're already using Workbox under the hood.

For our image problem, the entire setup boils down to a single runtime caching rule:

{
  urlPattern: ({ request }) => request.destination === 'image',
  handler: 'CacheFirst',
  options: {
    cacheName: 'images-v1',
    expiration: {
      maxEntries: 500,
      maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
    },
    cacheableResponse: { statuses: [200] },
  },
},

Let's break it down line by line.:

  • urlPattern: request.destination === 'image' — instead of maintaining a list of URL patterns or file extensions, we let the browser tell us what's an image. Anything the browser fetches as an image — <img src>, <picture> sources, srcset, favicons — matches.

  • handler: 'CacheFirst' — Workbox's name for the strategy "check the cache first, only hit the network on a miss." This is the right call for images that don't change once uploaded: if we've seen the URL before, we already have the right bytes.

  • cacheName: 'images-v1' — gives this cache its own bucket, separate from anything else the service worker manages. The v1 suffix is a versioning hook: when we need to invalidate everything, we bump the version.

  • maxEntries: 500 — caps the cache at 500 images with LRU eviction. Without a cap, the cache would grow forever; with it, the oldest entries fall off as new ones come in.

  • maxAgeSeconds: 30 days — a second eviction lever, by time. Even if an image is still within the entry cap, it expires after 30 days. Tune to your domain.

  • cacheableResponse: { statuses: [200] } — explicit and defensive. CacheFirst already defaults to caching only 200 responses, but stating it keeps the intent visible at the call site and makes it harder to accidentally widen the policy later.

The browser decides what's an image, Workbox decides when to serve it, and eviction keeps the whole thing bounded without us writing any of it ourselves.

Handling Images That Change

CacheFirst is built on an assumption: a given URL always points to the same bytes. That holds for most image use cases — uploaded attachments, avatars after upload, product photos with content-hashed names. The moment it doesn't hold, though, it leads to issues. Same URL, different content over time, and the service worker will keep serving the original version until the entry expires.

A few ways out of it:

  • Content-addressed URLs — bake a content hash or version into the URL itself (/avatars/abc123.jpg/avatars/def456.jpg). When the content changes, the URL changes, and the cache invalidates itself for free. Cleanest option if you control the URL scheme.

  • StaleWhileRevalidate — serve the cached copy immediately, then fetch a fresh one in the background and update the cache. Great UX, but wastes bandwidth if images rarely change.

  • Shorter maxAgeSeconds — force periodic refetches. Blunt, but works in a pinch when you can't change the URL scheme.

Pick based on how often images actually change and how much you control the URLs upstream.

Keeping the Service Worker Out of API Traffic

Now that we have a service worker sitting in front of every request, we could theoretically let it cache everything. For simple apps, caching everything via a service worker might work. For anything with an app-level cache like React Query, it backfires — here's why.

Your app-level cache — React Query, SWR, Apollo, whatever you're using — already has opinions about your business data: when it's stale, when to refetch, how to invalidate it on mutation. The service worker has no idea about any of that. If both layers cache the same JSON, you get ghost data: the app asks for fresh data, the service worker hands back yesterday's response, and your app-level cache happily stores the stale version as if it were real.

The fix is to be explicit about the boundary:

// Images: cache in the service worker
{
  urlPattern: ({ request }) => request.destination === 'image',
  handler: 'CacheFirst',
  options: { cacheName: 'images-v1', /* ... */ },
},

// Everything else from the API: don't cache
{
  urlPattern: /^https:\/\/api\./,
  handler: 'NetworkOnly',
},

NetworkOnly tells Workbox to leave the request alone — go to the network, return whatever comes back, don't store anything. Your API traffic flows through untouched, and your app-level cache stays the single source of truth for business data.

One important fact: rule order matters. Workbox evaluates rules top-to-bottom and picks the first match. If a catch-all NetworkOnly rule for your API sits above the image rule and your image URLs happen to live on the same origin, your images will match the API rule first and never get cached. Image rules go first.

Each layer owns its domain. The service worker owns transport — images, fonts, static assets. The app owns business data. Keep that line clean and the two caches stay out of each other's way.

Handling Signed URLs

Most production setups don't serve images from a public bucket. They go through signed URLs — S3 presigned URLs, GCS signed URLs, Azure SAS tokens — where the URL itself carries a short-lived signature in the query string. The signature expires, the client requests a fresh one, and the URL comes back with new query parameters even though it points to the exact same image.

This breaks our cache. The Cache API, by default, matches on the full URL including the query string. From its perspective, /images/abc.jpg?sig=xyz123&exp=1700000000 and /images/abc.jpg?sig=def456&exp=1700003600 are two completely different resources. Every signature rotation is a cache miss, a redundant network fetch, and a fresh entry alongside the old one. Pretty soon even our maxEntries: 500 is full of duplicates of the same handful of images.

The fix is one line:

{
  urlPattern: ({ request }) => request.destination === 'image',
  handler: 'CacheFirst',
  options: {
    cacheName: 'images-v1',
    matchOptions: { ignoreSearch: true }, // Match on origin + pathname only
    expiration: { maxEntries: 500, maxAgeSeconds: 60 * 60 * 24 * 30 },
    cacheableResponse: { statuses: [200] },
  },
},

ignoreSearch: true tells the Cache API to match on origin and pathname only and pretend the query string isn't there. Same path = cache hit, regardless of which signature happens to be on the URL today.

One subtlety worth knowing: ignoreSearch only affects lookups, not writes. Each new signed URL still creates a separate cache entry — the option just makes any of them count as a hit on read. That means the cache grows by one entry per signature rotation until eviction kicks in. The maxEntries cap is what actually keeps things bounded.

There's also a trade-off: you lose the ability to cache-bust via query params. If your URL scheme uses something like ?v=2 to force a refresh, that lever stops working — ?v=1 and ?v=2 now collide on the same cache entry. If you need both signed URLs and query-param versioning, you're into custom Workbox plugin territory or a URL-normalization strategy that strips signatures while preserving version params.

Verifying It Works

A service worker that silently misbehaves is worse than no service worker at all, so spend a minute confirming the cache is doing what you think it's doing. In Chrome DevTools:

  • Application → Service Workers — confirm the worker is registered, activated, and controlling the page. If "Status" shows anything but activated and is running, your rules aren't firing.

  • Application → Cache Storage → images-v1 — inspect the actual entries. After loading a page with images, you should see one entry per unique image URL (or per pathname, if ignoreSearch is on).

  • Network → Offline checkbox — flip the page offline and reload. Cached images should still render; everything else should fail. If cached images go missing offline, the service worker isn't intercepting them.

Do this once per environment and you'll catch the silent misconfigurations — wrong scope, rule order, route never matching — before users do.

Summary

That's it — three things worth holding onto:

  • Stop storing bytes in your app state. Store URL strings in your persisted cache and let the service worker handle the bytes in Cache Storage. The 93% storage reduction is what made this reliable on mobile.

  • Don't cache what you don't own. The service worker should not cache business JSON that your app-level cache already manages. Mixing the two creates ghost data.

  • Signed URLs need ignoreSearch. Rotating query parameters break cache matching by default. One config option fixes it — just keep maxEntries to bound the writes.

The thread running through all three is the same: let each layer own what it's actually good at. App state for structured data, Cache Storage for bytes, the service worker for transport. Stop forcing one layer to do another's job, and most of the hard offline problems get a lot easier.

If you enjoyed the article or have a question, feel free to reach out on Bluesky! 👋


And with that, the series wraps. Across ten posts we've gone from "what does offline even mean for a web app" through persisting React Query to IndexedDB, mutation queues, conflict resolution, sync strategies, and now images — the asset class that breaks every assumption the rest of the stack quietly relied on.

Thanks for sticking with it. Go build something that works without a connection.

Further Reading and References