ruby 92 lines · 3 tabs

Debouncing Sidekiq Jobs Per-Record With Redis So Rapid Updates Coalesce Into One Run

Shared by codesnips Jul 2026
3 tabs
class DebouncedReindexJob
  include Sidekiq::Job

  sidekiq_options queue: :indexing, retry: 5

  DEBOUNCE_DELAY = 5 # seconds

  def self.debounce(record, delay: DEBOUNCE_DELAY)
    token = SecureRandom.hex(8)
    key = redis_key(record.class.name, record.id)

    Sidekiq.redis do |conn|
      conn.set(key, token, ex: delay + 30)
    end

    perform_in(delay, record.class.name, record.id, token)
  end

  def self.redis_key(klass, id)
    "debounce:reindex:#{klass}:#{id}"
  end

  def perform(klass, id, token)
    key = self.class.redis_key(klass, id)

    current = Sidekiq.redis { |conn| conn.get(key) }
    return if current != token # a newer update superseded this run

    record = klass.constantize.find_by(id: id)
    return unless record

    record.reindex!

    # Only clear the key if we are still the winning token.
    Sidekiq.redis do |conn|
      conn.watch(key)
      if conn.get(key) == token
        conn.multi { |m| m.del(key) }
      else
        conn.unwatch
      end
    end
  end
end
3 files · ruby Explain with highlit

This snippet shows how to collapse a burst of updates to a single record into exactly one background run using Sidekiq, scheduled jobs, and a small Redis token. The problem it solves is a classic write-amplification issue: a record like a Document may be touched many times in a second (autosave, collaborative edits, bulk imports), and each change would normally enqueue an expensive reindex job. Running that job dozens of times is wasteful, and the intermediate runs are throwaway work — only the final state matters.

The debounce strategy here is trailing: after the last change in a window, one run fires. In DebouncedReindexJob, debounce writes a fresh random token into a per-record Redis key with a short TTL and schedules the job with perform_in(delay), passing that token along. When the job later executes, perform compares the token it was given against whatever is currently stored under the key. If a newer debounce call happened in the meantime, the stored token will have changed and this run bails out early — only the last scheduled run sees a matching token and does the real work in reindex!.

Using a token rather than just checking key existence avoids a subtle race: two overlapping windows could otherwise both believe they are the winner. The token acts as a compare-and-check guard so exactly one run proceeds. The Redis key's TTL is set slightly longer than the debounce delay so it survives until the scheduled job runs but self-cleans if the process dies.

The Reindexable concern wires this into ActiveRecord: an after_commit callback calls DebouncedReindexJob.debounce(self), so debouncing is triggered on the committed state, never inside an open transaction where the row might not yet be visible. The trade-off is added latency — results lag by the debounce window — and reliance on Redis as coordination state. It is ideal for idempotent, recompute-from-current-state work (search indexing, cache warming, denormalized counters) and a poor fit for jobs that must observe every individual event. Developers reach for this whenever enqueue volume, not correctness, is the bottleneck.


Related snips

ruby
class CommentsController < ApplicationController
  before_action :set_post

  def create
    @comment = @post.comments.build(comment_params)

System test: asserting Turbo Stream responses

rails hotwire turbo
by codesnips 4 tabs
ruby
class Post < ApplicationRecord
  belongs_to :author, class_name: 'User'
  has_many :comments, dependent: :destroy

  scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
  scope :draft, -> { where(published_at: nil) }

ActiveRecord scopes for reusable query logic

rails activerecord patterns
by Alex Kumar 1 tab
ruby
module Api
  module V1
    class UsersController < BaseController
      def show
        user = User.includes(:profile).find(params[:id])

ETags for conditional requests and caching

rails caching http-caching
by Alex Kumar 1 tab
ruby
class PostsController < ApplicationController
  def index
    @posts = Post.includes(:author)
                 .order(created_at: :desc)
                 .page(params[:page])
                 .per(10)

Turbo Frames: infinite scroll with lazy-loading frame

rails turbo hotwire
by codesnips 4 tabs
ruby
require "csv"

class PeopleCsvStream
  include Enumerable

  HEADERS = %w[id full_name email signed_up_at plan].freeze

Resilient CSV Export as a Streamed Response

rails performance streaming
by codesnips 3 tabs
erb
<form data-controller="query-sync" data-action="change->query-sync#apply">
  <select name="status" class="rounded border p-2">
    <option value="">Any</option>
    <option value="open">Open</option>
    <option value="closed">Closed</option>
  </select>

Filter UI that syncs query params via Stimulus (no front-end router)

rails hotwire stimulus
by Henry Kim 2 tabs

Share this code

Here's the card — post it anywhere.

Debouncing Sidekiq Jobs Per-Record With Redis So Rapid Updates Coalesce Into One Run — share card
Link copied