How to do debouncing?

TL;DR

Sorry this is long: wanted to explain my approach and reasoning.
You could just skip down to My Elm Code.

Background

My father has some hand tremor. Despite it being quite mild, it nevertheless can cause him frustration when using a touch interface. Hence I decided that my apps would have button debouncing baked in (and user controllable).

Definitions

I have seen much confusion online between debouncing and throttling.

Throttling: As with the accelerator on early internal combustion cars ā€” otherwise known as the throttle ā€” throttling is the process of holding back a stream; of reducing itā€™s flow rate. For example, there are throttling apps for Twitter that space out the tweets of prolific tweeters. But, eventually, everything that approaches a throttle goes through.

@Laurent demonstrated a neat way of throttling/delaying with
Task.perform (always msg) (Process.sleep 1000)

Debouncing: Originally, I believe, developed in the early days of electronics, when people were trying to mix electro-mechanical switches with electronic circuits. When such a switch is closed, to an unaided observer the contacts seem to close immediately. However, if viewed on high speed video slowed down, the moving contact can be seen to bounce repeatedly like a basket ball thatā€™s been dropped to the floor. Thus sending multiple pulses when only one was required. Debouncing is the process of turning multiple inputs into just one. Anyone who has done an electronics course will have come across the Set-Reset circuit which can be used to solve this.

My approach to debouncing in Elm

Analogy: Imagine an old telegraph office where each user is rationed to one message per day. When someone asks to send a message, the clerk looks at a row of tags. If the userā€™s tag is there, the request is declined. If the tag is not there, the request is accepted, the userā€™s tag is hung on the row, and a 24 hour timer is started. When the timer has finished, it automatically flips the tag off of the row.

So, in Elm, the row of tags is a List of Messages.
There are three relevant branches in update:

  • Debounce message ->
    Check if model.debounceList contains message
    If it does: do nothing
    If it does not: add the message to the debounce list, pass the message, wait for the debounce duration, remove the message from the list

  • PassMessage message _ ->
    Simply call update message model

  • PruneDebounceList message ->
    Remove message from model.debounceList

My Elm Code (just the relevant section of update)

Debounce message -> 
   case List.member message debounceList of 
      True ->  -- message is in debounce list, quietly drop message
         ( model, Cmd.none )
      False ->  -- add message to list, pass message, time, then prune 
         (  { model | debounceList = message :: debounceList }
         ,  Cmd.batch
               [  Task.perform (PassMessage message) Time.now   -- <--  
               ,  Task.perform
                     PruneDebounceList
                     (     Process.sleep (toFloat debounceDuration)
                        |> Task.andThen (\_ -> Task.succeed message)
                     )  
               ]  
         )  

PassMessage message _ -> 
   update message model  

PruneDebounceList message -> 
   let
      newDebounceList = List.filter (\m -> m /= message) model.debounceList
   in 
      (  { model | debounceList = newDebounceList }, Cmd.none )

Two questions

  • Is there a better way to do this? Is this a ā€˜badā€™ approach?
    Please interpret ā€˜betterā€™ any which way: more efficient / simpler / more robust / ā€¦

  • Is there a way to avoid the ā€˜Time.nowā€™ fudge?
    Task.perform (PassMessage message) Time.now -- <--
    Time.now is there only because Task.perform requires a Task.

4 Likes

Thinking out loud here. Would it be possible to solve the problem on the JavaScript layer and make it transparent to the elm code? Maybe some kind of wrapper. Maybe it could even work with other technologies!

Edit: or even better, a plug-in for the browser to make it work on all websites?

1 Like

Making sure I understand the goal. You want it so that if your dad (or anyone in general) presses a button too many times, only the first press is accepted and subsequent presses are rejected. E.g. if I have a button that submits a payment, you only want the payment to be submitted once.


If this is the case, then what youā€™re looking for is not necessarily debouncing but idempotency. I understand the definitions you gave, however I donā€™t think you want to prevent the messages from going through so much as filter out unwanted inputs, which is what your example shows. Following my very brief example of submitting a payment, you donā€™t want to stop someone from pressing the ā€œSubmit Paymentā€ button, but you do want to prevent it from calculating multiple payments. The typical way I see this done is by attaching unique ids to events. So a Submit event might become Submit Id and if Id is already being processed, then reject duplicates submissions.


The issue I see with the current debounce approach you have is that identical looking, yet still unique, events would be removed.

If we use the minimal counter app as an example, if I press the Increment button 5 times, I want it to increment 5 times. If your father presses the Increment button 5 times, he may have only wanted to press it 2-3 times. We need a way to differentiate between my multiple presses and your fathers.

You may want to add idempotency to your debouncing approach so that you can differentiate messages being truly identical vs similar.


To @marciofrayzeā€™s point, I wonder if thereā€™s a browser level option for something like this. It definitely seems like an accessibility issue and typically those are handled at the browser or OS level. If not thatā€™s unfortunate, maybe it can be brought up to someone in those domains to help get better support.

2 Likes

A good definition of throttle vs debounce:

With throttle we slow down function calls as they happen, with debounce we donā€™t fire at all until the user has stopped calling it.

My example below actually is not a way to throttle but a way to debounce :wink:
Itā€™s debouncing because of the if condition that calls the costly command only if the input is unchanged after the 1000 ms delay, meaning the user stopped typing:

delayMsg : msg -> Cmd msg
delayMsg msg =
    Task.perform (always msg) (Process.sleep 1000)

-- in your update cases:

GotFieldInput fieldInput ->
    ( { model | fieldInput = fieldInput }
    , delayMsg (GotDelayedFieldInput fieldInput)
    )

GotDelayedFieldInput fieldInput ->
    if fieldInput == model.fieldInput then
        ( { model | status = Loading }, costlyCommand fieldInput )
    else
        ( model, Cmd.none )

So even if you type for one hour in the input field (without pause), only one command call will occur after you stopped typing (no input changes for 1000 ms). That matches the definition of debouncing.

To be complete, an edge case could occur when the user presses the backspace key after a previous character and before pressing another character, all of this within a 1000 ms delay. In that case the command could be called but that should not be a problem for something triggered at key strokes :wink: If thatā€™s a problem then you should definitively redesign your command for better performance or cost.

2 Likes

Many thanks for the replies.


@marciofrayze An interesting thought, but Iā€™d rather do it inside Elm if possible :slight_smile:


@wolfadex Yes, youā€™ve understood my goal.

I need to revise my definitions:

Wikipedia:

  • Idempotence is the property of certain operations in mathematics and computer science whereby they can be applied multiple times without changing the result

  • debouncing: In digital systems, multiple samples of the contact state can be taken at a low rate and examined for a steady sequence, so that contacts can settle before the contact level is considered reliable and acted upon. Bounce in SPDT switch contacts signals can be filtered out using a SR flip-flop (latch) or Schmitt trigger. All of these methods are referred to as ā€˜debouncingā€™.

The definition of debouncing seems slightly at odds with itself: ā€˜examined for a steady sequenceā€™ (I imagine this means a steady-state) doesnā€™t match the action of the ā€˜SR flip-flopā€™ which latches to ā€˜Setā€™ on the first input.

It seems like we have the option of both

  • steady-state debouncing
    and
  • idempotent debouncing

I need to cover the bi-stable case (toggle buttons) ā€” those that flip back-and-forth between two states. Very frustrating for a user with a tremor who taps twice almost every time. idempotency isnā€™t quite what is wanted, as there is no steady-state after just one press.

Is there a term for grabbing the first input and discarding the rest ā€” the software equivalent of the Set-Reset circuit, but with an added timer to trigger the Reset? This is what I need and, unless Iā€™ve made a mistake, what my code does.

@Laurent it seems like your code is doing something like the converse: ignoring all inputs until a long enough delay.

Apologies for mis-representing your code, I was specifically referring to that one line. Although I can now see that it doesnā€™t as-and-of-itself throttle; on itā€™s own it would merely delay the entire stream. Your solution to the costly-command problem is neat ā€” I wish the Twitter app would use it!

1 Like

I donā€™t know if thereā€™s a term for what youā€™re looking for, as Iā€™m quite bad at knowing technical terms, but the most common way Iā€™m aware of this being done is with an idempotency key of some kind. What do you mean by ā€œthere is no stead-state after just one pressā€?

A key would be good in your Submit Payment example. Not sure how it would work for ordinary buttons though. Perhaps thereā€™s a use for the Time.now Posix value after all :wink:
Task.perform (PassMessage message) Time.now (see original post above, question 2).

My plan to address this is simply to allow the user to reduce (all the way to zero) the ā€˜debounce durationā€™ in the appā€™s Settings.

Simply that a toggle switch isnā€™t idempotent; subsequent presses do change the value.

Come to think of it, neither are the buttons in the counter app.

Iā€™m also concerned about the situation where pressing a button causes a new page or modal to pop up which has a button at the same x,y location: first tap pops up the modal, second tap activates an unwanted choice in the modal :person_facepalming: Currently Iā€™m using the same ā€˜debounce durationā€™ delay to mitigate this as well.

1 Like

@AlanQ Thereā€™s a simpler solution. Check out this Ellie. I started with the counter example and modified it so that it works in the way you want.

Hereā€™s the main abstraction extracted into a module:

module SetAndReset exposing (SetAndReset, init, isUnset, setAndScheduleReset)

import Process
import Task

type SetAndReset
  = SetAndReset Bool

init : SetAndReset
init =
    SetAndReset False

isUnset : SetAndReset -> Bool
isUnset (SetAndReset first) =
    not first

setAndScheduleReset : 
    { duration : Float
    , onReset : msg
    }
    -> SetAndReset
    -> (SetAndReset, Cmd msg)
setAndScheduleReset { duration, onReset } =
    ( SetAndReset True
    , Process.sleep duration
        |> Task.perform (always onReset)
    )
2 Likes

Here is how I would do it.

In essence, you need to store the message and some information about the time it happened. You would then need to periodically test if the wait time has passed and you also need to overwrite the start time on new events.

The implementation tries to stay simple and so, instead of using Time.now and extra messages I just use 0 as a convention for now.

Of course, this naive implementation does not support debouncing multiple messages at the same time but that would be easy to implement by moving from Maybe to a Dict where you would have keys for each message you would want to debounce independently.

LATER EDIT: Multiple Debounce

3 Likes

@dwayne thank you. That looks interesting and seems to work well. Especially as it can be a module :slight_smile: Still investigatingā€¦

@pdamoc thank you, also. I havenā€™t checked out your code in detail as the Ellie doesnā€™t seem to be doing what it should: after the first click Iā€™m getting a delay before the counter changes. I need an immediate response to the first click.

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.