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):
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):
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
- Import a large number of posts into the blog
- Load the content screen
- 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...
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.
Closed via TryGhost/Admin@0affec3
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.
You just need to make it use requestAnimationFrame
when the timeout
is a small one as in my above code.
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);