Speeding up Symbols: how we improved performance in Sketch 67
A behind-the-scenes look at how we built Symbols in Sketch — and how we made working with them faster
When we announced version 67 of the Mac app, we put a lot of focus on the performance improvements it brought. Our team worked hard behind the scenes to streamline things and make the whole app feel a lot smoother and more responsive. Take a look at our announcement post to find out more.
Now, we want to give you a look at our approach to speeding up Symbols. This is the first in a new series of posts where we’ll dive a little deeper into the technical side of the app. Stay tuned for more in future.
We spent a lot of time working on Sketch 67’s performance — to make the Mac app faster. We’ve improved a number of things, from how we render background blurs with saturation, to how we time our display updates to stay closer to the screen’s refresh rate.
We’ve tried to make working with Symbols feel smoother in general, too, and a few people have asked how we went about this. While the answer isn’t some crazy story involving advanced computer science or math, there also wasn’t just one thing that made all the difference. But we do think there are a few interesting things we can share that might also ring true for other developers.
What makes a Symbol?
When we introduced Symbols, we started with a basic foundation. Symbols consist of two parts; a Symbol master, which is like an Artboard, and contains multiple layers (such as text layers, groups and shapes), and a Symbol instance, which is a regular layer that just contains a reference to the master. Whenever we need to draw an instance to the canvas, we find the Symbol master and draw that in its place.
A Symbol instance can also contain overrides, which is a set of derivations from the values in the master. Text overrides are the most common. Before Sketch 67, if an instance had overrides we would make a full copy of the master, update the text layers with the overrides values, and then draw that copy.
When we first released Symbols, they only supported text overrides. Since then, we’ve added more types of overrides — nested Symbol overrides, style overrides, resize rules, Smart Layout and more. Having a growing team working on these presented challenges, and while we always try to document our code and share knowledge, we’re not a hive mind. We had a nagging feeling we could be doing better.
As features evolve, the basic assumptions that they were built on may no longer hold true, and the architecture you put in place on day one may no longer suffice. But rather than rewrite it at the first sign of trouble, it usually makes more sense to stretch the system a little further. After all, we can’t rewrite every line of code every time we create a new feature.
Eventually, though, you hit a breaking point, and we reached one while developing Smart Layout. We used to render Symbol instances as copies of Symbol masters, but that had to change so we could deal with layout adjustments affecting nested symbols. Now we render Symbols as detached groups — a Symbol instance is turned into a group that contains a copy of all those layers, recursively down into all the nested symbols.
After we shipped Smart Layout we felt we needed to take a step back and assess our whole approach. Unsurprisingly, we found assumptions that no longer held true — and inevitably, where multiple developers had been working on different assumptions, there was now conflict. We discovered, for example, that we had two caches for storing calculated Symbols, and we were spending a lot of time filling up one of those caches in advance — without ever actually using them where it mattered most.
Pulling Symbols into shape
We also decided to take a good look at how we were calculating the final Symbol instances that we render after applying overrides, scaling, Smart Layout etc. As I mentioned, these used detached groups, and we found that we were creating too many intermediary copies of (deep) object trees. We managed to cut this right back.
I described earlier how we make copies of the master to apply overrides. While this is fine for most Symbols, it becomes exponentially worse with more complex and deeply nested Symbols. What made this worse was the double tree structure we use in our model — a mutable tree that lets the UI layer easily make changes, backed by a lockless immutable model that we can safely pass between threads for doing things such as rendering the canvas across multiple threads.
In short, we were creating many intermediate copies and throwing them away again. There was no magic answer to this — it was just a case of finding ways in which we were going from one tree model to another and trying to eliminate them. On any decent-sized Symbol we managed to eliminate at least half a dozen intermediary copies using this method.
This had all kinds of other knock-on effects, too. For example, it meant we could better utilize a cache that was based on identity. Making an intermediary copy caused a cache miss, which could cause relatively expensive things like shadows to be calculated all over again.
Less work, more speed
Another big win was discovering that we were drawing too much when a Symbol master changed. Drawing fast in a typical macOS app is achieved by drawing as little as possible. This has two benefits:
- By telling the system the smallest area of the screen that has changed, we can push fewer pixels back to the screen
- We don’t need to redraw pixels that haven’t changed, so you can skip over logic that traverses layers, draws paths, shadows etc.
We found that editing a Symbol master actually caused the entire canvas to redraw, which had something to do with how we track changes in our document — by calculating diffs on a series of immutable trees. We’ll talk about the bigger benefits of this in a future post, but suffice to say that this was a worthwhile fix to make.
A blueprint for the future
If there’s a lesson to take from this, maybe it’s that it’s important to stop once in a while and investigate what you already have. It’s easy for little inefficiencies to creep in, and because they happen between other constant changes to the codebase they can be easy to miss. Making Sketch as responsive and reliable as possible is a priority for us, and it’s something we’ll continue to make efforts towards going forward.
For now, though, if you haven’t tried Sketch 67 yet and your documents have a lot of Symbols, I hope you’ll give it a try and see the difference it makes.