Back to Blog
SwiftReact NativeiOSWebViewOfflineExpo

WebView Doesn't Cache for Offline — So I Built a Swift Module and a Native Patch to Fix It

April 3, 2026 10 min read

Mobile app with offline mode Photo by William Hook on Unsplash

The Problem Nobody Warns You About

I was building a React Native app that loaded a full web application inside a WebView — an LMS portal with courses, navigation, and interactive content. On a solid connection it loaded fine. Then I tested it on a plane with Wi-Fi off.

Blank screen.

Not a loading spinner. Not a cached version. A blank, white screen. WKWebView — the native iOS component that powers React Native's <WebView /> — does not cache web content for offline use. The browser cache is session-scoped. Kill the network, kill the app, reopen it: everything is gone.

This was a dealbreaker. Users needed to browse course content they had already visited, even without connectivity. So I built the caching layer myself.

Error -1009: The Error Every iOS Developer Hits

If you've worked with WebView on iOS and tested offline, you've seen this:

NSURLErrorDomain error -1009 (NSURLErrorNotConnectedToInternet)

This is the native iOS error that fires when WKWebView tries to load a URL with no network connection. On Android, cacheMode={'LOAD_CACHE_ELSE_NETWORK'} gracefully falls back to cached content. On iOS, the same configuration does absolutely nothing — you just get error -1009 and a blank screen.

This isn't a niche issue. It's one of the most reported and longest-running problems in the react-native-webview community:

The pattern is clear: developers set cacheEnabled={true} and cacheMode={'LOAD_CACHE_ELSE_NETWORK'}, test on Android where it works, ship to iOS, and discover their app is broken offline. The issue has been open in various forms since 2019 with no native fix in the library.

Error -1009 is the symptom. The root cause is that WKWebView has no persistent offline cache — and react-native-webview doesn't add one.

That's the gap I filled.

Why WKWebView Doesn't Do This Out of the Box

WKWebView uses URLSession under the hood and respects standard HTTP cache-control headers. But that cache is in-memory and session-scoped — it evaporates when the process dies. Apple expects you to handle offline through Service Workers (if you control the web content) or native interception.

Service Workers weren't an option here — I don't own the web app being loaded. And the standard URLCache approach doesn't survive app restarts. I needed something that persists to disk and serves full pages when the network is completely gone.

The key insight: iOS 14+ gives you WKWebView.createWebArchiveData(). This captures the entire rendered page — HTML, CSS, JS, images, everything — as a single binary .webarchive file. If you save that to disk, you can reload it later with webView.loadData() even with zero connectivity.

The Architecture

The solution has two parts:

  1. An Expo Swift module (OfflineCacheModule) — handles file I/O, hashing, and metadata on the native side
  2. A patch on react-native-webview — hooks into WKWebView's navigation delegates to capture archives after page load and serve them when offline
User browses page (online)
    ↓
WKWebView finishes loading (didFinishNavigation)
    ↓
createWebArchiveData() captures full page
    ↓
SHA256(normalized URL) → filename
    ↓
Save {hash}.webarchive + {hash}.meta.json to Documents/offline_web_cache/
    ↓
Send WEB_ARCHIVE_CAPTURED message to React Native

User navigates (offline)
    ↓
WKWebView fails with NSURLErrorNotConnectedToInternet (-1009)
    ↓
didFailProvisionalNavigation intercepts the error
    ↓
Look up cached archive by normalized URL hash
    ↓
Cache HIT → loadData() silently renders the page
Cache MISS → send toast to RN, goBack()

The Swift Module: OfflineCacheModule

This is a custom Expo native module at modules/offline-cache/. It manages the cache directory and exposes functions to both the native patch and JavaScript.

public class OfflineCacheModule: Module { private var cacheDirectory: URL { let docs = FileManager.default.urls( for: .documentDirectory, in: .userDomainMask ).first! return docs.appendingPathComponent("offline_web_cache", isDirectory: true) } public func definition() -> ModuleDefinition { Name("OfflineCache") AsyncFunction("saveWebArchive") { (url: String, archiveBase64: String) -> Bool in return self.saveArchive(url: url, base64Data: archiveBase64) } AsyncFunction("loadWebArchive") { (url: String) -> String? in return self.loadArchive(url: url) } AsyncFunction("getCachedUrls") { () -> [String] in return self.listCachedUrls() } AsyncFunction("getCacheSize") { () -> Int in return self.calculateCacheSize() } AsyncFunction("clearCache") { () -> Bool in return self.clearAllCache() } AsyncFunction("removeCachedUrl") { (url: String) -> Bool in return self.removeArchive(url: url) } } }

Each cached page gets two files on disk:

  • {sha256}.webarchive — the binary web archive
  • {sha256}.meta.json — metadata (URL, timestamp, size)

The SHA256 hash is computed from a normalized URL — query params like mobile_offline and mobile_app_v2 are stripped so the same page matches regardless of those flags.

The TypeScript bridge is iOS-only and gracefully degrades:

import { Platform } from 'react-native' let OfflineCacheNative: any = null if (Platform.OS === 'ios') { try { const { requireNativeModule } = require('expo-modules-core') OfflineCacheNative = requireNativeModule('OfflineCache') } catch { console.warn('[OfflineCache] Native module not available') } } export async function saveWebArchive(url: string, archiveBase64: string): Promise<boolean> export async function loadWebArchive(url: string): Promise<string | null> export async function getCachedUrls(): Promise<string[]> export async function getCacheSize(): Promise<number> export async function clearCache(): Promise<boolean> export async function removeCachedUrl(url: string): Promise<boolean>

The WebView Patch: Capturing and Serving Archives

The second piece is a patch on react-native-webview@13.13.5 applied via patch-package. This is where the actual caching and offline serving happens at the WKWebView level.

Capturing pages after load

When a page finishes loading, the patched WebView calls createWebArchiveData() to capture the full page and saves it to the cache directory:

- (void)_captureArchiveForCurrentPage { if (@available(iOS 14.0, *)) { WKWebView *webView = _webView; NSURL *pageURL = webView.URL; if (!pageURL || ![pageURL.scheme hasPrefix:@"http"]) return; [webView createWebArchiveDataWithCompletionHandler:^(NSData *archiveData, NSError *error) { if (archiveData) { dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ [self _saveOfflineArchive:archiveData forURL:pageURL.absoluteString]; // Notify React Native [webView evaluateJavaScript:@"window.ReactNativeWebView.postMessage(JSON.stringify({type:'WEB_ARCHIVE_CAPTURED'}))" completionHandler:nil]; }); } }]; } }

This fires on two triggers:

  • didFinishNavigation: — standard full page loads
  • A URL KVO observer — catches Turbolinks and client-side pushState navigations that don't trigger didFinishNavigation, with a 2-second debounce

Serving cached pages when offline — catching error -1009

This is the critical piece — the one that directly solves the -1009 problem that the community has been hitting for years. When a navigation fails because there's no internet, the patch intercepts NSURLErrorNotConnectedToInternet (error code -1009) and looks for a cached archive instead of surfacing the error:

- (void)webView:(WKWebView *)webView didFailProvisionalNavigation:(WKNavigation *)navigation withError:(NSError *)error { if ([error.domain isEqualToString:NSURLErrorDomain] && error.code == NSURLErrorNotConnectedToInternet) { NSString *failedURL = error.userInfo[NSURLErrorFailingURLStringErrorKey]; NSData *archive = [self _loadOfflineArchiveForURL:failedURL]; if (archive) { // Cache HIT — load the archive silently [webView loadData:archive MIMEType:@"application/x-webarchive" characterEncodingName:@"utf-8" baseURL:[NSURL URLWithString:failedURL]]; return; // Don't fire onLoadingError to RN } // Cache MISS — tell React Native if (_onLoadingError) { NSMutableDictionary *event = [self baseEvent]; [event addEntriesFromDictionary:@{ @"offlineCacheHit": @NO, @"offlineCacheUrl": failedURL, }]; _onLoadingError(event); } } }

On a cache hit, the user sees the full page with styling — silently, no error, no flash. On a miss, React Native shows a toast and navigates back.

The React Native Side

The WebView component uses a dynamic cacheMode prop based on connectivity and the user's offline mode toggle:

<WebView cacheEnabled={true} cacheMode={ offlineMode ? isOnline ? 'LOAD_CACHE_ELSE_NETWORK' : 'LOAD_CACHE_ONLY' : 'LOAD_DEFAULT' } onMessage={async (event) => { const data = JSON.parse(event.nativeEvent.data) if (data.type === 'WEB_ARCHIVE_CAPTURED') { addCachedPage({ url: data.url, size: data.size, timestamp: Date.now() }) } }} onError={({ nativeEvent }) => { const { offlineCacheHit, offlineCacheUrl } = nativeEvent as any if (offlineCacheHit === true) return // Served from cache, no error if (offlineCacheHit === false) { setOfflineToastMessage(\`Not available offline: \${offlineCacheUrl}\`) webViewRef.current?.goBack() } }} />

The UI layer includes three components:

  • CacheIndicator — a floating draggable badge (orange while caching, green when done) with a bottom sheet listing all cached pages
  • OfflineToast — auto-dismissing notification when a user hits an uncached page offline
  • OfflineBanner — a top banner reading "You're viewing cached content" that animates in when offline

State is managed with Zustand and persisted to AsyncStorage:

const useStore = create((set, get) => ({ offlineMode: false, cachedPages: [], isCaching: false, offlineToastMessage: null, addCachedPage: (page) => { /* deduplicates by URL */ }, removeCachedPage: (url) => { /* removes + calls native removeCachedUrl */ }, clearCachedPages: () => set({ cachedPages: [] }), }))

Tradeoffs Worth Knowing

Pages must be visited to be cached. There's no background prefetch. The user has to browse a page while online for it to get archived. This is by design — we're caching what they actually use.

No cache eviction policy. Cached pages stay until manually deleted via the CacheIndicator UI or clearCache(). iOS can reclaim the Documents directory under extreme storage pressure, but it's rare.

iOS only. Android's WebView has a different API (shouldInterceptRequest with its own disk cache). The same patch file handles Android separately.

Turbolinks requires a debounce. Client-side navigations that use pushState/replaceState don't fire didFinishNavigation. The URL KVO observer catches these, but with a 2-second debounce to avoid capturing mid-transition pages.

Cached content is read-only. Forms, logins, and interactive features that require a server round-trip won't work offline. This is for viewing previously loaded content, not for offline-first interactivity.

The Result

The app now serves full web pages — with all their styling, images, and layout — completely offline. Users browse their LMS content on a plane, in a subway, or anywhere without connectivity. The first visit to a page caches it. Every subsequent visit, online or offline, is instant. Error -1009 is gone.

The two-package approach kept things clean: the Swift module handles file I/O and hashing, the WebView patch handles the WKWebView lifecycle, and React Native manages the UI state. Each layer does one thing.

For everyone who's hit issues #1651, #1387, #2170, or any of the other open threads asking why cacheMode doesn't work on iOS — it's because the iOS WebView simply doesn't have a persistent cache. Setting cacheEnabled={true} is a no-op for offline. The only way to solve this is at the native layer: capture full page archives with createWebArchiveData(), persist them to disk, and intercept didFailProvisionalNavigation with error -1009 to serve them back. That's what this solution does.

If you're building a React Native app that wraps web content and you need offline support on iOS, this is the path. Anything that tries to solve it purely in JavaScript is fighting the platform.