Debouncing / Throttling JavaScript events in a Blazor application

 
 
  • Gérald Barré

Debouncing and throttling are used to prevent too many events from being processing. For instance, when a user types text in a search bar, you may want to wait until they stop writing for a few milliseconds before executing the search request. This is what debouncing is made for. When using debounce, you will wait for a certain inactivity time before processing an event.

In Blazor, there are 2 ways to use debounce or throttle. You can debounce or throttle events in .NET or JavaScript. In Blazor WebAssembly, both methods are almost equivalent. However, in Blazor Server, each JS event is sent to the server using the websocket. So, if you filter out events on the server, you will use lots of network resources and server CPU for nothing. In this case, you should better debounce events on the client.

#Throttle / Debounce on the server

To throttle events, you need to wrap the event handler method in a throttle/debounce method. Also, as the events are handled from a timer, you need to use InvokeAsync to switch back to the UI thread and StateHasChanged to indicate the component should be re-rendered.

C#
@page "/"
<h2>Throttle</h2>
<input type="text" @oninput="onInputThrottled" />
Value: @value1

<h2>Debounce</h2>
<input type="text" @oninput="onInputDebounced" />
Value: @value2

@code{
    string value1;
    string value2;

    Action<ChangeEventArgs> onInputDebounced;
    Action<ChangeEventArgs> onInputThrottled;

    protected override void OnInitialized()
    {
        onInputThrottled = ThrottleEvent<ChangeEventArgs>(e => value1 = (string)e.Value, TimeSpan.FromSeconds(1));
        onInputDebounced = DebounceEvent<ChangeEventArgs>(e => value2 = (string)e.Value, TimeSpan.FromSeconds(1));
        base.OnInitialized();
    }

    Action<T> DebounceEvent<T>(Action<T> action, TimeSpan interval)
    {
        return Debounce<T>(arg =>
        {
            InvokeAsync(() =>
            {
                action(arg);
                StateHasChanged();
            });
        }, interval);
    }

    Action<T> ThrottleEvent<T>(Action<T> action, TimeSpan interval)
    {
        return Throttle<T>(arg =>
        {
            InvokeAsync(() =>
            {
                action(arg);
                StateHasChanged();
            });
        }, interval);
    }

    // Debounce and Throttle can be moved to another class
    Action<T> Debounce<T>(Action<T> action, TimeSpan interval)
    {
        if (action == null) throw new ArgumentNullException(nameof(action));

        var last = 0;
        return arg =>
        {
            var current = System.Threading.Interlocked.Increment(ref last);
            Task.Delay(interval).ContinueWith(task =>
            {
                if (current == last)
                {
                    action(arg);
                }
            });
        };
    }

    Action<T> Throttle<T>(Action<T> action, TimeSpan interval)
    {
        if (action == null) throw new ArgumentNullException(nameof(action));

        Task task = null;
        var l = new object();
        T args = default;
        return (T arg) =>
        {
            args = arg;
            if (task != null)
                return;

            lock (l)
            {
                if (task != null)
                    return;

                task = Task.Delay(interval).ContinueWith(t =>
                {
                    action(args);
                    task = null;
                });
            }
        };
    }
}

#Throttle / Debounce on the client

First, you need some JavaScript code to handle events. The idea is to handle the event and redispatch it when needed. You can create a file named wwwroot/events.js with the following content:

JavaScript
export function debounceEvent(htmlElement, eventName, delay) {
    registerEvent(htmlElement, eventName, delay, debounce);
}

export function throttleEvent(htmlElement, eventName, delay) {
    registerEvent(htmlElement, eventName, delay, throttle);
}

function registerEvent(htmlElement, eventName, delay, filterFunction) {
    let raisingEvent = false;
    let eventHandler = filterFunction(function (e) {
        raisingEvent = true;
        try {
            htmlElement.dispatchEvent(e);
        } finally {
            raisingEvent = false;
        }
    }, delay);

    htmlElement.addEventListener(eventName, e => {
        if (!raisingEvent) {
            e.stopImmediatePropagation();
            eventHandler(e);
        }
    });
}

function debounce(func, wait) {
    let timer;
    return (...args) => {
        clearTimeout(timer);
        timer = setTimeout(() => { func.apply(this, args); }, wait);
    };
}

function throttle(func, wait) {
    var context, args, result;
    var timeout = null;
    var previous = 0;
    var later = function () {
        previous = Date.now();
        timeout = null;
        result = func.apply(context, args);
        if (!timeout) context = args = null;
    };
    return function () {
        var now = Date.now();
        if (!previous) previous = now;
        var remaining = wait - (now - previous);
        context = this;
        args = arguments;

        if (!timeout) {
            timeout = setTimeout(later, remaining);
        }
        return result;
    };
};

Then, you can create a C# file with 2 extensions methods to call the previous JS functions:

C#
public static class EventExtensions
{
    public static async Task DebounceEvent(this IJSRuntime jsRuntime, ElementReference element, string eventName, TimeSpan delay)
    {
        await using var module = await jsRuntime.InvokeAsync<IJSObjectReference>("import", "./events.js");
        await module.InvokeVoidAsync("debounceEvent", element, eventName, (long)delay.TotalMilliseconds);
    }

    public static async Task ThrottleEvent(this IJSRuntime jsRuntime, ElementReference element, string eventName, TimeSpan delay)
    {
        await using var module = await jsRuntime.InvokeAsync<IJSObjectReference>("import", "./events.js");
        await module.InvokeVoidAsync("throttleEvent", element, eventName, (long)delay.TotalMilliseconds);
    }
}

Finally, you can use the code in the views:

Razor
@page "/"
@inject IJSRuntime JSRuntime

<h2>Throttle</h2>
<input @ref="throttledInput" type="text" @bind="value1" @bind:event="oninput" />
Value: @value1

<h2>Debounce</h2>
<input @ref="debouncedInput" type="text" @bind="value2" @bind:event="oninput" />
Value: @value2

@code {
    ElementReference throttledInput;
    ElementReference debouncedInput;

    string value1;
    string value2;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            await JSRuntime.ThrottleEvent(throttledInput, "input", TimeSpan.FromMilliseconds(500));
            await JSRuntime.DebounceEvent(debouncedInput, "input", TimeSpan.FromMilliseconds(500));
        }
    }
}

#Additional resources

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?Buy Me A Coffee💖 Sponsor on GitHub