Improve Signalr and React Performance User Experience
With the last two articles, we now have a working app that shows everyone’s cursor in real time. But before we deploy this to production, let’s do some much needed performance optimizations!
This article is part of my series “Realtime webapps with SignalR and React”:
- Realtime Cursor Tracking with .NET and React using SignalR
- Sync React with SignalR Events
- Improve Signalr and React Performance User Experience (You are here)
Too Many Updates per Second
Modern monitors and graphics cards can handle over 60hz, with some even reaching 240hz. This means the cursor moves between 60 to 240 times per second, causing each client to send updates up to 240 times per second over the SignalR connection.
It’s easy to see this doesn’t scale well. We can definitely sacrifice some of the buttery-smooth cursor movement for scalability and performance. For example, Miro allegedly updates just four times per second.
One way to solve this is by throttling
our mousemove
event listener. Throttling limits how often the event handler runs, ensuring it only executes at set intervals.
Since we might have different throttle requirements in the future, it is best to do this using a higher-order function instead of changing the actual event listener:
_17// utils.js_17const Throttle = (fn, delay) => {_17 let ready = true;_17_17 // return the throttled function_17 return (...args) => {_17 if (!ready) {_17 return; // block the function if it's not ready_17 }_17_17 ready = false; // block the function after this call_17 fn(...args);_17 setTimeout(() => {_17 ready = true; // allow the function to be called again after the delay_17 }, delay);_17 };_17};
A few JavaScript-specific things are happening here:
- The higher-order function takes another function
fn
and a delay timedelay
. - A
ready
variable is set outside the returned function to track when it can run again. - Using an arrow function ensures
this
is captured correctly, so we can useready
inside the throttled function. - The returned function spreads the current arguments and calls
fn(...args)
.
By using this higher-order function, we can easily adjust the throttle settings without touching the core logic of the event listener.
Finally, we can wrap our updateCursorPosition
function in the useUpdateCursorLocation
hook with our new Throttle
function like this:
_10//normal event handler_10const updateCursorPosition = (ev: MouseEvent) => {_10 // ...function body_10};_10_10//throttled event handler_10const updateCursorPosition = Throttle(_10 (ev: MouseEvent) => {_10 // ...function body_10}, 250);
Improving User Experience with Springs
After applying throttling, you’ll notice the user experience has become quite jarring. Instead of smooth movement, the other cursors seem to jump around the screen at noticeable intervals.
While we can’t update the cursor position 60-240 times per second to recreate the exact movements of the other cursors, we can improve the user experience by transitioning smoothly between each updated position.
I got the best results using springs. Springs are an animation technique that don’t rely on a timeline and predefined curves but instead mimic physical properties like stiffness, mass, and damping. The folks over at liveblocks.io dive deeper into different animations and their pros and cons for multiplayer cursor animation in this article.
There are several libraries that provide spring animations. Two of the most popular are react-spring
and framer-motion
. Since my projects often need more than just spring animations, I prefer using framer-motion
.
Adding framer-motion
is straightforward. We simply replace the div
in our OtherCursor
component with motion.div
like this:
_20// Feel free to change the settings to suit your animation preference_20const spring = {_20type: "spring",_20damping: 30,_20mass: 0.8,_20stiffness: 350,_20}_20_20export function OtherCursor({ color, name, x, y }: OtherCursorProps) {_20 return (_20 <motion.div_20 style={{ background: color, position: "absolute"}}_20 initial={{ x, y }}_20 animate={{ x, y }} // framer-motion now handles the position of each cursor_20 transition={spring}_20 >_20 <div>{name}</div>_20 </motion.div>_20 );_20}
With this, our mouse movement looks a lot smoother. Personally, I still prefer updating the mouse positions more than 4 times per second since a 250ms delay still looks a bit janky. But depending on your use cases, hardware and performance requirements, this approach works well. If Miro can get away with it, you probably can too.
Wrapping up
Last time, in Sync React with SignalR Events, we connected a React app to a .NET SignalR Hub for realtime cursor tracking. While that solution worked, it wasn’t quite production-ready. Having 60 to 240 cursor updates per second from multiple clients is a bit overkill and not even necessary.
In this article we took control of the updates-per-second by implementing a throttle function. With throttling, we can set a millisecond threshold to limit how often a function can fire.
While this solved performance issues, it hurt the user experience. By introducing spring-based animations, we brought back smooth mouse movement and improved the user experience.
That’s it for now, see you in the next one!
What to read next:
I really hope you enjoyed this article. If you did, you might want to check out some of these articles I've written on similar topics.
- Read Sync React with SignalR Events— 7 minutes readSync React with SignalR Events
- Read Realtime Cursor Tracking with .NET and React using SignalR— 6 minutes readRealtime Cursor Tracking with .NET and React using SignalR