Users like a fast and responsive application, however, sometimes there are things that make building such a responsive app a bit tricky. In this article, we discuss how we we manage to improve our front-end React app by shortening the loading time from approximately 30 seconds down to around 6 to 3 seconds.
We have a page known as the scenario editor page. In this page, a user can add, delete or modify what we call as steps, of a scenario. A step can be anything a user can do on the page, like for example: clicking a button, entering a value into a text field, visiting another web page, and so on.
Although we know that React is pretty fast by default—with its DOM diffing mechanism able to perform efficient updates only to the necessary nodes—some of our customers have so many steps in a single scenario making even such efficient rendering feels slow.
Yet, as this scenario page remains central to our users’ day-to-day life, we have to find a way to speed up the rendering time. Although the focus of this article is on the React side, we also made some improvements on the backend side, which we will discuss in this article as well.
Before going even further, it’s almost always necessary to have some baseline to compare. To do that, we can simply take notes of the page load time for example. We can take 10 hard reload, and calculate the average load time. In some cases, we measured the memory consumption as well. This simple step really doesn’t take more than just a pen and paper to do.
After establishing some baseline, we go stright at finding ways we can do to optimize our front-end application. We asked ourselves: why is the rendering so slow? There can be many reasons, and sometimes it’s confusing where to look at first.
Fortunately, we have been using an App Performance Monitoring (APM) tools, such as New Relic and Scout APM, for quite awhile. This was a good starting point. By utilizing it, we were able to point out that we were frequently sending queries on a table having no index for the column we used for filtering. That resulted in a whole-table scan making executing the query considerably slower.
So the fix was simply by adding an index on those fields! Easy fix, easy gain. Right? Thanks to the APM tools.
But wait, shouldn’t we always use the ID field, designated with primary key, for filtering out the data? Yes, but, there are cases where we don’t want to use the primary key. For instance, let’s imagine we allowed our admin to list scenarios belonging to any given username as it is easier to remember a username than an ID. Thus, the query for that request won’t use the table’s ID column, but the username.
On top of that, we should never perform this kind of query:
scenarios = Scenario
.joins(:tags)
.where("tags.name ILIKE ?", "%#{tags}%")
Can you guess why shouldn’t we issue such a query?
Yes! It is because searching off based on a string will never be as fast as the following codes:
scenarios = Scenario.where(tag: Tag.find(tag_id))
The query above is much faster, as we are using the primary key of the tag. No string scanning is needed, thus it’s much more optimized.
At the end, we managed to shorten query execution time from 5~7 seconds down to the neighborhood of 500ms for certain scenarios.
Do you believe that we achieved between 39% to 60% speed improvement just by avoiding N+1 queries? That translate to a speed improvement from 16.39 second to around 6.91 second, when the scenario page contains 200 steps.
How did we do that? We used Chrome Dev Tools to discover that we send an HTTP request for each step one-by-one. Why did we do that is because we would like to retrieve additional metadata given a step’s ID.
This kind of problem is called N+1 query issue. Sending a request one by one is almost certainly never a good idea. So, we fixed this issue by sending only one request to retrieve all the metadata. Even better, we further improved it by having the metadata embedded on the page’s DOM structure so that we don’t have to send another request just to have it.
JavaScript is an asynchronous language. It helps if we understand the so pronounced difference between the now and later in its event looping system.
In JavaScript, a later does not have to happen immediately after now. That is, the now is not necessarily blocking the later. That might sounds abstract and philosophical, but perhaps it is easier to observe by using codes. So, please take a look at the following JavaScript snippet:
var scenarioData = ajax("https://autify.com/scenarios/1.json")console.log(scenarioData)
In JavaScript, it’s definitely bound to happen that by the time we reach the second line, scenarioData is still undefined , and that is what is going to be printed to the console. That is, the second line is executed without waiting for the first line to be executed in its entirety.
Internally, we may be able to conceptualize JavaScript through the following oversimplified codes:
var event
while (true) {
if (futureEvents().length > 0) {
event = futureEvents().shift()
event()
}
}
Each iteration of the loop above is called a tick, within which an event is taken off the queue if there is any, and then executed. Those events are all later things that should become now in our JavaScript codes. As such, the correct way to print the scenarioData is to put this into the tick just after the ajax request completes, or by having a then callback:
ajax("https://autify.com/scenarios/1.json", (scenarioData) => { console.log(scenarioData)})
Internally, we may be able to conceptualize JavaScript through the following oversimplified codes:
var eventwhile (true) {
if (futureEvents().length > 0) {
event = futureEvents().shift()
event()
}
}
We can capitalize this knowledge further by asking ourselves: If JavaScript sees the world as events occurring one after another, where the later does not mean rightly after now, and the now doesn’t mean it will block: can we postpone expensive operations for, simply, 1 millisecond later?
So, instead of the following code:
<Form.Check inline
className="text-secondary"
type="checkbox"
checked={ !!continuesOnFailure }
onChange={() => {
onContinueOnFailureChange(!continueOnFailure)
}
} />
We request that the change be executed in the next tick (also turning this component from a controlled component to become an uncontrolled one):
<Form.Check inline
className="text-secondary"
type="checkbox"
defaultChecked={ !!continuesOnFailure }
onChange={() => {
setTimeout(() => {
onContinueOnFailureChange(!continueOnFailure)
}, 1)
}
} />
And yes! With that change, a user clicking on a checkbox will see the change takes effect immediately, instead of waiting for React to re-render 200 strong components so that the toggle turned into the checked state. Instead, what will happen is: the toggle get checked, and React re-render. Or technically speaking, since the change is registered in the next tick, users will see the change in the checkbox first, as the change callback is executed in the next tick. This makes the user experience feels more snappier and faster.
In some case, we even nest setTimeout within setTimeout:
fetch(stepsDataUrl)
.then(response => response.json())
.then(data => {
dispatch(hideSpinner()) // right now!
dispatch(showMessage("Scenario saved successfully"))// we most likely don't need to re-render steps right now!
setTimeout(() => {
dispatch(setSteps(data.steps)) // it's not so urgent to close edit panel right away
setTimeout(() => {
dispatch(closeAllDetailPanel())
}, 1)
}, 1)
})
It is important to note that setTimeout does not actually put our callback on the event loop queue. It just set up a timer, and when the timer expires, the environment places our callback back into the event loop, such that in some future tick, the JavaScript engine will execute the event.
In other words, setTimeout may not fire our event with a perfect temporal accuracy—that is, there is no guarantee that our callback is fired in the exact time as we want it. However, we are guaranteed that our callback won’t fire before the time interval we specify. In our case, this doesn’t matter a bit.
First, let’s observe a debouncing function as follows. If you are using lodash, we can use the excellent debounce function from the library.
const debounceChanges = _.debounce((value) => { onArgChange(value)}, 1000)
Debouncing is a technique we can use to prevent a triggered event from being fired too often. Essentially, it allows events to be executed once for a group of similar events.
But, what does that mean?
Ok next, instead of doing this:
<textarea value={arg.value || ""} onChange={e => onArgChange(e.target.value)} />
We can do this:
<textarea defaultValue={arg.value || ""} onChange={e => debounceChanges(e.target.value)} />
With this change, instead of waiting for around 2,000 ms (2 second) for a re-render each time a user key in something on a textarea, the changes from pressed keys appear immediately. That is because we debounced the fired events, making it only fire after certain period of time in which the event does not fire.
Look out on the ways you can cache something. Since we know that the browser won’t download images that have been downloaded, we always using the exact same URL address for fetching the same image. It does help.
When a user edits a scenario, the user will have to persist those changes to the server. We speed up the whole process by sending the edit request through an AJAX call. Using this techniques, and combined with the trimming data technique we described above, the update takes a second instead of tens of second when there are huge number of steps in a scenario.
At some point, we realized that we serialize data that we don’t even need. By only serializing data that we do need, we can reduce the file size from 3 megabyte to 1.5 megabyte, with a potential to reduce it even further down to 108 kilobytes.
There are still some experiments we would like to do in order to optimize this page further. Perhaps, we should render only 50 steps at any given time. If there are more than 50 steps in a window, there will be a scroll bar anyway. And when the user scroll down, only the next 50 steps will be rendered. This way, we limit the number of Step rendered in the Board.
Or, how about optimizing the image by way of compression? That way, the browser can process other things than downloading more bytes, couldn’t it?
As we are always trying to develop the best software our users can use, it’s very likely that this is not the end of the journey. Be sure to check out again our engineering blog for future updates.
That being said, let’s have a discussion if you have something in mind.