DEV Community

Cover image for The art of Smooth UX : Debouncing and Throttling for a more performant UI
Abhirupa Mitra
Abhirupa Mitra

Posted on

The art of Smooth UX : Debouncing and Throttling for a more performant UI

Github Code Repository

In the fast paced world, most of the jobs we do are on the Web, and fast. Creating a seamless, smooth user experience becomes all the more important. Consumers love a UI which works fast, without lags or delays. Achieving a near perfect experience is possible, although tricky. Have you heard of Event Loops?

In JavaScript, Event loop is a fundamental concept that manages the order of execution of code, collects processes, puts instructions in queued sub-tasks and runs asynchronous operations efficiently. Here's a quick breakdown of how an event loop works:

  • Call stack: All functions, when invoked, are added to this stack, and flow of control returns from the function, it's popped out of the stack
  • Heap: All variables and objects are allocated memory from this heap
  • Queue: A list of messages/ instructions - that get executed one after another

This event loop continuously checks the call stack. Execution of a JavaScript code continues until the call stack is empty.

Event handling is a very crucial part of building JavaScript applications. In such an application we may need to associate multiple events with an UI component.

An image of a Boy thinking

Imagine...

You have a Button in a UI which helps populate a table with the latest news in Sports. Now this requires you to:

  • Click a Button (Associate "click" event handler with a button.
  • Fetch results from an API
  • Parse the output (Json) and display

These 3 processes are chained together in a synchronous manner. Now repeatedly pressing on the Button would mean multiple API calls - resulting in the UI being blocked for quite a couple of seconds - a seemingly laggy User Experience.

This is a good use case for approaches like Debouncing and Throttling. For events like this, that trigger a chain of complex events - we can use such maneuvers to limit the number of times we are calling the API, or in a general sense - limit the rate at which we process an event.

What is Debouncing Versus throttling?

Debouncing: Deferring the execution of a function until a specified cooldown period has elapsed since the last event.

For example:

If we debounce handleOnPressKey() for 2 seconds, it will execute only if the user stops pressing keys for 2 seconds.

Scenario:

  • Initial key press: Start a 2000ms timer to call handleOnPressKey().
  • Subsequent key press within 1000ms: The timer is reset; we wait another 2000ms from this latest key press.
  • No key press for 2000ms: The timer completes, and handleOnPressKey() is called.

Code Snippet:

let debounceTimer; // Timer reference

const handleOnPressKey = () => {
    console.log("Key pressed and debounce period elapsed!");
};

const debouncedKeyPress = () => {
    // Clear any existing timer
    clearTimeout(debounceTimer);

    // Start a new debounce timer
    debounceTimer = setTimeout(() => {
        handleOnPressKey(); // Execute the function after cooldown
    }, 2000); // Cooldown period of 2000ms
};

// Attach debouncedKeyPress to keypress events
document.getElementById("input").addEventListener("keypress", debouncedKeyPress);
Enter fullscreen mode Exit fullscreen mode

Throttling: Ensuring a function is called at most once within a specified time period, regardless of how often the event occurs.

For example:

If we throttle handleOnScroll() with a 2-second interval, the function will execute at most once every 2 seconds, even if the scroll event triggers multiple times within that period.

Scenario:

  • Initial scroll event: handleOnScroll() is called, and a 2000ms cooldown starts.
  • Subsequent scroll events within 2000ms: These are ignored as the cooldown period is active.
  • Scroll event after 2000ms: handleOnScroll() is called again.

Code Example:

let throttleTimer; // Timer reference

const handleOnScroll = () => {
    console.log("Scroll event processed!");
};

const throttledScroll = () => {
    if (!throttleTimer) {
        handleOnScroll(); // Execute the function immediately
        throttleTimer = setTimeout(() => {
            throttleTimer = null; // Reset timer after cooldown
        }, 2000); // Cooldown period of 2000ms
    }
};

// Attach throttledScroll to scroll events
document.addEventListener("scroll", throttledScroll);
Enter fullscreen mode Exit fullscreen mode

Now Let's Build something

This project is a modern To-Do List application designed to explore the concepts of debouncing and throttling in event handling. It features real-time task addition, search functionality powered by Fuse.js, and a dropdown for dynamic suggestions.

UI showing a To-Do app prototype

Let's quickly take a look at the HTML code before jumping on the more critical script.js

We have used TailwindCSS for quick styling. You can check their documentation here Tailwind Documentation - it's massively helpful for making quick prototypes

  • Header: The header contains the title of the page.
  • Input Field: An input field for adding notes, styled with Tailwind CSS.
  • Dropdown for Suggestions: A hidden dropdown that will display suggestions as the user types.
  • Static Task List: A list to display the added tasks.
  • Scripts: Includes the Fuse.js library for fuzzy searching and the script.js file for custom JavaScript logic.
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Event Loop Practice</title>
    <!-- Tailwind CSS CDN -->
    <script src="https://cdn.tailwindcss.com"></script>
    <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
    <style>
        /* Tailwind Extensions (Optional for Customizations) */
        body {
            font-family: 'Inter', sans-serif;
        }
    </style>
</head>
<body class="bg-gray-50 min-h-screen flex items-center justify-center">
    <div class="container max-w-lg bg-white p-8 rounded-lg shadow-lg">
        <!-- Header -->
        <h1 class="text-2xl font-bold text-gray-800 text-center mb-6">Make Notes</h1>

        <!-- Input Field -->
        <div class="relative">
            <input 
                id="input" 
                type="text"
                placeholder="Add your note..."
                class="w-full px-5 py-3 text-gray-700 bg-gray-100 border border-gray-300 rounded-full focus:ring-2 focus:ring-blue-500 focus:outline-none transition-all duration-300"
            />
            <!-- Icon (Optional for Styling) -->
            <div id="submitButton" class="absolute right-4 top-1/2 transform -translate-y-1/2 cursor-pointer" >
                <i class="material-icons text-gray-400 hover:text-blue-500 transition duration-200">send</i>
            </div>
        </div>

        <!-- Dropdown for Suggestions -->
        <ul 
            id="dropdown" 
            class="mt-2 bg-white border border-gray-300 rounded-lg shadow-lg hidden p-4 space-y-2 text-gray-700 text-sm hover:bg-gray-50 transition-all">
        </ul>

        <!-- Static Task List -->
        <div class="mt-8">
            <h2 class="text-lg font-semibold text-gray-800">Static Task List</h2>
            <ul class="mt-4 space-y-2 text-gray-600" id="taskList">
            </ul>
        </div>
    </div>

    <!-- Script -->
    <script src="https://cdn.jsdelivr.net/npm/fuse.js"></script>
    <script src="script.js"></script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Why Use Fuse.js?

Fuse.js is a lightweight, customizable library for fuzzy searching. It handles typos and partial matches, offers high performance for large datasets, and has an intuitive API. This will help enhance your search functionality with flexible, user-friendly search experiences. Additionally, this provides you with a CDN link, so it can work right of the bat, no imports or local storage required.

Now let's Code in the Real Deal - The JS

1. Task Array and Variables

const tasks = new Array (
    "Complete Blog on Throttling + Debouncing",
    "Make a list of 2025 Resolutions",
);
let fuse = undefined;
let debounceTimer;
let throttleTimer;
Enter fullscreen mode Exit fullscreen mode

This section initializes an array of tasks and declares variables for Fuse.js, debounce timer, and throttle timer. We have hardcoded some tasks already - for the sake of this project

Now let's build the onSubmit function. This function will be triggered once the user clicks on the Submit Arrow. It prevents the default form submission, retrieves the input value, clears the input field, adds the new task to the tasks array, and updates the task list.

const onSubmit = (event) => {
    //Prevent default
    event.preventDefault();

    const text = document.getElementById("input").value.trim();
    document.getElementById("input").value = "";
    tasks.push(text);
    updateList();
}
Enter fullscreen mode Exit fullscreen mode

Now we need to ensure that once a task has been submitted, it gets updated in the Task list

const updateList = () => {
    const lists = document.getElementById("taskList");
    lists.innerHTML = "";

    //Loop through all elements in tasks
    tasks.forEach(task => {
        const taskElement = document.createElement("li");
        taskElement.classList.add("flex", "items-center", "space-x-2");

        //Add Bullet Point Element
        const bullet = document.createElement("span");
        bullet.classList.add("h-2", "w-2", "bg-blue-500", "rounded-full");

        //Add Span Tag
        const taskText = document.createElement("span");
        taskText.textContent = task;

        taskElement.appendChild(bullet);
        taskElement.appendChild(taskText);
        lists.appendChild(taskElement);
    })
}
Enter fullscreen mode Exit fullscreen mode

The updateList() function renders the task list by looping through the tasks array and creating list items for each task. Each list item includes a bullet point and the task text.

Now we need to ensure that the list gets update after Page is loaded, the first time. We also would want to initialize Fuse.js on Page load - and associate the tasks array with it. Remember, we would want to render suggestions from this tasks array within the dropdown.

const init = () => {
    console.log("Initializing...");
    //Update and render the list
    updateList();

    //Initialize Fuse with the updated array
    try{
        fuse = new Fuse(tasks, {
            includeScore: true,
            threshold: 0.3 //For sensitivity
        })
    } catch(e) {
        console.log("Error initializing Fuse:"+ fuse);
    }
}
Enter fullscreen mode Exit fullscreen mode
document.addEventListener("DOMContentLoaded", init);
Enter fullscreen mode Exit fullscreen mode

Now we need to ensure that on every 'input' we search through the list to show suggestions in the dropdown. This has 3 parts:

  • Write the Search Logic: searchTasks()
  • Populate the dropdown on every input: updateDropdown()
  • Associate the updateDropdown() to be called on every input (Well atleast for now :-) -> Until we implement the debouncing/ throttling logic)
//Utility function to search within already entered values
const searchTasks = (query) => {
    const result = fuse.search(query);
    const filteredTasks = result.map(result => result.item)
    updateDropdown(filteredTasks);
}
Enter fullscreen mode Exit fullscreen mode
const updateDropdown = (tasks) => {
    const dropdown = document.getElementById("dropdown");
    dropdown.innerHTML = "";

    if(tasks.length === 0) {
        dropdown.style.display = "none";
        return;
    }

    tasks.forEach(task => {
        const listItem = document.createElement("li");
        listItem.textContent = task;
        listItem.addEventListener("click", () => {
            document.getElementById("input").value = task;
            dropdown.style.display = "none";
        })
        dropdown.appendChild(listItem);
    });

    dropdown.style.display = "block";
}
Enter fullscreen mode Exit fullscreen mode
document.getElementById("submitButton").addEventListener("input", () => {
searchTasks(event.target.value)
});
Enter fullscreen mode Exit fullscreen mode

So far: The dropdown list will update everytime you type something - in a more bulky UI we would not want this experience

Updating the dropdown list on every keystroke in a bulky UI can lead to performance issues, causing lag and a poor user experience. Frequent updates can overwhelm the event loop, leading to delays in processing other tasks.

We will now see how we can use Debouncing OR throttling to help manage the frequency of updates, ensuring smoother performance and a more responsive interface.

Here's how we can implement either of the techniques in our note-making project.

Debouncing:

Debouncing ensures that a function is only called after a specified amount of time has passed since the last invocation. This is useful for scenarios like search input fields, where we want to wait for the user to finish typing before making an API call.

Code Snippet:

document.getElementById("input").addEventListener("input", (event) => {
    // Implement Debouncing - wait for 1 second of no input
    clearTimeout(debounceTimer); //debounceTimer is already declared in the beginning
    debounceTimer = setTimeout(() => {
        const query = event.target.value;
        searchTasks(query); // Call search function with the input value
    }, 1000);
});
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • The input event listener is attached to the input field.
  • The clearTimeout function clears any existing debounce timer.
  • The setTimeout function sets a new debounce timer for 1 second. If no input is detected within this period, the searchTasks function is called with the input value.

Throttling (In the same use case) - Use either of the two approaches

let lastCall = 0;  // To track the last time searchTasks was called
document.getElementById("input").addEventListener("input", (event) => {
    const now = Date.now();
    const delay = 1000; // Throttle delay (1 second)

    // If enough time has passed since the last call, run the search
    if (now - lastCall >= delay) {
        const query = event.target.value.trim();
        searchTasks(query); // Call search function with the input value
        lastCall = now; // Update last call time
    }
});
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • let lastCall = 0;: Initializes a variable to track the last time searchTasks was called.
  • document.getElementById("input").addEventListener("input", (event) => { ... });: Attaches an input event listener to the input field.
  • const now = Date.now();: Gets the current time in milliseconds.
  • const delay = 1000;: Sets the throttle delay to 1 second.
  • if (now - lastCall >= delay) { ... }: Checks if enough time has passed since the last call.
    • const query = event.target.value.trim();: Retrieves the trimmed input value.
    • searchTasks(query);: Calls the searchTasks function with the input value.
    • lastCall = now;: Updates the lastCall time to the current time.

However, please note: Throttling is not the best fit for this scenario because it limits the frequency of function execution to a fixed interval, which might not provide the best user experience for real-time search suggestions. Users expect immediate feedback as they type, and throttling can introduce noticeable delays.

Better Use Cases for Throttling

Throttling is more suitable for scenarios where you want to control the rate of event handling to avoid performance issues. Here are some examples:

  • Window Resizing: When a user resizes the browser window, you might want to update the layout or perform calculations. Throttling ensures that these updates happen at a controlled rate, preventing excessive function calls.
  • Scrolling: When handling scroll events, such as loading more content or updating the UI based on scroll position, throttling helps manage the frequency of updates, ensuring smooth performance.
  • API Rate Limiting: When making API calls, throttling can help you stay within rate limits by controlling the frequency of requests.

By using throttling in these scenarios, you can improve performance and ensure a smoother user experience.

Find the Complete Code here

Happy Coding!


Please leave a feedback!

I hope you found this blog helpful! Your feedback is invaluable to me, so please leave your thoughts and suggestions in the comments below.

Feel free to connect with me on LinkedIn for more insights and updates. Let's stay connected and continue to learn and grow together!

Top comments (1)

Collapse
 
mileswk profile image
MilesWK

Hi! This is cool! I made a deployed version of the code here:

debouncing-throttling-in-js.glitch...

Check it out! All code is by you!