redfin / react-server

:rocket: Blazing fast page load and seamless navigation.
https://react-server.io/
Apache License 2.0
3.89k stars 184 forks source link

Add service worker to support faster second load #281

Open aickin opened 8 years ago

aickin commented 8 years ago

After watching a bunch of Google I/O talks on Progressive Web Apps, I've been thinking a lot about how ServiceWorkers could help react-server load faster on second visit. I think it might look something like this:

On first load of any page:

  1. Install ServiceWorker for root level of the site.
  2. As part of the install process, download and cache every JS bundle in the app in the background.

On subsequent load of that same page or any other on the site:

  1. When a request comes in for an HTML URL (like example.com/foo), run through the router to determine which JS and CSS bundles would be required by the URL. Check the cache for those resources.
  2. If there's a cache miss, give up and just load /foo as usual. If there's a cache hit, though, then the ServiceWorker requests just the data bundle of /foo with a special header that has the webpack hash of the cached build (something like X-React-Server-If-Not-Updated: a34bfed4378).
  3. If the webpack hash sent by the ServiceWorker is different than the current build on the server, the client is out of date. The server therefore ignores the fact that the browser asked for the data bundle and instead returns the HTML page for /foo. The ServiceWorker just passes that along to the browser unaltered. The new /foo page installs a new ServiceWorker, which in turn caches the new app build.
  4. If instead the webpack hash is up to date, then the server sends back just the data bundle, and the ServiceWorker returns to the browser a skeleton page with one empty RootElement and performs a client transition to the page in question.

Benefits:

Drawbacks:

Thoughts?

gigabo commented 8 years ago

Mind. Blown.

This is an awesome idea!

a skeleton page with one empty RootElement and performs a client transition to the page in question

I'd been thinking about pre-loading the frameback frame with something like that, which could improve first load performance on frameback nav. Another skeleton page use case to keep in mind.

So... You working on this? When can I play with it? 👹

aickin commented 8 years ago

So... You working on this? When can I play with it?

I'm only thinking about it as of yet, and I may work on it, but I wanted to share the idea in case others wanted to run with it (and to get folks' thoughts).

I should also say that I have a competing implementation idea, which is essentially that the ServiceWorker just runs the server render code (with a hopefully extremely quick ping to the server to make sure that the SW's copy of the app is up to date and fallback logic when it needs to update). Chrome, Firefox, and Edge will all soon support the ability of Service Workers to stream content to the browser, so the browser could display early RootElements before later RootElements had been rendered. You'd basically just substitute the web stream response write method for Node's res.write().

A maybe cool side effect of this implementation strategy is that you could implement the ReactServerAgent cache using the Cache API rather than the <script> tag cache. As the page is being rendered in the ServiceWorker, just clone every data response and throw one copy in the cache using the Cache API. Then, when the browser requests that data endpoint, pull it from the cache and delete the entry. This would have the benefit of being less code (and perhaps more dependable?) than our <script> tag-based cache.

The downside to running the server side renderer in the ServiceWorker is that in HTTP/1 you might hit connection limits when retrieving the data from a bunch of different API endpoints. In HTTP/2, that shouldn't be a problem.

Another potential pitfall of this strategy is that I don't know what the JS parse/execute perf would be like for loading up the server renderer in the Service Worker, and I also don't know how frequently Service Workers are evicted from memory.

It does have the potential to be more maintainable, though, and it has a certain elegance. Thoughts?

aickin commented 8 years ago

Oh, sorry to double post, but one other thought.

In any case, a good first step might be a Service Worker that just aggressively downloads and caches the JS and CSS bundles. That would be useful even without any rendering hijinks.

doug-wade commented 8 years ago

I don't know how effectively browsers balance bandwidth between the current active page and an installing ServiceWorker. I think browsers just handle this, but it's possible that installing the app could interfere with first load.

My intuition is that browsers do "just handle this", since the "whole point" of web workers, iiuc, is to do work in the background that doesn't affect the ui thread. I also suspect that even if they don't, it shouldn't be too hard to work around -- I mean, if push came to shove, couldn't we postpend a script tag to the initial page load that starts the ServiceWorker only after the full page was received?

Load balancing between multiple versions of an app would likely cause the ServiceWorkers to thrash, but load balancing multiple versions is probably already a bad scenario.

You mean if there were two different groups of hosts, one running v1 and one running v2, and we got different webpack hashes from the different pools, and you were randomly assigned to one of them, we would reload the page unnecessarily? I don't think we'd thrash too hard; in the worst case, you'd have a 50/50 split on two versions, where 50% of second page loads would incur an extra page load (25% chance of v1 -> v2, and 25% chance of v2 -> v1), and you could completely eliminate the thrashing with a more sophisticated load balancing solution (consistent hashing, pooling, etc).

Loading a data bundle with the ServiceWorker means that we don't get any data until the last byte arrives, which may slow down client-side rendering vs. parallel client requests. However, the new client-side streaming APIs could probably fix this.

Could we use the data bundle preloading that we do for client transitions to offset this cost? It seems surprising to me that the cost of transmitting the data bundle across the wire would take significantly longer than the amount of time to create the data bundle, but I'll admit that's based on intuition and bias and not on data, and I might be wrong altogether.

The downside to running the server side renderer in the ServiceWorker is that in HTTP/1 you might hit connection limits when retrieving the data from a bunch of different API endpoints. In HTTP/2, that shouldn't be a problem.

The more I think about this, the less I feel like I "get" web workers -- I thought the intent was to do work in the background that didn't affect the main ui thread, but this has got to be true -- there's no way you can open inifinite xhrs in a ServiceWorker and not exceed the browser max conns. In any event, can we bundle up the data + static assets on the server and send that to the ServiceWorker in a single request?

it has a certain elegance

Yes, yes it does. I would love to see us come up with a use for the reserved __CHANNEL__ with isomorphic cluster + ipc modules that wraps up ServiceWorkers and the node cluser api into a single api that "just works" ™️, and it sounds like you're gesturing towards that in a way that is both elegant and practical.

In any event, this is a really long way of saying: I think you're on the right track

aickin commented 8 years ago

My intuition is that browsers do "just handle this", since the "whole point" of web workers, iiuc, is to do work in the background that doesn't affect the ui thread.

I agree, but it wouldn't be the first time a web feature had a point-defeating bug... 😉

if push came to shove, couldn't we postpend a script tag to the initial page load that starts the ServiceWorker only after the full page was received?

Yes, although to nitpick, that wouldn't guarantee that it wouldn't interfere with later AJAX requests, inflight image requests, etc. But it would help!

You mean if there were two different groups of hosts, one running v1 and one running v2, and we got different webpack hashes from the different pools, and you were randomly assigned to one of them, we would reload the page unnecessarily?

Yes, although there's the added wrinkle that the v2 ServiceWorker would cause the v1 ServiceWorker to uninstall (and vice versa). Since installing the ServiceWorkers downloads the whole app for offline, you could have a lot of extra downloading of the app if you swap between v1 and v2.

Could we use the data bundle preloading that we do for client transitions to offset this cost?

I'm not sure. How would we do that?

It seems surprising to me that the cost of transmitting the data bundle across the wire would take significantly longer than the amount of time to create the data bundle

The worry I have is that the case where there are 10 data requests, and 9 are fast and 1 is slow. If you do the requests individually, you can render pieces of the page as soon as the individual requests come in, whereas if you ask for the bundle, all the data goes as slow as the slowest request. Does that make sense?

The more I think about this, the less I feel like I "get" web workers -- I thought the intent was to do work in the background that didn't affect the main ui thread, but this has got to be true -- there's no way you can open inifinite xhrs in a ServiceWorker and not exceed the browser max conns.

I think that vanilla web workers are intended primarily to do work in the background, but service workers are really more about caching, proxying the network, offline behavior, and responding to events that occur when the web page is no longer open, like notifications.

would love to see us come up with a use for the reserved CHANNEL with isomorphic cluster + ipc modules that wraps up ServiceWorkers and the node cluser api into a single api that "just works" ™️

I think we've overstepped the bounds of my knowledge. 😆 Is __CHANNEL__ a react-server concept? What does "isomorphic cluster + ipc modules" mean?

In any event, this is a really long way of saying: I think you're on the right track

🚀🚀🚀 Thanks for the feedback.

dfabulich commented 8 years ago

Do you think this actually works? https://github.com/NekR/offline-plugin

This plugin is intended to provide offline experience for webpack projects. It uses ServiceWorker and AppCache as a fallback under the hood. Simply include this plugin in your webpack.config, and the accompanying runtime in your client script, and your project will become offline ready by caching all (or some) output assets.

aickin commented 8 years ago

I've seen that but haven't yet played with it. If it works, I think it could implement a simple "cache the static assets" strategy, but not a more complex strategy where the sw proxies the root HTML page.

addyosmani commented 8 years ago

Heya. Over on Chrome, we also work on a few Service Worker libraries that might come in handy for your offline caching strategy.

These have been used in production for simple to relatively complex SW setups by Alibaba, Flipkart, Washington Post, Walgreens and a bunch of other sites. If you find them useful enough to experiment with, feel free to holler and we can answer any questions you have.

I might also recommend looking at the PRPL pattern which tries to incorporate HTTP/2 push for delivering resources to render requested routes with SW precaching of other routes. We found it leads to some relatively decent perf wins (checkout how fast the deployed demo app is)).

jeffposnick commented 8 years ago

It's really exciting to hear that you're thinking about this pattern.

In case it's of any help, I gave a talk/have some sample code illustrating how something similar could be done using universal React (via my own, hand-rolled server) and adopting the App Shell + dynamic content model:

The sample doesn't do any code splitting, which means that the logic in the App Shell "skeleton page" is pretty straightforward—there's always a single, large JS bundle that gets pulled in. I'm not familiar with how react-server decides what code bundles are needed for a given page, so that would be one area that would need to be tweaked.

I'm happy to chat about the App Shell model a bit more, along with how sw-precache can handle service worker generation to ensure that your static, critical resources are cached and kept up to date. The sw-toolbox library can be used in conjunction with sw-precache, to help with defining runtime caching policies for bundled resources that are loaded dynamically and only used on certain pages.

(CC: @addyosmani, who beat me to commenting on this issue by like 5 seconds...)