Debouncing

Debouncing is a powerful tool that should always be considered when juggling between responsiveness and executing an expensive operation that can potentially be triggered multiple times by the user.

When building User interfaces for mobile, we constantly have to juggle responsiveness of the app and actual work that needs to be performed as a result of a particular user interaction. An example of this is a search functionality that queries a remote server, and displays the results in a table view. In this post, we will use the concept of a debouncer to keep the app responsive, while ensuring that we make as few requests as required to retrieve up to date search results for the user.

What is Debouncing?

Debouncing refers to the means of limiting the frequency of execution of a function. This is usually used when the function is question has a high cost of execution attributed to it. A debouncer therefore is an abstraction that handles debouncing a particular function. It is made up of two components, a delay/timeout and the function to be invoked after the delay (the debounced function). Let's see the representation in code below:

class Debouncer {
    private let delay: TimeInterval
    private var timer: Timer?

    var handler: () -> Void

    init(delay: TimeInterval, handler: @escaping () -> Void) {
        self.delay = delay
        self.handler = handler
    }

    func call() {
        timer?.invalidate()
        timer = Timer.scheduledTimer(withTimeInterval: delay,
                                     repeats: false) { [weak self] _ in
            self?.handler()
        }
    }

    func invalidate() {
        timer?.invalidate()
        timer = nil
    }
}

In the code above, the call function can be invoked multiple times, but the actual handler will not be executed until the duration of the delay duration is exceeded. As a preference, I've opted to make the handler mutable so that we can easily update the underlying debounced function without having to create a new debouncer. We can now use this to implement our aforementioned search functionality.

Responsive & Efficient Searching

Let's imagine we have a search service that has the following API:

protocol SearchService {
    func search(query: String, callback: @escaping ([String]) -> Void)
}

This service is the abstraction in charge of querying our remote service with the search query, and returning the results in a callback (We will assume our result type to be a String).

When the user is typing in the search field, we don't want to send requests for each time the text changes as this could be very wasteful. We can use our Debouncer defined above to limit the frequency at which we invoke the search API.

class SearchViewController: UIViewController {
    private enum Constants {
        static let searchDelay: TimeInterval = 1.0
    }

    ...
    var searchService: SearchService!
    private var searchDebouncer: Debouncer?

    override func viewDidLoad() {
        super.viewDidLoad()
        ...
        searchTextField
            .addTarget(self,
                       action: #selector(textFieldDidChange),
                       for: .editingChanged)
    }

    @objc func textFieldDidChange(sender: UITextField) {
        guard let searchText = sender.text else {
            return
        }

        var debounceHandler: () -> Void = { [weak self] in
            self?.searchService.search(query: searchText) { results in
                self?.update(withSearchResults: results)
            }
        }

        guard let searchDebouncer = self.searchDebouncer else {
            // setup debouncer
            let debouncer = Debouncer(delay: Constants.searchDelay,
                                      handler: {})
            self.searchDebouncer = debouncer

            textFieldDidChange(sender: sender)
            return
        }

        // invalidate the current debouncer so that it
        // doesn't execute the old handler
        searchDebouncer.invalidate()
        searchDebouncer.handler = debounceHandler
        searchDebouncer.call()
    }

    private func update(withSearchResults results: [String]) {
        // update view controller with new results from last search
    }
}

In our textFieldDidChange, we first initialize the searchDebouncer if one didn't exist already using a guard let syntax. Next, we then invalidate the current debouncer, preventing it from firing the old handler while we are in the middle of updating it. Then, we update then debouncer's handler with a new handler that will invoke the search service with the new value of the searchTextfield. Finally, we invoke the call() method on the debouncer which will schedule the handler to be invoked after the user stops typing for 1 second. 🚀

Conclusion

Using a debouncer, we were able to implement an efficient search functionality. The debouncer is a powerful tool that should always be considered when juggling responsiveness and executing an expensive operation that the user can trigger multiple times. I hope this article helps you understand the concept of debouncing and how it can be used in iOS.

Thanks for taking the time 🙏🏿

Find me on twitter or contact me if you have any questions or suggestions.