I agree with most of the article, but would clarify the following: > For the screen to look smooth, frames have to be drawn at the display’s refresh rate. On the most common 60Hz display, that means 60 frames per second, or about 16.6 milliseconds per frame.
It isn't absolutely necessary to match the display refresh rate. With a 144Hz monitor, 72FPS is going to look smooth for the vast majority of the people, and even 48FPS will look smooth for most people. I agree that higher FPS is better, but there are diminishing returns.
Superb article.
There was very little new information for me as I have applied some of these techniques myself based on having developed an intuitive understanding on how a rendering "thread" works and blocks, but for a less experienced developer this article should be incredibly enlightening and provide a solid understanding of what's happening.
I employed use of yielding on a hobby project [0] I made about 15 years ago, particularly when it had to do lots of draw operations on a canvas. I also experimented with using worker threads to render pieces of it on a background thread, but at the time there was no way to copy the data efficiently between them and the main thread, one had to send the data as a base64 encoded PNG and the overhead of encoding, decoding and then copying it onto the canvas made it perform far worse than just doing it all on the main thread.
The website also does Gzip decoding of uploaded files in JavaScript and the library I found at the time did all the work synchronously so could lock up the UI thread easily for 10+ seconds. I tweaked it to be able to yield every 200ms or something, the process of which was very educational, particularly due to it convincing me to never omit the curly braces after an if statement, I spent a very long time trying to understand why it wasn't working until eventually I realized a statement I added wasn't in the if statement's block. It's not that I didn't understand how if statements worked, it's that in my mind the lack of curly braces was initially invisible to me.
Top article, kudos! applied some of these techniques previously, and they do matter a lot. thing is, when you learn/teach JS there is usually limited time left to talk these topics, and they are essential more than it seems.
the whole point of cooperative multitasking is to yield now and then (back to the runner), so that it can process the drawing. Besides, at 60fps there's so much that can be computed once every N frames, and the eye does not see it, the animation flows.
It becomes even more interesting when webgpu is involved, but the CSS animator is indeed very fast.
note: some recent work of mine (newskool digital flyer sites) -> bsf.hmsu.org // nouveauxhivers.dub4powder.xyz
Great piece, OP. We work in WASM and are considering web workers to help with rendering documents off the main thread in that context. I was surprised to read that ArrayBuffer is moved by reference only! Might be an option. Thanks for putting this together.
FYI: This isn't a browser-only thing. All platforms (Windows, Mac, ios, android) typically have a single-threaded UI.
This article concludes with the following statement:
> So much of development is trade-offs, and you have to choose according to the situation, which ultimately comes down to the developer’s experience and judgment.
It’s a great article, but I think it is a bit behind the ball in framing scheduling problems as a matter of “experience and judgment”. The problem of allocating work to a scarce resource is one of the oldest and most well-studied in all of computer science. It would make sense to go consult a textbook on the matter before trying to reinvent the wheel.
The discussion of "FLIP (First, Last, Invert, Play)" would be improved by mentioning the View Transitions API, which is basically there to automate this technique.
Excellent article, I went into it thinking the headline is obvious, but the examples are well crafted and it isn't just the obvious stuff.
I remember seeing some JS cryptography library a long time (maybe 10 years?) ago that provided an async hashing function for exactly this reason: to split up what would normally be a simple synchronous computation and yield more often to the main thread.
Can't recall the name, but I remember thinking that was very clever once I understood why it was doing that, and am glad to see the concept explained directly here.
Here is a problem that I had thought about in my last frontend project: the project needed to parse a user-provided string to provide syntax highlighting. The implementation was to parse the string immediately after a user keystroke so the user always sees correct colors. But I had this nagging thought that if I had designed the grammar wrong some parses might take more than linear time and would hold the main thread during the parse. But I didn’t really want to first render a black string, parse on a service worker and then rerender with color. The effect must have seemed disorienting.
Anyways I stopped the project for unrelated reasons and this thought kept nagging me in the back of my mind. The time I work on the project I’ll be sure to try some techniques in the article.
In the first example: doesn't pushing the button while typing cause the textfield lose focus? What's the point of the typing in the example?
Brilliant, I had no idea about this stuff. Thanks for writing it up.
On the price ticket example, on my device (Samsung A15, mediocre phone from 2024) I get 11 fps with the slow example and 30 fps with the fast one. (15 if I scroll!)
Is that a case of the back pressure you mentioned?
All the other (fast) examples are 60 fps though :)
Nice examples, a bit repetetive perhaps.
One thing that I found quite interesting to see is how the behaviour of the "4.000 particles" demo changes between the 5ms rendering and the "everything now" rendering.
Such an excellently written articles with really nice interactive visualizations!
If you are currently trying to build a web-based clone of something approximating ChatGPT, pretty much everything in this article is absolutely mandatory.
The streaming response UI/UX is a total nightmare to make work smoothly. You have to come up with clever heuristics around where to chop up the stream of changes and how to batch the work relative to frame updates to make it not look like confetti during a streaming response.
Reminds me of sprinkling DoEvents all over my VBA code to keep it "snappy" when I started to get into programming
I wish await in JavaScript could conditionally yield. Instead I end up having to use arounds like if (shouldYield()) await do yield();
I learned these lessons from many separate places and have always wished to see them in one place. This was an incredibly great article that I very much enjoyed reading and will be sending to anyone who's in need of ideas about speed. Thanks a lot!
The chat example here doesn't really seem to work for me—I get 32 FPS consistently no matter what the chat settings are. Maybe a Chrome thing? Or an M3 thing?
One obvious tool that this article doesn't mention is Chrome's performance monitor. Many techniques in the article only have to be applied once you can verify that they are running slow.
This was so incredibly helpful and well presented.
I want to do a blanket response to people calling this AI slop: the post was written in korean and translated to english. Calling this slop is judgment slop.
That's the kind of content I come to HN for, thanks!
AI;DR – too bad as the content seems to be worthwhile, but then why not take the trouble to write it up yourself. Have it proofread/edited/spruced up all you want afterward, but don't have it do all (or most of) the writing.
> Press a button: the JS animation and typing freeze, but the CSS animation keeps spinning
How do you expect me to type if pressing a button moved text focus away from the text field?
> So what if off-screen posts were left as empty shells that only take up their height, and got filled with real content as they approach the screen?
Yeah fuck everyone doing that, hello Reddit, Outlook for Web or Bluesky. It makes searching on such "feed" pages with the browser's search function an utter pain in the ass, made worse by the fact that the platforms' own search functions are outright braindead.
great article - explains in detail why my sans js websites run so smoothly and so fast.
Yeah, but if your website with, you know, text and stuff, doesn't do any stupid and unnecessary crap, it doesn't matter where the nonexistent unnecessary yet expensive code doesn't run.
What does "Browser's Main Thread" even mean when there are over 30 processes running?
A handful of comments on an otherwise fairly reasonable article.
> When we run into jank like this as developers, the usual reaction is to wonder “is my code slow?” and start picking apart algorithms or looking for wasted computation. In most cases, though, the speed of the code is not the problem. The code isn’t slow. It just happens to be the code that’s holding the main thread.
Eh, most of the time it is slow, because you chose to use React. React used naively scales very badly. You have to jump through lots of sometimes-fiery hoops to make React… not much slower than some of the alternatives. Memoisation (possibly now via React Compiler) and things like that.
> [Diagram with rAF, Style, Layout and Paint attributed to the main thread]
Paint hasn’t been on the main thread in Firefox since 2017: https://mozillagfx.wordpress.com/2017/12/05/off-main-thread-...
Not sure about other browsers. Firefox has generally been the leader in these areas.
> [steering cost demo]
The demo is deeply unsatisfying because the simulations are quite different in a very problematic way: in “all at once” mode, it loses most mouse movements, which radically affects the simulation which is attuned to movement events rather than simulating physics based on a constant tick rate. Without examining it closely, my guess is it’s using pointer events and the browser is coalescing the events because of the blockage and it’s only taking the final state rather than going through all of getCoalescedEvents(). In a simulation like that, if you can’t run it at full speed, you have to decide what to do, and you should do it deliberately, because it may be disastrously wrong. Should you fall over, simplify the simulation by dropping some of the calculations, reduce the tick rate, something else?
> The demo below is a markdown editor with a long CHANGELOG open. Building the preview means parsing the entire document (about 2,000 lines) and rebuilding its DOM from scratch, which is far too expensive to run on every keystroke.
No it doesn’t. This is a clear example of your code being slow, because you’re doing the wrong thing. You want an incremental parser of some kind. Markdown is tolerably well-suited to this kind of thing because for most edits you can just render the current paragraph.
The suggestion is bad. “Debounce 300ms” behaviour is obviously worse for small documents.
> DOM writes can be batched as well. Appending a hundred nodes in one operation instead of one at a time, or toggling a single class instead of changing style properties individually, turns many changes into one and helps performance. The old technique of assembling an HTML string and assigning it to innerHTML in one shot has the same essence. You gather the writes so the rendering pipeline’s fixed cost is paid once.
This is simplified far beyond accuracy. parent.append(one_hundred_nodes) and one_hundred_nodes.forEach(node => parent.appendChild(node)) are unlikely to perform particularly differently. Changing style properties individually isn’t that* much worse than toggling a single class (it amounts to rewriting the style attribute a bunch of times, and the CSS parser and serialiser aren’t particularly slow). Building an HTML string and assigning it to innerHTML is better than constructing children manually in some cases, worse in others. The real thing that matters is avoiding triggering layout unnecessarily by things like accessing clientWidth between changes.
> Deferring
The lazy rendering trick used here is very problematic because you still need to know how tall each item is. Plenty has been written about hazards of infinite scrolling, that’s what’s being dealt with here. Now if you can have each item be a fixed height, then progressive rendering has far fewer side-effects. I would also say that switching from Notifications back to Feed and having it take an extra few hundred milliseconds is really not that bad. And that I’m not convinced the approach taken was right anyway, you could often just leave the feed rendered and avoid affecting the gross layout.
—⁂—
On the presentation of the site itself: this makes no sense:
.post-content ul {
word-break: break-all;
}[flagged]
So this is AI slop written? Why would anyone believe I'd want to read AI spam?
Also, the issue I see is who controls browsers as much more profound. We need to find a solution here, as the browser is too important to allow private companies to keep mankind hostage.
Edit: Wow, and the praise-accounts. Is that new on hackernews?
[flagged]
[flagged]
[flagged]
[flagged]
[flagged]
[flagged]
The javascript web is. Restore the interop with noscript/basic HTML and it will lean towards sanity, something lost a few years ago.
It would be better to say that the Browser's Main Thread has been made expensive to run, because browsers have had a bunch of crap piled on them in terms of functionality that is expensive to run.
Actor Model, dedicated or shared application worker. Full support for multi-window apps. Resolved since 2019. 26000 commits in, and counting. You are welcome!
https://github.com/neomjs/neo/blob/dev/learn/benefits/body/O...
This mistake gets repeated over and over again, in every single GUI framework of any kind. I don't understand why does it have to be that way.
Just use multi-threading. Run each library in a separate thread. Use actors approach to pass data between libraries, application and finally to submit data to update GUI.
Using single thread for everything and expecting that developer will create separate threads for heavy work is obvious approach, but it never worked. Developer doesn't care. And user gets inferior experience.
Good article and I wish this was much better known.
The issue is though that it's too focused on interactivity. In reality, 90%++ of slow sites are not slow because of interactivity really, they are slow because they ship enormous react/nextjs bundles and have extremely heavy hydration work to do.
_so many_ sites have bundles >10MB that need to be downloaded, parsed and hydrated.
I've even seen (many) sites which have multiple SPAs stacked inside of them.
If you're on a slow internet connection and/or CPU the page is basically unusable for many tens of seconds and no amount of yielding post bundle hydrate will really solve that.