WKWebView Communication Latency #

One of the many exciting things about the modern WebKit API is that it has an officially sanctioned communication mechanism for doing JavaScript-to-native communication. Hacks should no longer be necessary to get data back out of the WKWebView. It is a simple matter of registering a WKScriptMessageHandler on the native side and then invoking it with window.webkit.messageHandlers.<handler name>.postMessage on the JS side.

After a heads-up from Jordan that messaging latency with the new web view was not that great, I extended the test bed I had developed to measure UIWebView communication mechanisms to also measure this setup. I got results that mirrored Jordan's experiences — on an iPad mini 2 (A7), UIWebView's best officially supported communication mechanism (changing location.hash¹) took 0.44ms, while the same round trip on a WKWebView took 3.63ms. I was expecting somewhat higher latency, since there is a cross-process IPC involved now, but not this much higher.

Curious as to what was going on, I pointed a profiler at the test harness, and got the following results.

Running Time         Symbol Name
4147.0ms   94.1%     Main Thread
                         10 stack frames elided
2165.0ms   49.1%             IPC::Connection::dispatchOneMessage()
2164.0ms   49.1%              IPC::Connection::dispatchMessage(…)
2161.0ms   49.0%               WebKit::WebProcessProxy::didReceiveMessage(…)
2160.0ms   49.0%                IPC::MessageReceiverMap::dispatchMessage(…)
2137.0ms   48.4%                 void IPC::handleMessage<…>
2137.0ms   48.4%                  WebKit::WebUserContentControllerProxy::didPostMessage(…)
2132.0ms   48.3%                   ScriptMessageHandlerDelegate::didPostMessage(…)
1274.0ms   28.9%                    -[JSContext initWithVirtualMachine:]
 769.0ms   17.4%                    -[JSContext init]
 30.0ms     0.6%                 -[BenchmarkViewController userContentController:didReceiveScriptMessage:]
 16.0ms     0.3%                 -[JSValue toObject]

It looks like nearly half the time is spent in JSContext² initializers. As previously mentioned, the WKWebView API is open source, so it's actually possible to read the source and see what's going on. If we look at the ScriptMessageHandlerDelegate::didPostMessage source we can see that indeed for every postMessage() call on the JS side a new JSContext is initialized to own the deserialized value.

Even when not doing any postMessage calls on the JS side, and just measuring WKWebView's evaluateJavaScript:completionHandler: latency, there was still lots of time spent in JSContext initialization. This turned out to be because the completion handler also results in a JSContext being created in order to own the deserialized value. Thankfully this only happens if a completion handler is specified and there is a result, in other cases the function exits early.

After thinking about this some more, it occurred to me that the old location.hash mechanism could still work with a WKWebView. By adding a WKNavigationDelegate to the web view, it's possible to observe location changes on the native side. I implemented this approach and was pleasantly surprised to see that it took 0.95ms. This was almost 4x faster than the officially sanctioned mechanism (albeit still about twice as slow as the equivalent on a UIWebView, but that is presumably explained by the IPC overhead).

I then wondered if using location.hash to communicate in both directions (changing the location on the native side, listening for hashchange events on the JS side) would be be even better (to bypass more of the JS execution machinery), but that approach ended up being slower (for both UIWebView and WKWebView) since it involved more delegate invocations.

Putting all this together, here are the results (in milliseconds, averaged over 100 round trips) using these mechanisms on various devices (all running iOS 8.1) using my test bed.

Method/Device iPhone 5 (A6) iPad mini 2 (A7) iPhone 6 (A8) Simulator (Core i7)
location.hash 0.63 0.44 0.37 0.15
WKScriptMessageHandler 6.97 3.63 3.03 2.75
location.hash 1.3 0.95 0.77 0.32

I reported this performance problem to Apple last summer (while iOS 8 was still in beta), but I haven't seen activity in this area. Ideally there would be a way to skip over JS value deserialization altogether, for cases where the client doesn't actually benefit from the built-in parsing that is done. Specifically, Quip uses Protocol Buffer-encoded messages to have richer (and typed) data passed back and forth between the native and JS side, and so, as far as the JS runtime is concerned, they're all strings anyway.

Separately, it is a good question as to why I'm making such a big fuss over the overhead of one millisecond (or three) per call. Quip continues to be a “hybrid” app, with the editing experience implemented in a web view (a UIWebView for now). With the launch of spreadsheets we now have even more native ↔ web communication, and in some cases we notify the web view of touch events continuously while UIScrollViews are scrolled (for custom behavior³). When trying to hit 60 frames per second, spending 3 milliseconds of your 16 millisecond budget on pure overhead feels wasteful.

Update on 06/29/2015: Mark Lam recently fixed ScriptMessageHandlerDelegate::didPostMessage to reuse JSContext instances across calls, which fixes one of the two performance problems that this post covers. I've filed another bug to track the remaining issue with evaluateJavaScript:completionHandler:.

Update on 08/12/2015: The other bug has been fixed too, and both fixes are live in iOS 9. I have also discovered additional alternative mechanisms that perform better. See my more recent post about the current state of things.

  1. Astute observers will remark that my blog post used to recommend either changing location.hash or using the click() method on an anchor node. I have since discovered that click() results in a forced layout recalculation (via hit testing) as part of dispatching the event. I have since updated the post to recommend location.hash.
  2. I find it a bit odd that JavaScriptCore is not part of Apple's web-accessible documentation set. Even their own release notes announcing the framework say “for information about the classes of this framework, see the header files.”
  3. This involved yet more spelunking into UIWebView internals, something I will hopefully be posting about soon.


Have you considered SSE or WebSockets?
Haven't tried them myself for UI/WKWebView bridging yet but latency for both should be in low microseconds.
@Don Park: I haven't personally tried Web Sockets, but based on http://arstechnica.com/information-technology/2012/10/a-behind-the-scenes-look-at-linkedins-mobile-engineering/2/ LinkedIn tried in 2012 but it was too unstable. WebKit's Web Sockets implementations may have gotten better since then.
I am currently affected by this performance issue. I am building an iOS app for a WebAudio-based Synth.
The problem for me, is passing MIDI data from the native app to the synth running in a WebView. UIWebView is what I started with and it didn't perform too badly for the sound part, but scrolling in it would sometimes interrupt sound. I've divided UI and the Synth into 2 separate WebViews and while searching for performance stats for the FamousEngine I came about an article about WKWebView and decided to switch and check it out.

Unfortunately, the delay of passing MIDI events to the Synth makes it unplayable. And I can't even use one view for better performing UI, because of the delay generated between the UI and the native shell.

Do you know of a better way of communication between the Javascript code and the native shell, that will lower latency to UIWebView levels?

In anyways, thanks for posting about this, I honestly didn't expect to find anything about it on the interwebs. :)
@Nikolay: See my more recent post about this: http://blog.persistent.info/2015/08/wkwebview-communication-latency.html

Post a Comment