ruby 69 lines · 3 tabs

Database “Last Seen” without Hot Row Updates

Shared by codesnips Jan 2026
3 tabs
class LastSeenTracker
  THROTTLE = 5.minutes
  PENDING_KEY = "pending:last_seen".freeze

  class << self
    def touch(user_id, at: Time.current)
      marker = "seen:mark:#{user_id}"
      # NX + EX means "only proceed once per THROTTLE window per user".
      return false unless redis.set(marker, 1, nx: true, ex: THROTTLE.to_i)

      redis.hset(PENDING_KEY, user_id, at.utc.iso8601)
      true
    end

    def current_for(user_id)
      raw = redis.hget(PENDING_KEY, user_id)
      raw && Time.iso8601(raw)
    end

    def drain
      pending = redis.hgetall(PENDING_KEY)
      return {} if pending.empty?

      redis.del(PENDING_KEY)
      pending.transform_values { |v| Time.iso8601(v) }
    end

    private

    def redis
      RedisPool.current
    end
  end
end
3 files · ruby Explain with highlit

Tracking when a user was "last seen" is deceptively expensive. A naive implementation updates users.last_seen_at on every request, which turns a single row into a write hotspot: each UPDATE produces a new row version, bloats the table, thrashes indexes, and — on a chatty user — serializes behind row locks. The value barely matters to the second, so paying full transactional write cost for it is wasteful.

This snippet shows a two-layer throttle that keeps the per-request path cheap and coalesces writes into infrequent batches. In LastSeenTracker, touch records a timestamp in Redis using SET with an EX expiry, but only performs the write once per THROTTLE window per user thanks to the NX guard on a separate marker key. That makes the common case a single, in-memory Redis command with no database contact at all. The pending timestamps accumulate in a Redis hash (pending:last_seen) keyed by user id, so the source of truth for the not-yet-persisted value lives outside Postgres.

The LastSeenFlushJob is what eventually moves data into the database, and it is where the hot-row problem is actually solved. Instead of one UPDATE per user, it drains the pending hash and issues a single upsert_all (with HGETDEL-style read-then-clear semantics via hgetall + del) so that dozens or thousands of updates collapse into one bulk statement. The unique_by option lets Postgres apply the batch as an ON CONFLICT upsert against the primary key, and update_only restricts the write to the last_seen_at column so unrelated attributes are never touched.

The ApplicationController wires it in through a before_action that calls LastSeenTracker.touch, keeping controllers ignorant of the throttling mechanics. A scheduled trigger (cron, Sidekiq-cron, etc.) enqueues LastSeenFlushJob on an interval.

The trade-off is deliberate: last_seen_at becomes eventually consistent and can lag by up to the flush interval, and a Redis outage can lose the most recent unflushed timestamps. For presence and analytics that is almost always acceptable, and in exchange the database is spared enormous write amplification. The main pitfall is forgetting that the Redis value is fresher than the column, so any read that needs real-time accuracy should consult LastSeenTracker, not the row.


Related snips

Share this code

Here's the card — post it anywhere.

Database “Last Seen” without Hot Row Updates — share card
Link copied