Problem

You have a UI piece that is performing some action on every frame — an animation that is following continuous user input like a knob or a “range input” would do. All was dandy as long as you use a simple component or you managed to optimise rendering of it, but it got more complex or you just found out that on some older device that you have to support it doesn’t work as good, as on your superb Galaxy/Pixel/iPhone, part of you dev bling.

On web, rendering hook requestAnimationFrame is capped on 60fps and (for now) never goes higher, for some mobiles it goes under that, but favouring steadiness of frame rate above average speed, even on newer devices that theoretically would allow you to go further (120Hz mobile screens as a standard may here be soon!)

When you are way below 30fps, like 1fps 2fps, you are having more serious problem and more specific too, so let me state first that this is not about that kind of scenario. What I’ll help you with here is about having a 60fps target (as you should have) and actual performance between 20–60fps.

Analysis — What your problem is really about?

When you try to follow user interaction what you’ll do is update app UI whenever you get an information about that interaction, so user feels like it has a direct and often even physical influence over that part of UI. Knob example fits here quite well as it has a real world counter part and that is conveying an expectation how this should operate. But in real world, you turning a knob, have an actual, physical control over that object whereas when it is UI you have to update your UI to match that state.

Update of UI is never instantaneous, even if it is pretty fast. Reacting to user input providing steady 60fps means an update, a single frame every 16.(6)ms. In browsers that means less time for calculations as render (layout/composition/paint) is handled by a browser. On mobile, if you don’t draw pixels directly onto screen, you’ll have an overhead as well. Lag is where you (and whatever aids you in render) take more than that 16.(6)ms.

Where on high-end devices you should never have a lagging UI and by that I mean you should never produce a code that will cause that, you always can hold performance on older/weaker devices to lower standard. That still doesn’t mean unresponsive UI. So what to do when you just cannot reach target framerate?

(🌍Web) target=60fps: use “cheaper” properties

Rendering in browser consists of few steps:

  • layout
  • (then) paint
  • (then) composition

Then, there are some properties that in some cases overlap with their function, yet they trigger different (smaller) set of rendering steps. Having this problem in mind, most common replacement will probably be position to transform.translate.

https://csstriggers.com/position
https://csstriggers.com/transform

As you can read provided this list of causes, moving an element inside a viewport using transform should be more appropriate as it triggers only composition step and therefore leaves more time for your code to execute. If you need to move a node on the screen, but not to change it’s size — use transform.

(🌍Web) target=60fps: Use paint layers

So previous trick will mainly allow you to sometimes skip layout phase. This one will focus on paint phase. csstriggers.com may still help, but this time it is about more active approach.

Paint is slightly different phase it terms of how work is organised. Whereas layout is calculated as a whole, painting is done on layers — every layer is made up separately. Therefore, whenever there’s a paint trigger it is for that layer where trigger happened. We can leverage that.

Check an article about stacking context on MDN to see what causes a layer to constructed. These will be:

  • position=relative + z-index
  • position=absolute + z-index
  • position=fixed
  • being flex root child + z-index
  • opacity other than 1
  • mix-blend-mode
  • transform
  • filter
  • perspective
  • clip-path
  • mask, mask-image, mask-border
  • -webkit-overflow-scrolling=“touch”
  • will-change “specifying any property that would create a stacking context on non-initial value”
  • and one that is just for sole purpose o creating new layer: isolation=isolate, however is not yet reliably supported yet

Knowing that, we can make sure that an UI element that is following user interaction is not on the same layer as the rest of the app. Any paint triggering property upon change will trigger paint only for that single layer.

Even better when the rest of the UI is on a single separate layer. That means that also composition (merging layers) can be done faster — due to merging only two layers instead of amount that would surround animated element. That can be most easily achieved with still pretty new React Portals. You can put a dedicated node just next to app root node (one you mount your app onto) and make it so both of them are made into layers.

(🌍📱All) target=60fps: Defer acknowledgement of new state

This is for a subset of mentioned cases where a lag is not introduced not because of the animated element itself, but due to the element being a controlled component driven with a value(s) that drive something else, something “heavy”.

You can throttle submitting the value making so the animated element itself is in a state of limbo between having state and being controlled — having a state while updates are throttled, then at once submitting update and dropping it’s state, then repeat. This might still cause junk (breaks in responsiveness), yet while it is bad, it will be overall better.

(📱Native) target=60fps: Use Animated and (if possible) useNativeDriver

Although React Native allows you to start fast, that initial ease (as it often is) will take it’s vengeance. This is not something that React Native was built for neither is good at.

When you have a scenario where you can use event driven approach with Animated, use it. Although the service — it’s necessity — serves as a case against React Native, it is quite good solution when faced the problem. It has quite good interface, too bad that documentation could be a lot better and sometimes is confusing (which also indicates that this is a zone where React Native “ends”). When you cannot use Animated you’re left with next approach.

(🌍📱All) target=60fps: Compensate and predict

When all other means fail, the element is still lagging behind user input, take a leap into the future ✨

You could also apply this technique to elements that perform well, but usually this would make element more complex with little to no visible performance, so it would be waste of a workforce.

So, you missed no news — time machine is still unavailable, but I’ll show you how, using a prediction you can better UX.

Below is a representation of frames, frame hook and lagging code. For simplicity, let’s pretend that browser/native work on render is part of our code. This is what would normally happen:

You can see that as animation started, we immediately drop a frame as next one is not ready yet. It’ll be displayed on next frame buffer sync. That’s not very different than in case of meeting 60fps target, but now the delay of where user input is and what screen shows is twice longer, which is true for 30–60fps range and will be even more severe for apps performing worse than 30fps. However with proper approach even 30fps (where 60fps is technically possible) may be consider smooth and somewhat pleasant.

If you measure time between animation render hook (like requestAnimationFrame) triggers, you can estimate on average how much behind 16.(6)ms limit (1000s / 60 frame). Then, using that, you can submit (or locally acknowledge, according to “Defer acknowledgement of new state” section) state not for a moment of receiving the event, like you’d normally do, but estimated moment of next frame buffer flush.

Say, you track finger movement which accumulated to 4pxbetween frames. However you don’t hit 60fps, but you’ve estimated that on average you get 45fps. That means that, also on average, you’ll be not a frame, but frame and half behind. Using both informations you can draw an element like it should look with that “on average” overshoot. Knowing current velocity of user input change and assuming that it won’t change you can estimate that when you’ll finish rendering you’ll be twice that far — 8px in this case. Now when new frame will be pushed to the screen it’ll match user input. You have to still remember and differentiate “real” states, but always present prediction.

Assumption of constant movement is obviously flawed, but should be good enough if you can more specific to your element movement characteristic. In most cases you’ll undershoot on first frame and overshoot with last, but it will look better, more responsive than constant lagging behind user input.

(🌍Web) target=120fps: Use css transition / web animation

Current devices that are capable of pushing pixels to the screen 120 times per second still run the same browsers that cap update render hook firing frequency to 60fps. This is, obviously, not straightforward decision and is justified with an argument of that it is better for UX to pretend in front of your code and trigger render hook with 60fps as it is quite common that it would exceed 8.(3)ms time limit (1000s / 120frame), which in consequence would mean unstable frame rate.

However due to the fact that browser trusts itself a lot more than you and your code, it will run css animations and Web Animation effects with native frame rate! So, for animations that are not deterministic, which require render hook, you could potentially run using CSS transition or Web Animation for these even, filler frames setting a style as not an immediately required state, but animation target.

If you want to know more, watch:

…and/or read 2018: 120fps and no jank.

This is an “excerpt” from what I have found myself writing in code reviews either more than once or with a feeling that this is something important or due to completely another reason. Still naming it that way. Call 🚔 the police 🚔 if you wanna.
🌟 Follow me or the series to get more of it. 🌟

--

--

Artur Kulig
React Code Review Excerpts

Human since 1987, web developer since 2008. Seasoned warrior of Frontend tribe. Fought IE6 to extinction.