Debounce and throttle ActiveJob easiest way

# frozen_string_literal: true

# == Example:
# class DummyJob < ApplicationJob
# debounce # default 60 seconds
# throttle # default 60 seconds
# debounce duration: 10 # seconds
# throttle duration: 10 # seconds
# end
# DummyJob.perform_debounce(args) # only debounce rule works
# DummyJob.perform_throttle(args) # only throttle rule works
# DummyJob.perform_now # won't use any debounce or throttle rules
# DummyJob.perform_later # won't use any debounce or throttle rules
class ApplicationJob < ActiveJob::Base
BUFFER = 1 # second.
DEFAULT_DELAY = 60 # seconds

class_attribute :debounce_settings
class_attribute :throttle_settings

around_perform do |job, block|
options = job.arguments.extract_options!
throttle_enabled = options.delete(:throttle)
debounce_enabled = options.delete(:debounce)
job.arguments.concat(Array.wrap(options)) if options.present? # some arguments is key value format

if throttle_settings && throttle_enabled
cache_key = self.class.key(*job.arguments)
expires_in = (throttle_settings[:duration] || DEFAULT_DELAY).seconds
Rails.cache.fetch(cache_key, expires_in: expires_in) { }
elsif debounce_settings && debounce_enabled if perform?(*job.arguments)

class << self
def debounce(*args)
self.debounce_settings = args.extract_options!

def throttle(*args)
self.throttle_settings = args.extract_options!

def perform_throttle(*params)
params.push({ throttle: true })


def perform_debounce(*params)
# Refresh the timestamp in redis with debounce delay added.
delay = debounce_settings[:duration] || DEFAULT_DELAY
Redis.current.set(key(params), now + delay)

# Schedule the job with not only debounce delay added, but also BUFFER.
# BUFFER helps prevent race condition between this line and the one above.
params.push({ debounce: true })
set(wait_until: now + delay + BUFFER).perform_later(*params)

# e.g.
# "ElasticsearchIndexJob:gid://umami/Reservation/24242, update"
def key(params)
params_key = Array.wrap(params).map do |param|
param.try(:to_global_id) || param
end.join(", ")


def now

def perform?(*params)
# Only the last job should come after the timestamp.
timestamp = Redis.current.get(self.class.key(params))
# But because of BUFFER, there could be mulitple last jobs enqueued within
# the span of BUFFER. The first one will clear the timestamp, and the rest
# will skip when they see that the timestamp is gone.
return false if timestamp.nil?
return false if < timestamp.to_i

# Avoid race condition, only the first one del return 1, others are 0
Redis.current.del(self.class.key(params)) == 1



Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
ilake Chang

ilake Chang

Senior Rails developer, full stack developer, react-native junior, flutter player.