Too many DOM updates / not enough display updates
See original GitHub issueI’m porting a Photoshop-style cropping widget from plain JS to Preact, and ran into some serious performance problems.
During drag-and-drop, the display updates (which are triggered by setState()
internally in the component) were extremely sluggish, with the rectangle clearly and visibly lagging behind the mouse pointer.
Dumbfounded, I launched a profiler and CPU monitor, and eventually started digging around in the source-code, where I found this:
export const defer = typeof Promise=='function' ? Promise.resolve().then.bind(Promise.resolve()) : setTimeout;
On a modern browser, this would resolve to a Promise
, which, as far as I can figure, means that this defer function will defer for the shortest possible time - e.g. until the moment where the JS thread is idle.
For animation scenarios, this seems to mean it’s doing a lot more DOM updates than the browser (Chrome) can actually do repaints.
In other words, it’s burning CPU on calls to render()
and DOM updates, leaving not enough time for the browser to keep up with smooth display updates.
I eventually found the solution in this comment:
options.debounceRendering = requestAnimationFrame
Boom! Buttery smooth display updates - at about 20-30% CPU load, whereas before I was getting choppy animation and serious lag at 90-something % 😮
Is there any practical reason why requestAnimationFrame
isn’t the default?
Issue Analytics
- State:
- Created 5 years ago
- Reactions:2
- Comments:13 (4 by maintainers)
Top GitHub Comments
rAF
is only called at the beginning of a frame. Microtask could happen anytime during one frame, as long as no other JavaScript is mid-execution, so it could get called after a callback, or at the end of each task.According to https://html.spec.whatwg.org/multipage/webappapis.html#event-loop-processing-model, any queued requestAnimationFrame callbacks should be executed before the next style recalculation/layout/paint. Currently Chrome and Firebox follow this spec, Safari and Edge do not.
There is a lengthy thread on why React doesn’t use
rAF
to do render. The gist is:For interactive events (such as clicks), using
rAF
batching is not suitable. Because these events are usually intentional and can happen multiple times in a single frame, delaying all flushing until a rAF breaks React rendering model and developer expectations in cases where the value of event handler depends on the state, or when event handlers read the state. Thus we need to flush each of them at the end of each event.Other effects can be deferred until later than next frame is suitable for a task to handle. React uses
requestIdleCallback
.The above two strategy would satisfy most use cases. For animation-specific usage, provide a migration path to allow user to use
rAf
for rendering. So in preact we haveoptions.debounceRendering
.What about debouncing your
setState()
calls with rAF? That’s the core of the issue here - you’re changing state in response to mouse movement, but inevitably you’ll end up with a lot of useless intermediary states along the way.Think about the options we have: everyone wants
setState()
to be synchronous because that defines how long it takes to get a frame out (if it were rAF-based, it wouldn’t be possible to have setState() update the current frame). At the same time, when using setState() from a mouse event there’s a use-case for state updates to be batched across entire frames.Personally, I actually did prefer the old rAF-based default. Generally my renders produce UI, which means I have no interest in rendering faster than the browser can paint. However, for folks using a lot of async nested components, maybe sequential setStates via rAF could slow things down. Imagine a case where you have something like
setState({ showDialog: true }, () => setState({ showDialog: false }))
- now the render produced byshowDialog=true
will be painted, whereas with Promise-based render debouncing it actually gets completely dropped.I don’t think there is one general-purpose solution here. Scheduling is an incredibly complex problem, and it’s unlikely that a generalized solution would have enough knowledge of render intent to be able to accurately predict how a given state update should be batched/applied.
I do agree that global options are problematic. We use them as a last resort, and they are really only present in Preact because they’re needed by preact-compat. Perhaps there’s potential here to replace the current
options.debounceRendering
global with something per-component, but that’ll make the queue implementation more complex and possibly more expensive.