What is rendering on the web?
I’ve been interviewing a lot lately, and I’ve found that I like “onion” questions that can be answered at different levels of depth.
On the backend “What is a database index?” is one of my favorites (thanks Yoel). An engineer can tell me that it will make their query faster. Or that it does so because it prevents sequential scans. They can mention you shouldn’t index everything, because there is a write time and memory cost.
That’s usually where pretty good engineers stop, but they can also tell me about the difference between hash indexes and B-tree indexes, or indexes that can help you with fuzzy searching (gin index using trigrams), or all sorts of other things I don’t know about. Not knowing about different types of indexes isn’t a disqualifier, but it does speak to how much time they’ve spent in databases.
There’s an entire landscape in these types of questions, which makes them great conversation starters. I’ve been searching for something similar on the front end and I think I have a good candidate question—“What is rendering?”
Here is my attempt at answering it.
In the browser
”Rendering” means different things in different contexts. In the browser, it’s the pipeline that takes us from HTML to pixels on the screen. Browsers do this with a “rendering engine”. Chrome has the Blink Rendering Engine, and Safari has Webkit.
At a high level, browser rendering looks like this:
- Parse the HTML and build the DOM
- If it finds a
<script>tag, it pauses HTML parsing and downloads the JS file - It executes the JS file
- If it finds a
- Parse the CSS, build the CSSOM
- It has to parse all of the CSS, because selectors can override each other
- Using the DOM and CSSOM, it builds the render tree
- The render tree, in contrast to the DOM, only captures visible content
- If there’s a
display: none;set on an element, it’s excluded from the render tree
- If there’s a
- It includes information from the DOM and the CSSOM
- The render tree, in contrast to the DOM, only captures visible content
- Determine layout (layout = render tree + device / browser information (e.g. width))
- Actually placing the pixels on the screen, or “painting”.
To learn more about this process:
In the context of React
There’s a catch, though. This is probably not what is meant when you’ve heard the term “rendering”. At least in the parts of software engineering I’m in, “rendering” is usually mentioned in the context of React and the JavaScript ecosystem.
In React’s docs they acknowledge this difference, and call the above process “browser rendering”, and say they will refer to it as “painting” to avoid confusion.
By design, React makes sure this type of rendering happens as little as possible. React maintains a virtual DOM, which is then diffed with the previous version and only the parts that have changed are “re-painted” (read “browser re-rendered”). But we also hear all sorts of strategies to “prevent re-renders” in React, which, if you have browser rendering as your mental model for rendering, and you are aware of the virtual DOM, doesn’t make any sense.
This is because in React, they use the term “rendering” to mean React calling your components. Once it calls your components, it has the new version of the vDOM, which it can then diff against the previous version and “commit” the updates to the actual DOM.
There are 2 moments worth considering—the initial render and subsequent re-renders.
Initial Render
Client Side Rendering
Often, the HTML sent from the server to the browser is an empty shell with a reference to a JavaScript file. As the browser parses the HTML (see “browser rendering”), it will see that <script> tag, fetch the JavaScript and run it. In this example React is contained in that JavaScript, and it will call your components and update the DOM. This is client-side rendering (CSR)—React calling your components exclusively in the browser. All the server did here was serve the files.
CSR is conceptually the simplest way to use React, because there are less moving pieces. It also means the browser will receive data back from the server quickly because it’s doing no processing, which can contribute to a strong Time To First Byte (TTFB).
Of course client-side rendering does have a few problems.
The first problem with CSR is user experience. The first thing a user hits is a loading page. If you’re on your computer in an office, this will be brief and it’s not a big deal. But if you’re in another country, or on a train, or anywhere with a slow network, you’re more likely to have a bad experience, because the way that a client-side rendered page degrades is to simply not work at all.
This negative experience is represented by bad scores in another Core Web Vital, First Contentful Paint. For sites that are really applications, like Datadog or your bank account, this is acceptable. For content sites like blogs or e-commerce storefronts, it’s not.
The second is SEO. Now, Google can see client-side rendered pages, but a client-side rendered page is not optimized for bot traffic. I have no idea how OpenAI and Anthropic have ingested the data of the internet, but it seems reasonable that client-side rendered pages were less easily ingested, if at all.
So what’s are our other options?
Server Side Rendering
How about instead of the initial HTML being a loading page, it is the actual page? This would mean the First Contentful Paint problem that we had with CSR is improved, and users with bad network connections are getting a better experience. (There’s nuance here; this is not always true.). But because our server is no longer dumb—now it has to render the page before it can even respond to the request—our Time to First Byte will be worse, assuming an uncached response.
There’s a bigger problem though. We don’t just need HTML in the browser. We need JavaScript, with state management and event listeners connected to DOM nodes. So even though we already went through the process of rendering on the server, we now have to do it again in the browser.
We call this process of rendering a second time in the browser hydration, where JavaScript attaches event handlers and application state to the DOM generated from original HTML. Before this occurs our page is not interactive—if a button is clicked, nothing happens.
Furthermore, the hydrated DOM has to match the original DOM exactly. This means we need to send the data that was used to render on the server sent with the code to the browser (e.g. __NEXT_DATA__ in Next.js’s Pages Router). This increases the total amount of data that we have to send.
Subsequent Renders
In React, there are a number of things that can cause a component to re-render. The main ones are:
- A component’s state changes
- A component’s prop changes
- A component consumes a context value which changes
- Note that not every child of the context provider re-renders, only the components that use the context
When React calls a component, it has to then recursively call its child components to get the fully updated tree. Here’s an example—when the parent component’s state changes, the child component renders, while the sibling does not.
Rendering, especially the higher up you go in the tree, can be expensive. So it’s a good idea to minimize it. There are a few different strategies to help with that.
The first and most obvious strategy is to keep state as low in the tree as possible. If you have a counter component, the state should be in that component, not inlined into your top level <App> component. We don’t want all of the children of <App> to re-render when the state of the counter changes.
Along the same lines, we should avoid “mega-contexts”. If we have one large context, e.g. AppContext, whenever any piece of it changes, any component that consumes it will re-render. It’s a better idea to have one context per thing, e.g. a ThemeProvider, UserProvider, etc., which makes our context updates, and subsequent re-renders, more surgical.
Now, recall that when a parent renders, its children render again too. This is the opposite of “fine-grained reactivity”. We can improve the performance here by caching the expensive pieces of the render. We can use React.memo to cache an entire component render, or useMemo to cache a computation inside a component. Removing this mental tax from developers is the idea behind React Compiler.