WebView Doesn't Cache for Offline — So I Built a Swift Module and a Native Patch to Fix It
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:
-
#1651 — "react-native-webview cache not working for iOS" — Opened in 2020, with developers still reporting the same issue in 2025 on v13.15.0. Multiple "+1" comments from users hitting the exact same blank screen. Android caches and works offline. iOS doesn't.
-
#1387 — "Offline webview(cache) support" — A direct feature request from 2020 asking for cached content to display when offline. One commenter described the business impact: "When a person is using your app on WiFi, and they switch to their phone's internet... the applications momentarily error." Closed as stale with no solution.
-
#2170 — "iOS webview is not caching website" — Same story. Developer confirmed the PWA caches correctly in Safari, but react-native-webview on iOS shows a "no internet" error. Closed as stale.
-
#1511 — "Cache mode for iOS" — Pointed out that
cacheModeonly works on Android. The iOS implementation simply ignores it. Closed without a fix. -
#1975 — "iOS: cache settings not being respected" — Even
cacheEnabled={false}andcacheMode={'LOAD_NO_CACHE'}are ignored on iOS. The only workaround found was settingincognito={true}. -
#1929 — "Can the entire content of an app be bundled to work offline?" — A developer building an educational app asked for offline bundling. No maintainer response. Closed as stale.
-
#869 — "PWA with WorkBox Support" — 17 comments asking for Service Worker / PWA offline support in the WebView. Closed with no resolution.
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:
- An Expo Swift module (
OfflineCacheModule) — handles file I/O, hashing, and metadata on the native side - 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.