Optimizing rendering of 100,000+ HTML nodes

Optimizing rendering of 100,000+ HTML nodes

Posted on Thursday, April 4th, 2024

TLDR: Optimize rendering by not rendering at all.

While working on Whispy, I've noticed that navigation between pages is quite slow. We use Svelte and SvelteKit as frontend, so on every navigation, the page recreated from scratch and it has to rerender all nodes (elements) again.

Initially, I didn't know what exactly was causing the slowness, so I started looking around in DevTools. In Performance monitor tab it became clear what's the issue:

DOM Nodes: 121,081

Because every post component is quite complex and has a lot of stuff, with just 500 posts rendered it already amounts to 100,000+ nodes (about 200 nodes per post, keep in mind that 'node' is different from 'element' - node can be text node, comment node, etc.).

While thinking on how to solve it, I got a simple idea: why not just render the posts that are visible on the screen and render the rest as empty divs with rendered height? You might be wondering what's the point of preserving the height of the post if it's not rendered: it's important to do it so the scroll restoration works correctly and you end up at the same position where you was before navigating to another page. And then as you scroll close to unrendered posts you replace empty divs with actual content. This way we can reduce the number of nodes rendered at once and unblock the main thread quickly.

Biggest problem is that every post has different height, so it's not possible to just set fixed height for every element like virtual list libraries do. So instead I've decided to first always render posts when they appear for the first time, and then save their height into an object using ResizeObserver.

Following code is written with SvelteKit, but it the idea can be easily ported to any other framework or vanilla JavaScript.

This is the component I ended up with:

		
<script>
	import { navigating } from '$app/stores';
	import { tick, onDestroy } from 'svelte';
	import { onResize, onVisibility } from '$lib/actions'; // explanation below
	import { postHeights } from '$lib/stores'; // export const postHeights = {};
	import { browser } from '$app/environment';

	export let id = null; // post id

	let visible = false;
	let el;

	function resized(entry) {
		// save the height of the post
		postHeights[id] = entry.contentRect.height;
	}

	function onvisible() {
		// this fires when post is within 1500 pixels of the viewport
		if (!visible) {
			visible = true;
		}
	}

	if (id && browser) {
		onDestroy(() => {
			// resize observer doesnt always fire, so we need to save the height when the component is destroyed
			if (!postHeights[id] && el) {
				postHeights[id] = el.getBoundingClientRect().height;
			}
		});
	}

	// on navigation
	$: if (!$navigating) {
		// if no height stored then its rendering for the first time and should be always visible
		if (!postHeights[id]) {
			visible = true;
		} else {
			// wait for slot to render
			tick().then(() => {
				if (!el) return (visible = true);
				// check if post is within 3000 pixels of the viewport and set visible to true
				let rect = el.getBoundingClientRect();
				if (rect.top < window.innerHeight + 3000 && rect.bottom > -3000) {
					visible = true;
				}
			});
		}
	}
</script>

<div
	class="container"
	bind:this={el}
	use:onResize={resized}
	use:onVisibility={onvisible}
	style:height={!visible && postHeights[id] ? postHeights[id] + 'px' : null}
>
	{#if visible || !id}
		<!-- only render content when 'visible' is set to true -->
		<slot />
	{/if}
</div>
		
	

You can see onResize and onVisibility used in the component. These are Svelte actions that I've created to handle ResizeObserver and IntersectionObserver. To not create a new observers for every post, There's a single ResizeObserver and IntersectionObserver that are used for all posts. Implementation of these actions looks like this:

		
import { browser } from '$app/environment';

// visibility //
function isNearViewport(entry, pixels) {
	const { top, bottom } = entry.boundingClientRect;
	// within pixels of the viewport
	return (
		(top >= -pixels && top <= window.innerHeight + pixels) ||
		(bottom >= -pixels && bottom <= window.innerHeight + pixels)
	);
}

// weakmaps are cool because you can use elements as keys and map them into their callbacks
const observeCallbacks = new WeakMap();
const intersectionObserver =
	browser &&
	new IntersectionObserver((entries) => {
		entries.forEach((entry) => {
			// if the element is in the viewport or near it
			if (entry.isIntersecting || isNearViewport(entry, 1500)) {
				const cb = observeCallbacks.get(entry.target);
				if (cb) {
					cb();
					intersectionObserver.unobserve(entry.target);
				}
			}
		});
	});

export function onVisibility(node, cb) {
	observeCallbacks.set(node, cb);
	intersectionObserver.observe(node);

	return {
		destroy() {
			if (observeCallbacks.has(node)) {
				observeCallbacks.delete(node);
				intersectionObserver.unobserve(node);
			}
		},
	};
}

// size //
const resizeCallbacks = new WeakMap();
const resizeObserver =
	browser &&
	new ResizeObserver((entries) => {
		entries.forEach((entry) => {
			const cb = resizeCallbacks.get(entry.target);
			if (cb) {
				cb(entry);
			}
		});
	});
export function onResize(node, cb) {
	resizeCallbacks.set(node, cb);
	resizeObserver.observe(node);

	return {
		destroy() {
			if (resizeCallbacks.has(node)) {
				resizeCallbacks.delete(node);
				resizeObserver.unobserve(node);
			}
		},
	};
}
		
	

Now with this optimization you can see how much faster the page loads:

That's it! Very simple optimization yet it makes a huge difference in performance. While user scrolls, it only needs to render a bunch of posts and not 500 posts all at once. Easiest way to optimize rendering is to not render at all.