TryGhost/Ghost

Chrome infinite scroll performance

Closed this issue ยท 14 comments

Issue Summary

There appears to be a possible layout bug in Chrome that causes long delays when loading/displaying pages of our content list with infinite scroll. This screen cap demonstrates the issue where I am attempting to scroll as quickly as possible (it was particularly bad when recording, I didn't reach the bottom of the list to keep the gif short):

chrome-scroll

For comparison, here's exactly the same set of posts in safari and scrolling as quickly as possible (note it reaches the bottom of the list before Chrome even displays 1 extra page):

safari-scroll

Performance in Firefox is similar to Safari.


Things I've ruled out:

  • scripts taking longer to run in Chrome - captured profiles indicate that the time spent in scripts whilst the spinner is displayed is pretty minimal compared to the length of the delay
  • network requests / images - profiles from both browsers and the logs in ghost indicate that the time spent in the ajax request is minimal, typical response time from the server is ~60ms. Removing images from the template to avoid the loading and 404s made no difference
  • rendering of list items - removing the list item component and simply rendering a <h3>{{post.title}}</h3> inside each list element still displays the same problem
  • layout bug - on further investigation the JS, layout + paint step is pretty quick

Scroll behaviour and timeouts stalling network connections

I've observed that when dragging with the scrollbar rather than the trackpad it's not possible to reproduce the slow behaviour.

Looking at the timeline profile I can see quite a few scroll events and timers are created, it now seems possible that those events/timers are somehow causing the stalled network requests that I've shown in my comment below

Possible layout bug?

Removing all other layout to leave only the scrollable element with the same styling does not exhibit the dire performance illustrated above. This leads me to think that there may be a bug in Chrome's layout engine that is being triggered by something in our surrounding styles/HTML, perhaps something to do with flexbox and an expanding element? I've not had much luck confirming this via profiling, the profiles appear to show Chrome sitting there doing nothing for quite a while before a very quick paint step.

Steps to Reproduce

  1. Import a large number of posts into the blog
  2. Load the content screen
  3. Scroll

Technical details:

  • Ghost Version: master (a degree of this is also visible in LTS but the scroll behaviour has been improved in master)
  • Browser/OS:
    • Chrome 55.0.2883.95 / macOS 10.12.3
    • Chrome 56.0.2924.87 / macOS 10.12.3
    • Chrome Canary 58.0.3000.0 / macOS 10.12.3

I'm now wondering if this is a layout bug or if something else is going on. This timeline profile indicates that the page load request took 3.5s from kicking off the request to receiving the data during which time the spinner was showing. However, inspecting the actual network request shows it took only 108ms...

screen shot 2017-02-02 at 14 36 13

screen shot 2017-02-02 at 14 37 26

To confirm stalled network requests I added some instrumentation to ember-infinity's _loadNextPage route method. These are the results comparing Safari (all mouse scrolling) and Chrome's difference between mouse scrolling and scrollbar scrolling...

screen shot 2017-02-02 at 17 34 13

After some more investigation I think I'm running into this behaviour http://stackoverflow.com/questions/35024301/xhr-settimeout-promise-not-finishing-until-scrolling-stops-in-chrome

I am consistently able to increase the loading time by scrolling while the network request is being triggered. If I disable Chrome's "threaded scrolling" so that scroll events happen on the main thread the effect is even more pronounced, to the point where I can almost prevent the next page from loading indefinitely.

I backed up my conclusion by modifying _shouldLoadMore to always return true so that on refresh every page is loaded sequentially. Without scrolling every page is loaded in ~60ms, as soon as I start using the scroll wheel whilst the pages are loading the loading time jumps, if I continuously scroll up and down a little the next page never loads although interestingly my _loadNextPage timer only reports a delay of a few seconds once I stop scrolling.

Confirmed the slow infinite scroll loading in LTS is caused by the same setTimeout/XHR blocking behaviour in Chrome as we're seeing since the switch to ember-infinity.

I've tested removal of Ember.run.debounce (setTimeout) and replacing it with requestAnimationFrame in ember-infinity to no avail, it seems that it's definitely the XHR that is being blocked rather than usage of setTimeout. Strange thing is that the request is delayed for a period even after scrolling has stopped which results in a worse experience than if the request was kicked off immediately after scrolling - this doesn't sound like normal behaviour but I have been able to prove otherwise yet.

There's a long discussion on a related chrome bug report that has been going on for >1 year https://bugs.chromium.org/p/chromium/issues/detail?id=570845

Things to look into:

  • do we have touch events that are causing us to be throttled more heavily
    • YES - see comment below
  • re-run the isolated scroll container to see if that provides any insights now there's more information about what the actual problem is
    • isolating the scroll container so that it was the only element on screen displayed the same behaviour
    • interestingly, recorded profiles of the scrolling were still showing "long frames" of ~30ms even with the infinite scroll code removed. Chrome's FPS overlay was showing 60fps however
  • how are others handling this? We can't be the only app to be struggling with infinite scroll in Chrome

I eventually tracked this down to a Chrome "optimisation" behaviour that is enabled when you have touch event handlers registered. Disabling touch events in Ember's EventDispatcher solved the problems I was seeing:

let App = Application.extend({
    Resolver,
    modulePrefix: config.modulePrefix,
    podModulePrefix: config.podModulePrefix,

    customEvents: {
        touchstart: null,
        touchmove: null,
        touchend: null,
        touchcancel: null
    }
});

Another confounding factor which may/may not have been an issue I noticed when profiling - Chrome seemed to be flagging long frames (~30ms) when scrolling, as far as I can tell that time was taken up entirely by Chrome's scrollUpdate and mouseWheel events, there was only a ms or two that were JS related and it was the same when I'd removed ember-infinity to test. I bring this up as I've seen mention that the delayed setTimeout and xhr behaviour can be triggered when frames are taking >16ms.

Hi @kevinansfield,

Thanks for this complete debug. I was wondering if disabling touch events really solved the problem ? I'm facing the same issue on a side project and I make no difference ๐Ÿ˜ž

Thanks !

@thomasdurin I just fixed this issue in my Angular application by shamefully modifying the Angular source code to use requestAnimationFrame instead of calling setTimeout with a low or undefined ms.

self.defer = (fn, ms) => {
  return (ms || 0) < 16 ? window.requestAnimationFrame(fn) : window.setTimeout(fn, ms)
}

@m59peacemaker Which file in the Angular source code did you modify? We are running into the same issue and desperately looking for a solution... Thanks!

Mine was in the ionic bower package, ionic.bundle.js, but I think this is the file in the non-bundled Angular source.

https://github.com/angular/angular.js/blob/a03b75c6a812fcc2f616fc05c0f1710e03fca8e9/src/ng/browser.js#L322

You just need to make it use requestAnimationFrame when the timeout is a small one as in my above code.

So, go to https://github.com/angular/angular.js/blob/a03b75c6a812fcc2f616fc05c0f1710e03fca8e9/src/ng/browser.js#L322

And changing it to this should help:

  var efficientTimeout = (fn, ms) => (ms || 0) < 16
    ? window.requestAnimationFrame(fn)
    : window.setTimeout(fn, ms)
  
  self.defer = function(fn, delay) {
    var timeoutId;
    outstandingRequestCount++;
    timeoutId = efficientTimeout(function() {
      delete pendingDeferIds[timeoutId];
      completeOutstandingRequest(fn);
    }, delay || 0);
    pendingDeferIds[timeoutId] = true;
    return timeoutId;
  };

Thanks for the quick reply! We are actually in Angular 2, but maybe I can find a similar workaround there. Thanks again!

I experienced a similar problem with my own app in React. I stumbled across this post looking for a solution. My issue was related to "Content Download" and infinite scrolling. Disabling the touch events did not work for me, but Chrome honors the mousewheel event despite the fact that most other browsers don't. I put a conditional e.preventDefault() on the event and that seems to have done the trick.

window.addEventListener("mousewheel", (e) => {
    if (e.deltaY === 1) {
        e.preventDefault()
    }
})

I am still not 100% sure as to how this directly impacted the scroll event in Chrome. Here is a more in-depth response on stack overflow.

@m59peacemaker I came across this issue in my AngularJS app with a custom lazy template loader. I really like your solution, but didn't want to modify the Angular.js file directly. So instead, during the config phase of the application, I copy the $window service and modify the setTimeout to your efficientTimeout function. Then it's just a matter of overriding the $browserProvider to return the $browser service instantiated with the modified $window service.

Hope this help someone. :)

let $window = $windowProvider.$get();
let $modifiedWindow = Object.assign({}, $window);
$modifiedWindow.setTimeout = (fn, delay = 0) => delay < 15 ? window.requestAnimationFrame(fn) : window.setTimeout(fn, delay);

let [browserProvider] = $browserProvider.$get.slice(-1);
$browserProvider.$get = /*@ngInject*/ ($log, $sniffer, $document) => browserProvider($modifiedWindow, $log, $sniffer, $document);