Post

iOS WKWebView Preload and Cache|Boost Page Load Speed with Efficient Resource Management

Discover how iOS WKWebView preload and caching techniques reduce page load times by pre-downloading and storing resources, enhancing user experience and app performance effectively.

iOS WKWebView Preload and Cache|Boost Page Load Speed with Efficient Resource Management

点击这里查看本文章简体中文版本。

點擊這裡查看本文章正體中文版本。

This post was translated with AI assistance — let me know if anything sounds off!


iOS WKWebView Page and File Resource Preload / Cache Study

iOS WKWebView Pre-download and Cache Resources to Improve Page Load Speed Research.

Photo by [Antoine Gravier](https://unsplash.com/@antoine_gravphotos?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash){:target="_blank"}

Photo by Antoine Gravier

Background

For some reason, I have always had a connection with “Cache.” Previously, I was responsible for researching and implementing AVPlayer’s “iOS HLS Cache Implementation Exploration” and “Complete Guide to AVPlayer Local Cache.” Unlike streaming cache, which aims to reduce playback data usage, this time the main task is to improve the loading speed of in-app WKWebView. This also involves research on WKWebView preloading and caching. However, honestly, the WKWebView scenario is more complex. Unlike AVPlayer streaming video, which consists of one or multiple continuous chunk files and only requires file caching, WKWebView includes not only the page files but also imported resource files (.js, .css, fonts, images, etc.). These are rendered by the browser engine to display the page to the user. Many parts in this process are beyond the app’s control, from the network to frontend JavaScript performance and rendering methods, all of which take time.

This article only explores the feasibility of iOS technology and is not necessarily the final solution. Overall, it is better for front-end developers to tackle this issue from the front end to achieve a more efficient result. Front-end partners are encouraged to optimize the First Contentful Paint and improve the HTTP Cache mechanism. This will speed up Web/mWeb itself, affect the speed of Android/iOS in-app WebViews, and also enhance Google SEO ranking.

Technical Details

iOS Restrictions

According to Apple Review Guidelines 2.5.6:

Apps that browse the web must use the appropriate WebKit framework and WebKit JavaScript. You may apply for an entitlement to use an alternative web browser engine in your app. Learn more about these entitlements .

Apps can only use Apple’s provided WebKit Framework (WKWebView) and are not allowed to use third-party or modified WebKit engines, otherwise they will not be approved for the App Store. Additionally, starting from iOS 17.4, to comply with regulations, the EU region can use other browser engines after obtaining special permission from Apple.

What Apple doesn’t provide, we can’t do either.

[Unverified] Research shows that even the iOS versions of Chrome and Firefox can only use Apple WebKit (WKWebView).

Another very important thing:

WKWebView runs on a separate thread outside the app’s main thread, so all requests and operations do not pass through our app.

HTTP Cache Flow

The HTTP protocol already includes a Cache mechanism, and the system has implemented caching for all network-related components (URLSession, WKWebView, etc.). Therefore, the client app does not need to implement any caching on its own. It is also not recommended to create a custom caching system. Following the HTTP protocol is the fastest, most stable, and most efficient approach.

The general workflow of HTTP Cache is shown in the above image:

  1. Client sends a request

  2. Server response cache strategies in the Response Header are automatically handled by system components like URLSession and WKWebView. They cache the response based on the Cache Header, and subsequent requests automatically apply this strategy.

  3. When requesting the same resource again, if the cache has not expired, directly read the local cache from memory or disk and respond to the app.

  4. If expired (expiration does not mean invalid), a real network request is sent to the server. If the content has not changed (still valid despite expiration), the server responds with 304 Not Modified (Empty Body). Although a network request is made, the response is basically in milliseconds with no response body, resulting in minimal traffic usage.

  5. If the content has changed, provide the data and Cache Header again.

Caching can occur not only locally but also on network proxy servers or along the network path.

Common HTTP Response Cache Header Parameters:

1
2
3
4
5
expires: RFC 2822 date
pragma: no-cache
# Newer parameters:
cache-control: private/public/no-store/no-cache/max-age/s-max-age/must-revalidate/proxy-revalidate...
etag: XXX

Common HTTP Request Cache Header Parameters:

1
2
If-Modified-Since: 2024-07-18 13:00:00
IF-None-Match: 1234

In iOS, network-related components (URLSession, WKWebView, etc.) automatically handle HTTP Request/Response Cache Headers and manage caching, so we do not need to handle Cache Header parameters ourselves.

For more detailed HTTP Cache operation details, refer to “Step-by-step Understanding of HTTP Cache Mechanism by Huli”.

Overview of iOS WKWebView

Back to iOS, since we can only use Apple WebKit, we have to rely on the WebKit methods provided by Apple to explore possible ways to achieve preloading cache.

The above image shows all Apple iOS WebKit (WKWebView) related methods introduced by ChatGPT 4o, along with brief descriptions; the green sections indicate methods related to data storage.

Sharing with everyone a few of the more interesting methods:

  • WKProcessPool: Allows multiple WKWebViews to share resources, data, cookies, and more.

  • WKHTTPCookieStore: Manages WKWebView cookies, including cookies shared between WKWebViews or between URLSession and WKWebView within the app.

  • WKWebsiteDataStore: Manages website cache files. (Can only read information and clear data)

  • WKURLSchemeHandler: Registers a custom handler to manage URL schemes that WKWebView does not recognize.

  • WKContentWorld: Groups and manages injected JavaScript (WKUserScript) scripts.

  • WKFindXXX: Controls the page search function.

  • WKContentRuleListStore: Enables content blockers within WKWebView (e.g., ad blocking).

Feasibility Study on Preloading Cache for iOS WKWebView

Improve HTTP Cache ✅

As introduced earlier about the HTTP Cache mechanism, we can ask the Web Team to improve the HTTP Cache settings for the event page. On the client iOS side, we only need to simply check the CachePolicy settings; the system handles the rest!

CachePolicy Settings

URLSession:

1
2
3
let configuration = URLSessionConfiguration.default
configuration.requestCachePolicy = .useProtocolCachePolicy
let session = URLSession(configuration: configuration)

URLRequest/WKWebView:

1
2
3
4
var request = URLRequest(url: url)
request.cachePolicy = .reloadRevalidatingCacheData
//
wkWebView.load(request)
  • useProtocolCachePolicy: Default, follows the default HTTP Cache control.

  • reloadIgnoringLocalCacheData: Do not use local cache; each request loads data from the network (but allows network and proxy caching).

  • reloadIgnoringLocalAndRemoteCacheData: Always load data from the network, ignoring both local and remote caches.

  • returnCacheDataElseLoad: Use cached data if available; otherwise, load data from the network.

  • returnCacheDataDontLoad: Use only cached data; if no cache is available, do not make a network request.

  • reloadRevalidatingCacheData: Sends a request to check if the local cache has expired. If not expired (304 Not Modified), it uses the cached data; otherwise, it reloads data from the network.

Set Cache Size

App Global:

1
2
3
4
5
let memoryCapacity = 512 * 1024 * 1024 // 512 MB
let diskCapacity = 10 * 1024 * 1024 * 1024 // 10 GB
let urlCache = URLCache(memoryCapacity: memoryCapacity, diskCapacity: diskCapacity, diskPath: "myCache")
        
URLCache.shared = urlCache

Individual URLSession:

1
2
3
4
5
6
let memoryCapacity = 512 * 1024 * 1024 // 512 MB
let diskCapacity = 10 * 1024 * 1024 * 1024 // 10 GB
let cache = URLCache(memoryCapacity: memoryCapacity, diskCapacity: diskCapacity, diskPath: "myCache")
        
let configuration = URLSessionConfiguration.default
configuration.urlCache = cache

Also as mentioned before, WKWebView runs on a separate thread outside the app’s main thread, so the cache of URLRequest and URLSession is not shared with WKWebView.

How to Use Safari Developer Tools in WKWebView?

Check if local Cache is being used.

Enable Developer Features in Safari:

Enable isInspectable for WKWebView:

1
2
3
4
5
func makeWKWebView() -> WKWebView {
 let webView = WKWebView(frame: .zero)
 webView.isInspectable = true // is only available in iOS 16.4 or newer
 return webView
}

WKWebView requires webView.isInspectable = true to enable Safari Developer Tools in Debug Build.

p.s. This is a separate WKWebView test project I created

p.s. This is a separate test project I created for WKWebView.

Set a breakpoint at the webView.load line.

Start Test:

Build & Run:

When the breakpoint at webView.load is hit, click “Step Over”.

Go back to Safari, select the toolbar’s “Develop” -> “Simulator” -> “Your Project” -> “about:blank”.

  • Because the page has not started loading, the URL will be about:blank

  • If “about:blank” does not appear, go back to XCode and click the step-by-step debug button again until it appears.

The developer tools corresponding to this page appear as follows:

Back to XCode and click Continue to run:

Returning to Safari Developer Tools, you can see resource loading status and the full set of developer tool features (components, storage debugging, etc.).

If the online resource has HTTP Cache, the transfer size will show as “disk”:

Clicking in also shows cache information.

Clear WKWebView Cache

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Clean Cookies
HTTPCookieStorage.shared.removeCookies(since: Date.distantPast)

// Clean Stored Data, Cache Data
let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes()
let store = WKWebsiteDataStore.default()
store.fetchDataRecords(ofTypes: dataTypes) { records in
 records.forEach { record in
  store.removeData(
   ofTypes: record.dataTypes,
   for: records,
   completionHandler: {
          print("clearWebViewCache() - \(record)")           
   }
  )
 }
}

You can use the above methods to clear cached resources, local data, and cookie data in WKWebView.

But improving HTTP Cache only affects caching (fast on the second visit), it does not impact preloading (the first visit).

Improve HTTP Cache + WKWebView Full Page Preload 😕

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class WebViewPreloader {
    static let shared = WebViewPreloader()

    private var _webview: WKWebView = WKWebView()

    private init() { }

    func preload(url: URL) {
        let request = URLRequest(url: url)
        Task { @MainActor in
            webview.load(request)
        }
    }
}

WebViewPreloader.shared.preload("https://zhgchg.li/campaign/summer")

After improving the HTTP Cache, the second time WKWebView loads, it will have cached data. We can preload all the URLs in the list or homepage once to create the cache, so when users enter, it will be faster.

After testing, it is feasible in principle; however, it greatly affects performance and network traffic ; users may not even visit the detail page, but we preload and load all pages at once, which feels like shooting blindly.

Personally, I think it is impractical in reality, and the drawbacks outweigh the benefits, making it a case of cutting off one’s nose to spite one’s face.😕

Enhanced HTTP Cache + WKWebView Preload for Static Resources 🎉

Based on the above optimization method, we can combine the HTML Link Preload technique to preload only the resource files used on the page (e.g., .js, .css, fonts, images). This allows users to directly use cached resources upon entering, without making additional network requests for resource files.

This means I no longer preload everything on the entire page. Instead, I only preload the resource files that the page will use, which may also be shared across pages; the page file .html is still fetched from the network and combined with the preloaded files to render the page.

Please note: HTTP Cache is still used here, so these resources must support HTTP Cache; otherwise, future requests will still go through the network.

Please note: HTTP Cache is still used here, so these resources must support HTTP Cache; otherwise, future requests will still go through the network.

Please note: This still uses HTTP Cache, so these resources must support HTTP Cache; otherwise, future requests will still go through the network.

1
2
3
4
5
6
7
8
9
<!DOCTYPE html>
<html lang="zh-tw">
 <head>
    <link rel="preload" href="https://cdn.zhgchg.li/dist/main.js" as="script">
    <link rel="preload" href="https://image.zhgchg.li/v2/image/get/campaign.jpg" as="image">
    <link rel="preload" href="https://cdn.zhgchg.li/assets/fonts/glyphicons-halflings-regular.woff2" as="font">
    <link rel="preload" href="https://cdn.zhgchg.li/assets/fonts/Simple-Line-Icons.woff2?v=2.4.0" as="font">
  </head>
</html>

Common Supported File Types:

  • .js script

  • .css style

  • font

  • image

The Web Team places the above HTML content in the agreed path with the App. Our WebViewPreloader then loads this path, and WKWebView will parse the <link> preload resources and generate the cache during loading.

1
2
3
WebViewPreloader.shared.preload("https://zhgchg.li/campaign/summer/preload")
// or preload all at once
WebViewPreloader.shared.preload("https://zhgchg.li/assets/preload")

After testing, a good balance between data loss and preloading can be achieved . 🎉

The downside is that this Cache resource list requires maintenance, and web page rendering and loading still need optimization; otherwise, the perceived load time of the first page will remain long.

URLProtocol

Also, remember our old friend URLProtocol; all requests based on the URL Loading System (URLSession, openURL…) can be intercepted and manipulated.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class CustomURLProtocol: URLProtocol {
    override class func canInit(with request: URLRequest) -> Bool {
        // Determine if this request should be handled
        if let url = request.url {
            return url.scheme == "custom"
        }
        return false
    }
    
    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        // Return the request
        return request
    }
    
    override func startLoading() {
        // Handle the request and load data
        // Change to cache strategy, read file locally first
        if let url = request.url {
            let response = URLResponse(url: url, mimeType: "text/plain", expectedContentLength: -1, textEncodingName: nil)
            self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
            
            let data = "This is a custom response!".data(using: .utf8)!
            self.client?.urlProtocol(self, didLoad: data)
            self.client?.urlProtocolDidFinishLoading(self)
        }
    }
    
    override func stopLoading() {
        // Stop loading data
    }
}

// AppDelegate.swift didFinishLaunchingWithOptions:
URLProtocol.registerClass(CustomURLProtocol.self)

The abstract idea is to secretly send URLRequest in the background -> URLProtocol -> download all resources by itself, user -> WKWebView -> Request -> URLProtocol -> respond with preloaded resources.

As mentioned before, WKWebView runs on a separate thread outside the app’s main thread, so URLProtocol cannot intercept WKWebView requests.

But I heard that using black magic might work, though it’s not recommended as it can cause other issues (submission rejected)

No Entry ❌.

WKURLSchemeHandler 😕

Apple introduced a new method in iOS 11, seemingly to compensate for WKWebView’s inability to use URLProtocol; however, this method is more similar to AVPlayer’s ResourceLoader, where only schemes unrecognized by the system are passed to our custom WKURLSchemeHandler for handling.

The abstract idea is to secretly send WKWebView -> Request -> WKURLSchemeHandler in the background to download all resources by itself, while the user -> WKWebView -> Request -> WKURLSchemeHandler responds with the preloaded resources.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import WebKit

class CustomSchemeHandler: NSObject, WKURLSchemeHandler {
    func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
        // Handle custom scheme
        let url = urlSchemeTask.request.url!
        
        if url.scheme == "custom-scheme" {
            // Change to cache strategy, read file locally first
            let response = URLResponse(url: url, mimeType: "text/html", expectedContentLength: -1, textEncodingName: nil)
            urlSchemeTask.didReceive(response)
            
            let html = "<html><body><h1>Hello from custom scheme!</h1></body></html>"
            let data = html.data(using: .utf8)!
            urlSchemeTask.didReceive(data)
            urlSchemeTask.didFinish()
        }
    }

    func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
        // Stop
    }
}

let webViewConfiguration = WKWebViewConfiguration()
webViewConfiguration.setURLSchemeHandler(CustomSchemeHandler(), forURLScheme: "mycacher")

let customURL = URL(string: "mycacher://zhgchg.li/campaign/summer")!
webView.load(URLRequest(url: customURL))
  • Because http/https are system-handled schemes, we cannot customize their handling; we need to change the scheme to one not recognized by the system (e.g., mycacher://).

  • Use relative paths throughout the page to automatically apply mycacher:// for our Handler to capture.

  • If you don’t want to change http/https but still want to capture http/https requests, you can only use black magic. Not recommended, as it may cause other issues (rejection during review).

  • Cache page files locally and respond; Ajax, XMLHttpRequest, and Fetch requests used in the page may be blocked by the CORS Same-Origin Policy. You need to lower the website’s security to use this (because requests are sent from mycacher:// to http://zhgchg.li/xxx, which is a different origin).

  • You may need to implement your own Cache Policy, such as when to update and how long it remains valid. (This is exactly what HTTP Cache does.)

In summary, although theoretically feasible, the implementation requires huge investment; overall, it is not cost-effective and difficult to scale and maintain stability 😕

It seems that the WKURLSchemeHandler method is more suitable for handling large resource files within a webpage by declaring a custom scheme and letting the app handle it, working together to render the webpage.

Bridge WKWebView Network Requests to Be Sent by the App 🫥

WKWebView switches to calling predefined app interfaces (WkUserScript) instead of Ajax, XMLHttpRequest, or Fetch, letting the app request resources.

This case is not very helpful because the first screen takes too long to appear, not the subsequent loading; moreover, this method creates an overly deep and strange dependency between Web and App 🫥

Start with Service Worker

Due to security concerns, only Apple’s own Safari App is supported; WKWebView is not supported❌.

WKWebView Performance Optimization 🫥

Optimize and improve the performance of WKWebView Load View.

WKWebView itself is like the skeleton, and the web page is the flesh. Research shows that optimizing the skeleton (e.g., reusing WKProcessPool) has very limited effect, possibly a difference of 0.0003 -> 0.000015 seconds.

Local HTML, Local Resource Files 🫥

Similar to the Preload method, but instead, the event page is placed in the App Bundle or fetched remotely at startup.

Hosting the entire HTML page may also encounter CORS same-origin issues; simply hosting web resource files seems suitable for using the “complete HTTP Cache + WKWebView Preload pure resources” method instead; including them in the App Bundle only increases App Size, fetching from remote is WKWebView Preload 🫥

Frontend Optimization Starts 🎉🎉🎉

[Source: wedevs](https://wedevs.com/blog/348939/first-contentful-paint-largest-contentful-paint/){:target="_blank"}

Source: wedevs

Refer to wedevs optimization suggestions, the front-end HTML page typically has four loading stages: from initially loading the page file (.html) First Paint (blank page) to First Contentful Paint (rendering the page skeleton), then to First Meaningful Paint (adding page content), and finally to Time To Interactive (when the user can interact).

Test with our page; browsers and WKWebView first request the main .html page, then load the required resources. Meanwhile, they build the interface for the user according to the program instructions. Comparing with the article, the page phase actually only has First Paint (blank) to Time To Interactive (First Contentful Paint only shows the Navigation Bar, which probably doesn’t count…), missing intermediate staged rendering for the user. Therefore, the overall user waiting time is extended.

Currently, only resource files have HTTP Cache set; the main page does not.

You can also refer to Google PageSpeed Insights for optimization suggestions, such as compression, reducing script size, and more.

Because the core of in-app WKWebView is still the web page itself, adjusting from the front-end web is an effective and efficient approach. 🎉🎉🎉

User Experience Focus 🎉🎉🎉

A simple implementation starting from user experience: add a Loading Progress Bar. Don’t just show a blank page that leaves users confused. Let them know the page is loading and how far along it is. 🎉🎉🎉

Conclusion

The above summarizes some ideas and research on feasible approaches for WKWebView preloading and caching. The technical aspect is not the biggest issue; the key is choosing the methods that are most effective for users while minimizing development costs. Selecting the right approach may only require minor adjustments to achieve the goal directly. Choosing the wrong method can lead to wasted resources, going in circles, and likely difficulties in maintenance and usage later on.

There are always more solutions than difficulties; sometimes, it’s just a lack of imagination.

There might be other genius combinations I haven’t thought of. Feel free to contribute your suggestions.

References

WKWebView Preload Pure Resource 🎉 You can refer to the following video for the solution

The author also mentioned the methods of WKURLSchemeHandler.

The complete demo repo in the video is as follows:

iOS Veteran Weekly Report

The veteran driver’s weekly report on WkWebView is also worth a read.

Chat

A long-awaited return to writing in-depth articles about iOS development.

If you have any questions or feedback, feel free to contact me.


Buy me a beer

This post was originally published on Medium (View original post), and automatically converted and synced by ZMediumToMarkdown.

Improve this page on Github.

This post is licensed under CC BY 4.0 by the author.