惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

云风的 BLOG
云风的 BLOG
博客园 - 三生石上(FineUI控件)
WordPress大学
WordPress大学
F
Fortinet All Blogs
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
博客园 - 叶小钗
爱范儿
爱范儿
美团技术团队
H
Hackread – Cybersecurity News, Data Breaches, AI and More
有赞技术团队
有赞技术团队
博客园_首页
T
The Blog of Author Tim Ferriss
T
Tailwind CSS Blog
V
Visual Studio Blog
Jina AI
Jina AI
博客园 - Franky
量子位
MongoDB | Blog
MongoDB | Blog
L
LangChain Blog
Apple Machine Learning Research
Apple Machine Learning Research
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
U
Unit 42
aimingoo的专栏
aimingoo的专栏
M
MIT News - Artificial intelligence

dimden

Japan Spring 2026 WebTiles: It's fine to accept user-supplied code, actually Eastern Europe Trip (Oct 2025) Custom profile CSS in OldTwitter Making Tetris in Twitter with Node.js brainfuck.js - DIMDEN The Puzzle 3 and stuff Multi Line Task∞: Hello World chip8js - my first emulator Hello World! - DIMDEN
Optimizing rendering of 100,000+ HTML nodes
dimden · 2024-04-04 · via dimden

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.