class CounterBuffer
DELTA_HASH = "counter:deltas".freeze
READ_RESET = <<~LUA.freeze
local v = redis.call('HGET', KEYS[1], ARGV[1])
if v then redis.call('HDEL', KEYS[1], ARGV[1]) end
return v
LUA
def self.redis
@redis ||= Redis.new(url: ENV.fetch("COUNTER_REDIS_URL"))
end
def self.increment(key, by = 1)
redis.hincrby(DELTA_HASH, key, by)
end
def self.flush_key(key)
raw = redis.eval(READ_RESET, keys: [DELTA_HASH], argv: [key])
raw.to_i
end
def self.debounce_lock(key, ttl)
# SET NX returns true only for the first caller in the window.
redis.set("counter:lock:#{key}", 1, nx: true, ex: ttl)
end
def self.clear_lock(key)
redis.del("counter:lock:#{key}")
end
end
class Post < ApplicationRecord
has_many :comments, dependent: :destroy
DEBOUNCE_WINDOW = 5.seconds
def bump_comment_count(by = 1)
key = counter_key
CounterBuffer.increment(key, by)
schedule_flush(key)
end
def counter_key
"post:#{id}:comments_count"
end
private
def schedule_flush(key)
return unless CounterBuffer.debounce_lock(key, DEBOUNCE_WINDOW.to_i + 2)
CounterFlushJob.set(wait: DEBOUNCE_WINDOW)
.perform_later(self.class.name, id, key)
end
end
class CounterFlushJob < ApplicationJob
queue_as :counters
def perform(model_name, record_id, key)
delta = CounterBuffer.flush_key(key)
if delta.nonzero?
klass = model_name.constantize
klass.where(id: record_id)
.update_all(["comments_count = comments_count + ?", delta])
end
ensure
CounterBuffer.clear_lock(key)
end
end
Counter caches keep an aggregate count (like a post's comment count) denormalized on a row so reads avoid an expensive COUNT(*). Rails' built-in counter_cache works well until writes get hot: every insert and delete fires a synchronous UPDATE on the parent row, creating lock contention and write amplification on a single tuple. This snippet shows a debounced alternative where increments accumulate in Redis and are flushed to Postgres at most once per interval per record.
In CounterBuffer, the buffer is treated as a per-key pending delta plus a dirty set. increment uses an atomic HINCRBY on a Redis hash so concurrent writers never lose updates, then registers the key in a SADD set so the flusher knows which records are dirty. The flush_key method leans on HGETSET-style semantics via a small Lua script: it reads and resets the pending delta atomically, guaranteeing no counts are dropped even if new increments arrive mid-flush. This read-and-reset atomicity is the crux of correctness.
In Post model, bump_comment_count is the public API models call instead of touching the column directly. It writes to the buffer and then enqueues CounterFlushJob with a debounce guard: set(wait: ...) combined with a Redis SET NX lock key means only one job is scheduled per record per window, collapsing a burst of a thousand increments into a single delayed flush. The NX guard is what makes this a true debounce rather than a naive per-event job.
In CounterFlushJob, perform pops the pending delta, applies it with a single relative UPDATE ... SET comments_count = comments_count + ? so the DB math stays authoritative and concurrent-safe, and then clears the debounce lock so the next burst can re-arm. Using a relative update rather than writing an absolute value avoids clobbering concurrent flushes.
The trade-off is eventual consistency: the cached count lags by up to the debounce window, so this pattern suits view counts, reaction tallies, and analytics — not balances that must be exact on read. Edge cases to watch are Redis eviction (make the buffer a durable, non-evictable namespace) and job failures, where leaving the delta in Redis until a successful UPDATE makes retries safe and idempotent.
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
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.