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
module Reindexable
extend ActiveSupport::Concern
included do
after_commit :enqueue_debounced_reindex, on: [:create, :update]
end
def reindex!
SearchIndex.upsert(
id: search_document_id,
type: self.class.name,
body: as_indexed_json
)
end
private
def enqueue_debounced_reindex
return if destroyed?
DebouncedReindexJob.debounce(self)
end
def search_document_id
"#{self.class.name.underscore}_#{id}"
end
end
class Document < ApplicationRecord
include Reindexable
belongs_to :author, class_name: "User"
has_many :revisions, dependent: :destroy
validates :title, presence: true
def as_indexed_json
{
title: title,
body: body.to_plain_text,
author: author.name,
updated_at: updated_at.iso8601,
tags: tag_list
}
end
def tag_list
(tags || "").split(",").map(&:strip).reject(&:blank?)
end
end
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
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
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
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
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
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<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)
Share this code
Here's the card — post it anywhere.