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




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

Love podcasts or audiobooks? Learn on the go with our new app.

Recommended from Medium

TweetDeck (for Mac) is dead. Here are some alternatives [Latest 2022]

Elasticsearch is a distributed, RESTful and analytics search engine capable of solving a wide…

A Data Engineer’s Cheat Sheet on Pandas and Jupyter-Notebooks

How to Learn C++: The Variable Swap Template

The biggest challenge for Agile is no longer establishing SCRUM teams or creating working software…

Konomi Network Staking Announcement on

Hack The Box Writeup- Bypass

Iris Recognition Matlab Source Code Free Download

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.

More from Medium

Serverless Offline causes MongoDb Atlas to run out of connections

Setting up Docker and debugging inside the Docker container — System Design

Using Cypress Intercept to Fix a Cross-Domain Test

Securing AWS HTTP API with Asgardeo