Hello Beautiful People of the Internet! 🚀
I've been on a rollercoaster journey these past few months, building the MVP of my product, Talez. Amidst all the ups and downs, something cool caught my eye: the viral "One Million Checkbox" challenge that's been buzzing online. But there's more to this chaos than just a sea of checkboxes—it’s a fascinating dive into performance optimization and scalability.
I'll link to the original blog at the end so you can see how the author scaled their system for a million users. But for now, I was curious about how One Million Checkboxes would actually behave. I knew there was no way they would render smoothly without some help from those fancy tech terms... wait, what were they called again? (Pausing for effect)… Ah yes, Lazy Loading!
Let's dig into the madness and figure out how we can handle this ocean of checkboxes without losing our sanity—or our app’s performance!
Figuring it Out 🤔:
The first challenge was to figure out how to render thousands of checkboxes without crashing the browser or breaking the code. I knew about concepts like lazy loading and batch rendering, but I soon realized those alone wouldn’t be enough. As seen on One Million Checkbox, the checkboxes render on scroll, leveraging the viewport of the DOM —meaning only the checkboxes visible in the view are rendered.
A bit of research led me to the IntersectionObserver API in JavaScript. This API allows us to observe elements as they enter or exit the viewport, making it perfect for implementing lazy loading of checkboxes. By rendering only the checkboxes that come into view, we can manage performance efficiently and avoid overwhelming the browser.
💡Note :
The original site may have a little different implementation. I might try and explore different way to render checkbox UI using vanila JS, in current implementation of One Million Checkbox there is a way where only selected amount of checkboxes are rendered inside the view port if i remember correctly it is called windowing or virtualization.
Building Blocks in JS for rendering UI Checkboxes:
Let's start by setting up the basic structure using HTML. We don’t need much code here—just a simple layout to get things started. I’ve also added some CSS styles to improve the overall appearance: padding inside the
for better spacing, gaps between the checkboxes for a cleaner look, and a flex-wrap property to ensure the checkboxes stack neatly across multiple lines.Ahoy This is all we need from out HTML code !
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>One Million Checkboxes</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 10px;
}
#checkbox-container {
display: flex;
flex-wrap: wrap;
gap: 10px;
/* Space between each checkbox */
}
</style>
</head>
<body>
<div id="checkbox-container"></div>
<script src="script.js"></script>
</body>
</html>
"Let’s spice up the basic structure with some Vanilla JavaScript (no pun intended!)."
As discussed earlier, we'll be leveraging the IntersectionObserver API, but before diving into it, we need to address potential rendering issues that might arise with such a large number of elements. To handle this efficiently, we'll implement batch rendering of checkboxes. This approach allows us to render checkboxes in manageable batches, gradually building up to our target of one million checkboxes without overwhelming the browser.
1.) Let's start with some dom manipulation's and some declaring global variables first
const container = document.getElementById("checkbox-container");
const checkboxCount = 1000000; // total number of checkboxes we need to render
const batchSize = 1000; // Number of checkboxes to render at once
let lastRenderedIndex = 0; // track for us when we use observer api
The last rendered index refers to the position of the last checkbox that was rendered within the current viewport. As we scroll, we'll dynamically add a new batch of checkboxes based on this index, incrementally increasing the rendered count. We’ll use the IntersectionObserver API to unobserve the previously rendered elements and observe the latest ones.
2.) Now lets implement the batch-rendering function
function createCheckboxesBatch(startIndex, endIndex) {
const fragment = document.createDocumentFragment();
for (let i = startIndex; i < endIndex; i++) {
const checkboxItem = document.createElement("div");
checkboxItem.className = "checkbox-item";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.id = `checkbox-${i}`;
checkbox.name = `checkbox-${i}`;
checkboxItem.appendChild(checkbox);
fragment.appendChild(checkboxItem);
}
container.appendChild(fragment);
}
Here I have added the checkboxes batch function named createCheckboxesBatch
which takes startIndex
and an endIndex
and inside the func we create a fragment from the document
Within the #checkbox-container
div, we start by creating an input element for each checkbox. We set the type attribute to "checkbox" to define it as a checkbox input. Additionally, we assign each checkbox a unique id and name based on its current index number. This approach helps in debugging by making it easy to identify each checkbox by its corresponding index.
and then now we append the checkbox to the checkboxItem to checkbox and than assigning the fragment to the container.
Are you thinking wth createDocumentFragment does in JS DOM?
We create a new empty DocumentFragment, which allows us to build an offscreen DOM tree where DOM nodes can be added without affecting the main document. This fragment is not part of the actual DOM until it's appended, at which point it is replaced by all its child nodes in the DOM tree. This approach improves performance by minimizing reflows and repaints. For more details, you can check the documentation on MDN.
3.) But let move forward where we can actually write the next set of code lines where we write a function checking do we need to load more checkboxes and if yes we ideally change the lastRenderedIndex
with the batchSize.
function loadMoreCheckboxes() {
if (lastRenderedIndex < checkboxCount) {
createCheckboxesBatch(
lastRenderedIndex,
Math.min(lastRenderedIndex + batchSize, checkboxCount)
);
lastRenderedIndex += batchSize;
}
}
4.) Now we shall add the Observer API logic which we intend to implement before
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
loadMoreCheckboxes();
observer.unobserve(entry.target);
// Observe the last child again after loading more checkboxes
if (lastRenderedIndex < checkboxCount) {
observer.observe(container.lastElementChild);
}
}
});
},
{
rootMargin: "100px", // Load more checkboxes just before reaching the end of the container
}
);
The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document's viewport
Code Break Down :/
👺 Creating the IntersectionObserver:
We start by creating an IntersectionObserver
instance, passing it a callback function that runs whenever observed elements enter or exit the viewport. The callback function receives a list of entries
, which represent the observed elements and their intersection status with the viewport.
👺 Processing Each Entry:
For each entry in entries, we check if it is intersecting (i.e., visible within the specified viewport). If entry.isIntersecting
is true, it means the element is in view or close enough based on the root margin.
When an entry intersects, we call loadMoreCheckboxes()
, a function implemented to add a new batch of checkboxes to our container.
👺 Managing Observations:
After loading more checkboxes, we use observer.unobserve(entry.target)
to stop observing the current entry, which prevents repeated calls as the user continues scrolling.
To keep the process going, we then observe the last newly added checkbox using observer.observe(container.lastElementChild)
. This step ensures that as soon as the last checkbox in the current batch comes into view, more checkboxes are loaded, creating a seamless user experience.
👺 Root Margin:
The observer is configured with a rootMargin of "100px", which acts as a buffer zone around the viewport. This means the observer will trigger loadMoreCheckboxes() 100 pixels before the target element actually enters the viewport, ensuring that new checkboxes are loaded just in time to avoid any visible gaps or delays in rendering.
And lastly add the function for observing the last child when the user lands on the page for the first time ever and we start observing lastElement by observer.observe(container.lastElementChild)
// Start by observing the last child
createCheckboxesBatch(0, batchSize);
lastRenderedIndex += batchSize;
observer.observe(container.lastElementChild);
I have curated a JAM link where I added a console with the last rendered index here is the link https://jam.dev/c/623545b7-000b-4318-85bb-4064a723c8d2
So let us run throught the intrusive thoughts of performance with the contributor who used virtual DOM versus when we used IntersectionObserver API.
Windowing, Efficiently renders only the visible portion of large lists, reducing the number of DOM elements and improving performance significantly. Ideal for uniform lists where the size of items is predictable, like tables or grids. But it does Struggles with dynamic or varying content heights, requiring additional logic to handle discrepancies. Less suited for complex interactions or elements outside of simple scrollable views.
In contrast, the IntersectionObserver API offers superior performance for large datasets by rendering only visible elements, thus reducing memory usage and enhancing responsiveness with on-demand loading. However, it is less effective for handling frequent updates or complex interactions.
Here is the blog to the original author's blogpost:
https://eieio.games/nonsense/game-14-one-million-checkboxes/
Thank you for reading. It’s been great exploring these concepts with Vanilla JS. If you like the post drop a like and a comment if you have new way to add these one million checkbox on the UI screen. See you in the next post!