Making Chromium UI ~3x as Smooth on High Refresh Rate Monitors

The Problem

Is it just me, or Chrome really isn't as fast and smooth as it claims?

Most people switch to Firefox for all its privacy claims. I did not (although that's indeed a plus). I have been using Firefox because the UI animations on nearly all Chromium-based browsers are intolerably laggy.

By “UI,” I mean all the components surrounding the web view: the tap strip, the toolbar, the download shelf, the Omnibox. The problem is most noticeable when you close a tab and see how neighbors adjust their bounds to fill the gap. You observe uncomfortable stutters, and that discomfort lures you into repeating the whole thing to confirm that it's not an illusion. It's not, and the pain only intensifies with the repetition until it completely ruins your day.

Do not mistake me. The animations in web pages are impeccable (Chrome couldn't have taken over more than half of the market share if it can't even do this right). But this contrast just makes the whole problem more annoying. Worse still, this lag passes down to nearly all Chromium derivatives like Edge or Brave and makes the entire Chromium world visually unacceptable. Vivaldi is an exception because it bases its UI on its own framework, but then its homemade UI does not blend well with other Chromium UI (which the Vivaldi devs don't bother to hide). This inconsistency itself is a problem.

So I started using Firefox. It was great until all the incompatibility issues reminded me again that I would need a Chromium-based browser in case.

Of course, you would expect someone to file a bug report somewhere for such a big user experience problem. Someone did indeed, but the devs don't seem to care.

Tfw you run across this thread while trying to find out if anyone else's scrolling stutters sometimes.

Blows my mind how stuff like this remains unsolved for months. I've sorta gotten used to laggy tabs by now but it's like they're fine with ignoring reasons for people to trash talk their browser's performance.

Enough complaints. If the devs aren't going to fix it, I will do it myself.

Checking Out the Code

The first step into fixing the bug is to check out the code. I followed this guide on Chromium's project homepage and encountered basically no problems except for two,

  • The guide asks you to run everything in a Windows command prompt instead of other shells. This also implies that you shouldn't start the command prompt from other shells (e.g., by typing cmd.exe in PowerShell) because then the launched process inherits the environment variables from the parent shell. I have Conda installed and this inheritance caused problems. The Conda shell integration somehow dynamically prepends its Python executable to Path on PowerShell startup. Consequently, no matter how up front I put depot tools in Path it will always be preceded by the Conda Python as long as I launch cmd.exe from PowerShell.

  • Use NTFS. The guide says

    At least 100GB of free disk space on an NTFS-formatted hard drive. FAT32 will not work, as some of the Git packfiles are larger than 4GB.

    I did not take this very seriously and thought I would be good as long as I don't use FAT32, so I initially used my external SSD in exFAT. The checkout did complete with no warnings and I was able to build. However, I faced massive >1h overbuilds even when I modified nothing (and more than 6000 targets have to be rebuilt when I changed a .cc file). For a time I thought this is the norm and had a very bad impression on the efficiency of Chromium's build system. ExFAT is also very inefficient in storing small files such that ~30G of source files ends up occupying ~180G of disk space, which is complete nonsense. Reformatting my drive as NTFS solves both problems.

First Attempt

I have never dealt with Chromium code before. Fortunately, Chromium's excellent documentation saves me the trouble of diving into the tens of millions of lines of code in the codebase completely clueless. It also has this online code search engine which makes navigating the code orders of magnitude easier. I was able to find this piece of code, a good start:

A further look into BoundsAnimator reveals that it has the member function SetAnimationDuration.

My first hypothesis is that if the animation is somehow laggy, then by making it shorter in duration the lag becomes less noticeable.

A search in tab****.cc reveals that SetAnimationDuration is not called by tab strip except with an argument of 0 to disable rich animation, so the default duration is nearly always used, which is defined here:

So I changed 200 down to 100 and then to 60. Now the animations are blazingly fast (literally), but is it smoother? Lags are much less likely to occur and be noticed but I could still found some from time to time. This fix is not satisfactory. Time to go deeper.

Second Attempt

A closer look at BoundsAnimator shows that it calls SlideAnimation under the hood:

SlideAnimation, in turn, inherits LinearAnimation, where an interesting constant gets defined,

The above screenshot basically captures everything I want to say. The LinearAnimation class uses the framerate passed in the constructor to calculate the timer interval at which it updates its internal state and calls observers (that's the role of BoundsAnimator). The observers then propagate the update to redraw interfaces. Most calls to LinearAnimation constructors ignore the frame_rate argument so kDefaultFrameRate is used. 60 appears to be a reasonable value at the first glance but it no longer is given that I am using a 165Hz monitor.

There has been a common argument that human eyes cannot differentiate motions beyond 60Hz and high refresh rate monitors are just a hype. I had believed this for years until I switched to a 90Hz phone. When everything else is animating at just 90Hz a 60Hz animation stands out as clunky, not to mention 120Hz or 165Hz. So here lies the problem: the Chromium UI animation appears to lag because somehow the devs decided to hardcode a framerate ceiling into its rendering process. They take VSync onto another level.

(This realization might also explain an interesting phenomenon I discovered when I attempted to file a bug report myself a while ago. I used OBS to record the tab strip animations. The lag was obvious at the time of the recording but when I looked back at the recording it seemed less so. Perhaps that was due to the screen capture being 60FPS too? Probably there was some magic going on at the video decoders that makes 60FPS video content appears smoother?)

If this is indeed the root of the problem, then the solution is simple: just tune up kDefaultFramerate. Do not forget to tune down the minimum timer interval in CalculateInterval accordingly though, because as it is now (10000) any framerate greater than 100 will be effectively capped to 100.

This is literally a change to 3 lines of code, and yes, the lags are gone!

(So in some sense the “3x as smooth” in the title is a clickbait. 165 / 60 = 2.75, so the framerate does approximately triple. The question is: is framerate a good measure of “smoothness”?)

Some Thoughts

I have had a very strong respect for the Chromium Project for years, so strong that I may well call it worship. After all, it is a codebase with over 10M LoCs (and growing) while I was still struggling to manage my personal projects, which all go below 10k LoCs. I've read many posts analyzing the elaborate architecture of Chrome, and many others call Chromium an engineering masterpiece. It was under this mindset that, when I first observed this bug a year ago, I thought there must be some seriously complicated regression deep inside the rendering engine of Chromium. Any fix would be highly untrivial and beyond the understanding of us mortals.

I consider a great takeaway from this bug-fixing experience to be the demystification of the project. I downloaded the code; I found something I could fix; I fixed it. Locating the root of the problem wasn't hard. So I suppose the only justification for the devs not to have fixed it is that the browser UI has a lower priority than the kernel.

I wouldn't say that my fix is elegant in any sense. All I can say about it is that it works well on my machine. Now it seems that the approach to hardcode the default framerate as a device-independent constant is fundamentally flawed. But how to improve on that? Should we query the max framerate of the monitor whenever we play an animation? Should we cache the result somewhere? Should we make it a configurable item in chrome://flags? A general and sustainable fix is much more complicated than changing the constants. I may be able to come up with one someday, but IAP is drawing to an end, and there are too many things for me to do. So, I choose to file a more technical issue on Chromium's issue tracker for the time being. Leave it to the devs. But before they officially fix the bugs, I will be using my own fork of Chromium.

Speaking of building Chromium on my own. A side product is that now I can enjoy the latest features of Chrome (I am already using M100) without worrying about the privacy controversies Chrome has. I am pretty sure there still exists some telemetry modules in Chromium (that's why ungoogled Chromium exists), but that's arguably much better than Chrome. I also leave the API key empty, so nothing sensitive will be sent to Google -- perhaps? My guess is that now it's on par with unhardened Firefox.

By the way, here I would like to briefly mention Firefox's approach to the UI problem. When investigating the bug I was surprised to know that Firefox's UI are web-based, i.e., it uses the same kernel to render both the web pages and the UI. Thus some very interesting things will happen if you type chrome://browser/content/browser.xhtml into the Firefox address bar! I feel like this is a better way to go since building web UI is a mature subject, and now by optimizing your kernel you are also optimizing your UI. I am not sure.