
Have you ever worked on a solution that you just couldn’t get right? No matter from how many angles you tried approaching it, there would always be something missing. Or wrong. Or out of place. It would be a safe bet to say that everyone who works in the creative domain stumbled upon this issue at least once in their career.
When the answer to the problem stays stubbornly elusive, taking a step back is sometimes not enough. In order to get to the bottom of the matter, one needs a drastic change. But how to decide what should be changed?
On the Whiteboards.io team, we have recently tackled an interesting challenge. The only background available to our users has been the line grid. Although it was quite easy on the eye and battle-tested in terms of performance, we wanted to give the users an alternative choice.

That’s what has led us to the idea of creating the dots grid, stumbling many times along the way and ultimately using the titular Occam’s razor to break the deadlock when coding the feature.
The task turned out to be not only an interesting challenge but also a source of fun! We’ve had an opportunity to tinker a lot with different concepts and refresh our knowledge about micro-enhancements of applications performance.
The initial spike
At first, we created a simple prototype — the dots rendered in the form of a grid and adjusted as the user moved on the board and zoomed in and out. It quickly caught our eye that the prototype had several problems:
- We rendered the dots on an infinite area instead of a size-restricted one,
- The dots didn’t align with the lines from our previous grid,
- We had serious performance issues.
While the first two looked like they could be solved relatively easily, the problem with low performance seemed to be more troublesome. If you’re not interested in the technical details of the process outlined in the following sections and would like to skip to the resolution, skip straight to “The intervention.”
Troubles with performance
Every animation is created by repeatedly changing images displayed to the viewer. The speed with which old images are replaced with new ones is described by the value of frames per second (FPS). For the human brain to perceive an animation as smooth, there should be at least 30FPS, with 60FPS being the golden standard for what we see as the smoothest.
To get to the 60FPS refresh rate, the new image has to be drawn within no more than 16.7ms, as there have to be 60 such operations executed each second. After running first performance tests, we’ve seen the value of ~90ms for the dots prototype, meaning our display refresh rate was at around 11FPS, much below the acceptable threshold.
Brainstorming
Knowing we would have to solve the low refresh rate problem, we’ve held a brainstorming session to gather ideas on what could be done better. To properly explain the improvements proposed as a result of this session, we first have to outline the details of the implementation.
To render the dot background, we were using the Canvas API. On every user action, we would recalculate the size of the dots and the spacing between them. Then we would draw them one by one on the canvas using the arc() method of its API and adjust their positions by how much the user moved or zoomed on the board.
for (let i = minX; i < maxX; i += dotDistance) {
for (let j = minY; j < maxY; j += dotDistance) {
const screenX = i * zoom + translate[0];
const screenY = j * zoom + translate[1];
ctx.beginPath();
ctx.arc(screenX, screenY, dotSize, 0, 2 * Math.PI);
ctx.fill();
}
}
Loop rendering dots in a grid using the Canvas API
In order to avoid unnecessary re-draws of the canvas, we’ve decided to use the translate() method whenever a user moves the board and only re-render the canvas when they zoom in or out. We would translate the canvas along with the user movement until the translation value reached the value of the distance between two dots, then we would snap the canvas back into its initial position.
The rabbit hole of improvements
This simple trick allowed us to increase performance while simulating smooth, continuous movement, but it created its own set of problems. Our boards have an infinite space for the users to work on, but only a part of it is covered by the background grid to impose some restrictions on what the suggested area of work is.

The existence of the edge of the background grid forced us to implement a dedicated behavior of translation whenever the edge entered the viewport. The background had to move out of the view to simulate the end of the working area.
The working area has been established as a square of fixed size expressed in pixels. To calculate its boundaries with respect to the current user position, we were adjusting those values by their zoom and translation at any given moment. The final outcome had to be juxtaposed with the viewport’s width and height, informing whether the edge had been reached.
const seesBorderTop = rectBoundY > 0 && canvas.offsetHeight - rectBoundY > 0;
const seesBorderRight = canvas.offsetWidth - rectBoundX > rectSize && rectSize + rectBoundX > 0;
const seesBorderBottom = canvas.offsetHeight - rectBoundY > rectSize && rectSize + rectBoundY > 0;
const seesBorderLeft = rectBoundX > 0 && rectBoundX < canvas.offsetWidth;
if (seesBorderRight) {
newX = rectBoundX + rectSize - canvas.offsetWidth;
} else if (seesBorderLeft) {
newX = rectBoundX;
}
if (seesBorderTop) {
newY = rectBoundY;
} else if (seesBorderBottom) {
newY = rectBoundY + rectSize - canvas.offsetHeight;
}
return { newX, newY };
Adjusting translation to recognize the edges of the background
The code already grew in complexity, but it was far from the end of our problems. Besides implementing a dedicated translation behavior, we also needed the dots to fit within the working area during the redrawing of the canvas and we couldn’t use translation to achieve that. Because of that, we’ve added a check to only draw dots that fit inside the working area.
An issue appeared when we tried translating the edge of the background after zooming in or out. As only a part of the grid has been rendered, the translation of an incomplete grid causes a gap in the background, sometimes making it disappear completely.
Multi-canvases of madness
To patch the background hole issue, we wanted to offload the complete background grid with all the dots to a separate offscreen canvas on each redraw. Yet another offscreen canvas would then store data only for the part where the dots fit inside the background. This way, we would have a dedicated offscreen canvas when translating and another one when zooming in and out.
Drawing the dots using the Canvas API has been identified by us as one of the most costly operations in the background component. To ensure executing this operation exactly once, we’d first load all the elements that fit inside the background to canvas A, then all that don’t fit to canvas B, and then merge the canvases using the drawImage() method.

The drawImage() method would then be called once again at the very end to load the proper supporting canvas to the main one, displaying the results of the transformation to the user. To decide which canvas was needed, we also had to add tracking of the previous user operation, to determine if they translated or zoomed in or out before. And when this over-engineered solution seemed to finally work as expected…
We found a small mismatch between the layouts when changing operations. Putting it simply, whenever one would perform both translation and zooming one after another, there would be a small flicker of the background.
Hoping to find the mismatch soon, we’ve published the work-in-progress version of the pull request to our code repository. We’ve asked our colleagues to take a look at the solution while we kept digging in all the methods created there. We’ve thought that this must simply be an issue with non-updated values from a previous tick inside one of the useEffects. In the end, we never found out because of the review that we received in the meantime.
The intervention
One of the colleagues from our team decided to take a look into the PR with the performance optimization we’ve published. Struck by the complexity of our over-engineered solution, they asked — are you trying to implement a Blitter? This was a very much-needed wake-up call for us. As they rightly pointed out, if we struggled to find a basic fix to a problem in our solution while immersed this much in the topic, it would be a nightmare to maintain for other developers in the future.
Instead of remodeling the solution and trying to simplify it, we’ve decided to stop and reflect. What we knew so far could be summed up as follows:
- Our solution helped address existing performance issues,
- It caused at least one new issue in the process,
- We couldn’t fix that issue easily and it would only get worse in the future,
- It was complex to the point that it confused people familiar with the topic after just a day of not seeing it.
The prospect unfolding from these key points didn’t inspire optimism. It looked like taking a step back wouldn’t be enough. Or even several steps. We would have to start over.
This is where the titular Occam’s razor comes into place. Instead of wallowing in despair at all the days dedicated to developing those changes, we’ve decided to learn from our mistakes and, this time, keep it simple. But what Occam’s razor even is? In short, it’s a principle of avoiding unnecessary complications. Quoting its first great populariser, William of Occam, to whom it owes its name:
What can be done with fewer [assumptions] is done in vain with more.
Keep it simple, stupid
We’ve opened our round two with a basic question — what is the simplest way we could achieve performance improvements? We wouldn’t have to be 100% certain it’d work — given it was simple enough, we could just try it out and move on to other ideas if it failed.
The simplest way turned out to be introducing all micro-improvements we could possibly fit into the basic solution. That means batch drawing all elements in a row into one path, ensuring function calls happen only when absolutely necessary, and passing function parameters one-by-one instead of as an object, to name a few. Basically, we’ve tried to make sure that this part of our code calculates exactly as many things as it has to… and it worked.
Just like that, the simplest solution turned out to be the right one. After a healthy dose of fine-tuning, we’ve obtained a smooth animation! What initially stopped us from following this path is its simplicity. We have (incorrectly) assumed that if the problem is this significant, it would have to be fixed with some large-scale inventive changes. Turned out that if we hadn’t added unneeded assumptions to our process, we could’ve finished this task with considerably less effort.
If you love solving interesting problems just like we do — we are hiring!
Conclusions
This whole journey could be summed up in these words — always check if what you’re following is the optimal path. We often get carried away with our solutions because they are a breakthrough in a troubling case or because of our sentiment for them as their creators. In such scenarios, it’s a good practice to ask the question — could it be done simpler?
As a silver lining, two of the methods we’ve written during our first attempt have worked as inspirations for micro-improvements in the second iteration. We’ve used them to completely stop operations if the viewport is entirely outside of the background’s working space and to render the dots on parts of the screen when on the edge.
The dots background is already available in Whiteboards.io! If you want to look at what’s the result of all our hard work outlined in this article, you can change it in User settings within the app. And if you don’t already have a Whiteboards.io account, be sure to sign up for free by clicking the button below.
