14-Dec

Elm

Fixing a performance problem in Elm using Html.Lazy

How you can increase the performance of your Elm application using Html.Lazy, and why that motivated me to look into elm-css.

7 min read

·

By Robin Heggelund Hansen

·

December 14, 2021

A few weeks ago, I got to sit down with a colleague of mine to fix a performance issue with one of our customer’s search page. The page looks something like this:

The page causing a performance problem

This page is one Elm app. Its job is to present the user with a list of results that match a search the user has made, and let the user make a new search if they find no suitable result.

Before reading the next paragraph, keep in mind that the actual application is much more complex than the simple drawing I’ve just shown you. The illustration is only there to make it easier to understand the problem and the solution we arrived at.

The page worked as intended, but felt a bit slow. There were small, but noticeable, delays between characters when changing the content of the search field. When clicking a button you might notice a tiny delay before anything happened. The animation for clicking the search button, which due to a bug in elm-animator wasn't using css animations, was choppy.

Now, the reason we were looking into this was because we, the developers, were unhappy about it. We hadn’t received any user feedback from our users that the page was slow. It wouldn’t even surprise me if most of our users didn’t notice any performance problem at all, much like many don’t notice the difference between a native- and an electron-app unless comparing them side-by-side. Well, the choppy animation was probably noticable, but that had only been in the app for a few days at this point.

In other words, the reason we were looking into this was because the performance didn’t meet our own high standards, not because it had become an actual problem… yet.

Finding the problem

Whenever there is a performance problem, we need to find a reproducible way to measure it. Since we already had a choppy animation, we simply triggered the animation a couple of times while running the browser’s builtin profiler.

Here are the results:

Profiling the page with Chrome devtools

The root of our performance problem stems from a function called _Char_toCode, which is being called by two elm-cssfunctions: VirtualDom.Styled.getClassnameand Hash.fromString.

Since our animation isn't using css animations (again, due to a bug in elm-animator), every stage of the animation causes the view function of our Elm application to run. It seems that the most expensive part of our view function is when elm-cssgenerates class names for the HTML elements.

If the only problem we had was a choppy animation, we could re-implement it using css animations ourselves. However, since the app had a general feeling of sluggishness, that wouldn’t fix all our issues.

One could attempt to refactor the code to use less HTML elements, thereby giving elm-css less work to do. But when looking over the code, we didn't find any obvious way to do this. Even if we did manage to remove a bunch of HTML elements without changing the look of the page, we had no idea if it would be enough to fix our performance problems.

Enter Html.Lazy

Elm’s HTML package has a series of functions in a module calledHtml.Lazy, which lets you change a view function so that it is only called when its input arguments have changed. If the input arguments are the same as when the view was previously called, it will tell Elm's virtual dom implementation to make no changes to this particular part of the DOM without calling the actual view function.

As an example, if we have a view function call like:

viewSearchResults currentDate results

We can make it lazy by re-writing it to:

Html.lazy2 viewSearchResults currentDate results

And then viewSearchResults will only be called if there is reason to believe that it could return something different, which potentially avoids all the computation that function has to make.

elm-css has its own implementation of Html.Lazy, Html.Styled.Lazy, and so in theory we should be able to use this trick to give elm-css much less work to do, thereby increasing our performance.

It did turn out to be the case. Today, the search page runs much faster thanks to two lines of code that wrap our view functions using Html.Styled.Lazy. However, in order for those two lines to work we did have to do quite a bit of refactoring, in part because of how this optimization works.

The problem with Html.Lazy

The first thing we had to fix was some bad design on our part.

The search page is structured like two separate Elm views. The top part of the page, the search box, is one view while the results is another. It is natural then, that both of these top-level view functions become wrapped in Html.Styled.Lazyfunctions. This way, if you edit the text in the search box, the computation for rendering the search results can be skipped.

However, both view functions, as well as their sub-views, take in the entire model as an input argument. This doesn’t play too well with Html.Lazy, as the view will be triggered whenever the input changes, and since the input is the model itself, it will trigger every time one of its fields changes, even if that field isn’t being used in the particular view.

It’s also problematic from a code design perspective, as the views become very tightly coupled to the entire state of the application.

It took some time to refactor this so that both view functions had their own, entirely separate, piece of the model. Strangely enough, the application felt just as sluggish as before.

In order to reduce the amount of refactoring, we had opted not to change the actual application model, as that would also require us to refactor the init and update functions. Instead, we divided the application model into separate pieces as part of the view function itself.

So, where we previously had code like this:

view : Model -> Html msg
view model =
  div []
    [ viewSearchBox model
    , viewSearchResults model
    ]

We now had:

    resultsModel =
      { field1 = model.resultsField1
      , field2 = model.resultsField2
      -- in reality, there were more fields
      }
  in
  div []
    [ Html.Styled.Lazy.lazy viewSearchBox searchModel
    , Html.Styled.Lazy.lazy viewSearchResults resultsModel
    ]

But this didn’t work.

The reason is that functions in Html.Styled.Lazy have a different notion of equality than Elm itself. In general, two things are considered equal in Elm if they represent the same value. However, Html.Styled.Lazy functions only considers two things to be equal if they are the same reference.

Since we were constructing a new searchModel and resultsModel object every time the view function was being called, they would never be considered equal to the previous input by Html.Styled.Lazyfunctions, even if comparing them with Elm's == would return true.

Realizing this, we went on and refactored the application model to have its own search and results sub-models. This also required big changes to our init and update functions.

After another hour or two we had a better structured application. Not only that, the animation was running silky-smooth and there were no noticable delays anywhere.

In conclusion

Html.Lazy, or Html.Styled.Lazy, can be a great way to improve the performance of your Elm applications. However, it's an optimization that is easy to get wrong.

Html.Lazy is the only construct in Elm that has the notion of reference equality. Because of that, it's an easy thing to forget when refactoring code, especially if you're not working on the same code base on a daily basis. You now need to be careful to retain reference equality in update and view functions around your code, something you normally don’t need to worry about.

If you don’t, nothing bad happens other than performance becoming worse, which you may or may not notice depending on your hardware, if you have Elm’s debugger running, or the code paths you’re triggering.

So while Html.Lazy can be a great tool, I found myself wishing that elm-csswas fast enough that we didn't have to use this optimization at all.

Tomorrow, I’ll take a deep dive into the inner workings of elm-css and explain how I found a way to nearly double the performance of the framework.