LinkedIn Lite: A Server-Side Rendered PWA

A couple of months ago, we shared details about LinkedIn Lite's architecture, its evolution as a light-weight mobile web experience, and how it became a huge success in emerging markets. As a pure server-side rendered web app, it was fast, but it wasn’t providing a good user experience.

  • If a member was viewing their feed and had scrolled beyond the first page, they completely lose the context if they navigated away to a detail page and returned back to the main feed page.

  • If a member was offline, the browser rendered its default/native error page, as opposed to rendering an offline page that preserved the app navigation to allow them to try again. Even worse, if the member was in “lie-fi” mode (the device thinks it has a connection where it can barely reach the proxy/cellphone tower to hook up to a connection), they saw a loading progress bar forever.

Almost all web browsers restore the scroll position when users press the back button. However, since the subsequent content is loaded via AJAX through a different URL, the browser’s cache stores the content of the page against their own URLs. Assuming proper cache-control headers, and in the instance that the member has scrolled twice, the browser would have three cache entries: one for the base page and/or two with their page numbers. So if the member presses the back button, the browser cannot restore the scroll position, since it sees only part of the whole content that the member saw. Browsers only understand URLs (and not always the one visible in the address bar) and responses.

Use of Service Workers

The definition of Service Workers says that it is a script that your browser runs in the background enabling push notifications, background sync, and rich offline experiences.

Service Workers are JavaScript Workers, and therefore they run in a separate thread without access to the page DOM. However they have the capability to intercept network requests and can essentially act as a forward proxy like Squid or ATS in your user’s web browser! When combined with access to a cache storage, it becomes a powerful concept to enhance your web app, providing rich capabilities.

linkedinlitept21

Here’s a 50,000-foot view of the logic. On the unload of the page, the entire document is sent to the Service Worker, which caches it into the primary/hot cache against the page URL. If there’s a request for the same URL, the Service Worker responds back with the cached version of the page and makes a real request to fetch a fresh copy. The response is cached into a secondary/network cache with a very short timeout.

However, the actual implementation is a bit more detailed.

In a real-world application, there is always more than one canonical URL for a page, and LinkedIn Lite is no different. So, during the build time, all of these canonical URLs are grouped together under a common key (controller.method in our case) as a JavaScript file and bundled along with the Service Worker. For an incoming request, the Service Worker checks the URL against the key, and if a match is found, returns the response. For example, “/,” “/hp/,” and “/nhome/” are all mapped to a key FeedController.returnFeeds.

If there’s a cache miss, the Service Worker makes a request to the server and stores the content against its primary cache database. When the member navigates away from a page and subsequently returns back (either using the back button or nav bar), their browsing context and scroll position needs to be maintained. To maintain the context of the page, instead of keeping a single cache that gets overwritten on every subsequent request to the page, we maintain two caches, e.g., a primary/hot cache and a secondary/network cache. The former is always used to serve a request and preserve the context for the member, while the latter has the fresh content from the server and is returned when the member does a pull to refresh or if the primary cache goes out of date (30 mins.).

Caching strategy
The Lite Service Worker acts as a forward proxy/cache. It doesn’t know any specific details about the application, particularly regarding the static and the dynamic parts of the content. For this reason, it caches the entire page (as HTML) against the URL/cache key and responds back with the same to the browser when requested.

As with any forward proxy, the Service Worker doesn’t cache any errors, because the error status might be temporary from when the application was down or busy; only successful responses (status code 2xx) are cached. Adhering to HTTP RFC, the Service Worker caches only GET requests because POST and PUT requests are forbidden to be cached. In addition to this, any route specially annotated (@pwa noCache in our case) does not belong to the URL mapping, and hence are also not cached.

The only application-specific logic that the Service Worker has is to purge all the cache databases on any “edit” route. This design choice was made because the Service Worker neither maintains any application state nor knows the guts of the application.

If a member reloaded the page or pulled to refresh, the browser sets a “reload” flag as part of the Service Worker fetch event (event.isReload is true) and in such a scenario, the Service Worker bypasses the primary cache and responds from the secondary network cache or from the server if there’s a cache miss.

The contents of the secondary network cache are never swapped or moved to the primary cache or vice versa. Both the caches stay independent of each other. However, when there’s a page navigation either using the browser buttons (back/forward) or through the app nav bar/links, the entire page is saved to the primary cache database. Only in this case —when the content was served from the network cache—does get stored into the primary cache. This is because, on page unload, the entire content is always saved to the primary cache.

linkedinlitept22
linkedinlitept23

Success metrics
When this was ramped to 100% of members, we saw better business metrics, given that the site was now performing even better by a large factor.

Variant FBT (50th pct) PLT (50th pct) FBT (90th pct) PLT (90th pct)
Lite (w/out SW) 950 ms 2.1 sec 2.9 sec 5 sec
Lite w/ SW 150 ms 1 sec 450 ms 2.6 sec

App shell

However, we didn’t want to stop there. We wanted to further stretch the architecture and see if we could build an app shell that could enable members to launch LinkedIn Lite from their home screen (A2H). From our user research, we learned that our members had some resistance against opening a web browser and typing linkedin.com in the URL bar, so having an app icon would overcome that issue and help us with repeat sessions.

linkedinlitept24

The idea
During installation, the Service Worker would fetch and cache a transition page. When the browser initiated a request, the Service Worker would respond back with the transition page, and when the actual response came back from the network/server, the Service Worker would request the transition page to render the content provided.

While the idea seemed simple, there were a few issues we saw during our whiteboarding/prototyping:

  • If the transition page replaced the content (using innerHTML or a similar technique), none of the scripts or CSS would be executed by the browser automatically, so the transition page needed to also include the logic of evaluating the scripts and CSS, which would cause many issues, including FOUC.

  • Injected scripts are not evaluated in the order in which they’re injected, obviously. So there needed to be a logic to chain each of these bundles so as to not cause errors, or we needed to have a mechanism like RequireJS which would mean we’d need to bundle JS assets differently.

  • Webpack’s require.ensure also didn’t work for our build pipeline, with dynamic import syntax conflicting with Babel transpilation.

What worked
Finally, we decided to piggyback on the existing Service Worker caching logic. When the browser initiates a request, the Service Worker responds with a transition page. When the actual response comes back, the Service Worker notifies the transition page to “reload” itself, which results in the page returned from the cache.

The transition page also handles “lie-fi” scenarios where there’s no response from the Service Worker for more than 30 seconds and shows a nice custom error page requesting the member to retry.

linkedinlitept25

Challenges

  • The routes that are annotated as “not cacheable” are stored in a temporary, one-time read cache. However, images, AJAX requests, and edit routes still pass through and don’t go through the “app shell” experience. So for any third-party link (say a blog post shared on LinkedIn), the app will not show the progress spinner. This is still an open item and is not yet addressed in our architecture.

  • Since the transition page reloads itself, the navigation timing doesn’t reflect the actual numbers, so we had to compute a custom page load timing instead of purely relying on window.performance.

Conclusion

We have been seeing a positive impact on business metrics ever since we started using Service Workers in October last year. And with the app shell, the experience has also been much better, and we’ve been able to push the limits of an SSR app to behave like an app.

The biggest advantage we see with this approach is that there’s no penalty for members that don’t have a capable browser. And since the approach relies on Service Workers and not any custom/browser specific implementation, the app shell experience automatically started working on Safari beginning with iOS 11.2.

Acknowledgements

The initial team consisted of yours truly (Gopal Venkatesan) and Ramitha Chitloor. Later, the team became myself, Kaushik Srinivasan, Neena Jose, and Rahul Kumar. I’m thankful to a few people that provided valuable input during the initial phase of the project when the idea was conceived, especially Karthik Ramgopal, Kristofer Baxter, and Asa Kusuma. Lastly, special thanks to Ravi Hamsa for input on the app shell design.