RUBY ON RAILS · 17 MIN READ ·

Rails Kredis: Redis-Backed Model Attributes Without Writing Redis Code

Rails Kredis adds Redis-backed counters, flags, lists, and hashes directly to your models — no manual key management, no boilerplate.

Every time I inherit a Rails app that has been running for a couple of years, I find the same thing somewhere in the codebase: a $redis global, or a Redis.new call buried in a service object, and a half-dozen methods that look like INCR user:#{user.id}:daily_requests scattered across controllers, background jobs, and model callbacks. The key format is invented fresh every time someone needed it. Nobody wrote it down. The only documentation is a Slack message from eighteen months ago where someone explained why the counter resets at midnight UTC instead of in the user’s timezone.

This is the problem Kredis solves. Not the timezone issue — that one you still have to think about — but the scattered Redis key management, the lack of encapsulation, and the friction that makes developers reach for a database column instead of Redis because a database column “just works” with ActiveRecord.

Kredis is a first-party Rails gem — authored by DHH and the Rails core team, extracted from Basecamp and Hey, and bundled with Rails 7+. It gives you Redis-backed data types — counters, flags, lists, sets, enums, hashes — as model attributes that behave like any other ActiveRecord attribute. Declare kredis_counter :page_views on a model and you immediately get user.page_views.increment, user.page_views.value, with Redis key naming handled automatically. No manual key construction. No global Redis client leaking into the wrong layer.

After using Kredis in several production Rails apps, I have found it changes how I think about what belongs in Postgres versus Redis. Certain patterns I used to wedge into the database — recent search history, soft rate limits, temporary feature states, per-session metadata — fit Redis much more naturally, and Kredis removes the friction that used to push me toward the easier database column.

What Rails Kredis Is (and Is Not)

Kredis is a thin wrapper over Redis, not an ORM for Redis. It does not try to query across keys or build cross-record indexes. What it does is three things:

  1. Manage Redis key naming automatically, scoped to each model instance
  2. Expose a clean Ruby interface for a handful of Redis data types
  3. Integrate those types directly into ActiveRecord models via class macros

A kredis_counter on a User model stores its data at a key that looks like user:42:page_views. That key is created on first write, updated on each increment, and expires when you call .reset or set expires_in: on the declaration. Nothing written to Postgres. Nothing in a database transaction.

What Kredis is not: it is not a replacement for Solid Cache or the fragment cache. It is not a replacement for Rack::Attack or Sidekiq’s rate limiting middleware. It is not a pub/sub system or an event stream. It is for per-resource ephemeral state that has a natural Redis shape — state that is scoped to one model instance, read and written frequently, and does not need to be queried across records.

Setup

Add the gem to your Gemfile:

gem "kredis"
bundle install
rails kredis:install

The installer creates config/redis/shared.yml:

development:
  url: redis://localhost:6379/1

test:
  url: redis://localhost:6379/2

production:
  url: <%= ENV["REDIS_URL"] %>

I always set separate Redis database indexes for Kredis versus the cache. If you ever need to flush the cache, you do not want Kredis state — user counters, flags — wiped at the same time.

For multiple named Redis connections:

development:
  url: redis://localhost:6379/1

analytics:
  url: redis://localhost:6379/3

Reference them with the config: option: kredis_counter :events, config: :analytics. Useful when you want high-frequency analytics counters isolated from user-facing state.

Kredis Counter: Atomic Increments Without Locking

The counter is where Kredis earns its keep fastest. Redis INCR is atomic — no dirty reads, no need for with_lock, no row-level contention on a database column. For anything you are incrementing frequently from multiple threads or processes, a Kredis counter is cleaner and faster than a database column.

class User < ApplicationRecord
  kredis_counter :api_calls
  kredis_counter :api_calls_today, expires_in: -> { Time.current.end_of_day - Time.current }
end

Usage:

user = User.find(1)

user.api_calls.increment            # +1
user.api_calls.increment(by: 10)    # +10
user.api_calls.decrement            # -1
user.api_calls.value                # => 47
user.api_calls.reset                # back to 0

The api_calls_today counter uses a lambda for expires_in:. It computes the time remaining until end of day at the moment of the first write. Each day the Redis key expires naturally and the counter starts fresh on the next increment — no cron job, no cleanup code.

A practical application: soft API rate limiting at the model level, complementing the harder request-level limiting you already have in Rack::Attack (covered in the Rack::Attack production guide):

class User < ApplicationRecord
  API_DAILY_LIMIT = 1_000

  kredis_counter :api_calls_today, expires_in: -> { Time.current.end_of_day - Time.current }

  def api_rate_limited?
    api_calls_today.value >= API_DAILY_LIMIT
  end

  def track_api_call!
    api_calls_today.increment
  end
end

A controller can check current_user.api_rate_limited? before processing and track usage with current_user.track_api_call! after — neither path touches Postgres.

Kredis Flag: One-Time States and Soft Feature Toggles

A Kredis flag is a Redis key that either exists or does not. mark sets it, marked? checks it, remove deletes it.

class User < ApplicationRecord
  kredis_flag :welcome_email_sent
  kredis_flag :profile_prompt_dismissed
  kredis_flag :beta_features_enabled
end
user.welcome_email_sent.mark
user.welcome_email_sent.marked?    # => true
user.welcome_email_sent.remove
user.welcome_email_sent.marked?    # => false

A flag beats a boolean database column when the state is ephemeral (safe to lose on Redis restart), not needed for queries or reporting, and scoped to a short time window. Add expiry for time-bounded states:

class User < ApplicationRecord
  kredis_flag :password_reset_initiated, expires_in: 10.minutes
end

This is a lightweight alternative to a password_reset_tokens table for simple flows that do not need cryptographic signed tokens. The key expires automatically after ten minutes — no background cleanup, no expired token rows accumulating.

Flags also work for per-user feature toggles that do not need the full machinery of Flipper (which I covered in the feature flags post). When you want to enable a beta feature for a specific user without a full feature flag system, user.beta_features_enabled.mark and user.beta_features_enabled.marked? in a helper is two lines of code with no new tables.

List and Unique List: Recent History Without a Join Table

kredis_list gives you an ordered Redis list with duplicates allowed. kredis_unique_list deduplicates — backed by a Redis sorted set with insertion timestamp as the score.

class User < ApplicationRecord
  kredis_unique_list :recent_searches, limit: 10
  kredis_list        :activity_log,    limit: 50
end
user.recent_searches.prepend("rails credentials")
user.recent_searches.prepend("kamal deploy")
user.recent_searches.prepend("rails credentials")  # deduplicates and moves to front
user.recent_searches.elements   # => ["rails credentials", "kamal deploy"]

user.activity_log.append("logged_in")
user.activity_log.append("viewed_dashboard")
user.activity_log.elements      # => ["logged_in", "viewed_dashboard"]
user.activity_log.remove("logged_in")

The limit: option caps list length automatically. Older entries fall off when you exceed the limit. No cleanup job. No growing table.

For a search autocomplete feature, this replaces what would otherwise be a recent_searches join table with a user_id, query, searched_at, a uniqueness index, and a cleanup job for entries older than 30 days. The join table approach works — but it adds a migration, an index, and ongoing maintenance. The Kredis unique list is one declaration and one .prepend call.

The tradeoff is real: Kredis lists are not queryable across records. You cannot ask “which users have searched for ‘rails credentials’.” If you need cross-user analytics, the join table is the right tool. Kredis is for per-resource state that is only ever accessed from one side.

Enum: Ephemeral State Machines

kredis_enum stores a string constrained to an allowed set of values, with optional expiry.

class VideoExportJob < ApplicationRecord
  kredis_enum :processing_status,
              values: %w[queued processing completed failed],
              default: "queued"
end
job.processing_status.value              # => "queued"
job.processing_status.value = "processing"
job.processing_status.is?(:queued)       # => false
job.processing_status.is?(:processing)  # => true

I use this for operations that have a visible status that does not need to outlast the operation itself:

class Account < ApplicationRecord
  kredis_enum :maintenance_mode,
              values: %w[none enabled scheduled],
              default: "none",
              expires_in: 4.hours
end

Maintenance mode that auto-expires in four hours eliminates the “someone enabled maintenance mode and forgot to disable it” incident at 2am. The expires_in: clock starts on first write, so if maintenance mode is enabled at 11pm it automatically clears at 3am.

Hash and JSON: Structured Metadata Without Schema Changes

kredis_hash stores key-value pairs in a Redis hash. kredis_json stores any JSON-serializable Ruby object.

class User < ApplicationRecord
  kredis_hash :preferences
  kredis_json :last_session_context, expires_in: 30.days
end
user.preferences.update(theme: "dark", locale: "en")
user.preferences[:theme]         # => "dark"
user.preferences.to_h            # => {"theme" => "dark", "locale" => "en"}

user.last_session_context.value = { referrer: "google", utm_campaign: "q4" }
user.last_session_context.value  # => {"referrer" => "google", "utm_campaign" => "q4"}

kredis_json is the most flexible type and the most prone to misuse. I use it for session metadata, analytics context, or feature-specific configuration that is per-user but not queryable. I do not use it as a document store. If I ever need to query the stored data, it goes in a Postgres JSONB column — the Postgres JSONB post covers indexing patterns for that.

Testing Rails Kredis

Kredis works against real Redis in tests. Configure a dedicated test database index in config/redis/shared.yml (index 2 by convention) and flush between tests:

# spec/rails_helper.rb
RSpec.configure do |config|
  config.before(:each) do
    Kredis.redis.flushdb
  end
end

With Minitest:

# test/test_helper.rb
class ActiveSupport::TestCase
  teardown do
    Kredis.redis.flushdb
  end
end

A practical test for the rate limiting example:

class UserTest < ActiveSupport::TestCase
  test "api rate limiting triggers at limit" do
    user = users(:active)
    assert_not user.api_rate_limited?

    User::API_DAILY_LIMIT.times { user.api_calls_today.increment }

    assert user.api_rate_limited?
  end

  test "rate limit resets after expiry" do
    user = users(:active)
    User::API_DAILY_LIMIT.times { user.api_calls_today.increment }

    # Simulate the key expiring
    user.api_calls_today.reset

    assert_not user.api_rate_limited?
  end
end

Testing against real Redis is significantly easier than stubbing individual Kredis attributes. The flushdb in teardown keeps tests isolated without any mock setup.

Production Considerations for Kredis

Key namespacing. By default, Kredis prefixes keys with the Rails application name to avoid collisions in a shared Redis instance. Verify the prefix in config/redis/shared.yml. In a multi-app environment, explicit namespacing is worth double-checking before deploying.

Keyspace size. Kredis creates one Redis key per declared attribute per model instance. A User model with three Kredis attributes and 500,000 users creates up to 1.5 million Redis keys. That is well within Redis’s capabilities — it handles hundreds of millions of keys — but you need memory planning. A Kredis counter takes roughly 50–100 bytes including key overhead. A flag is smaller. Review your model population sizes and declared attributes before sizing your Redis instance.

Expiry policy. Set expires_in: on anything that should not grow unbounded. Lists with limit: trim automatically, but a flag that is set and never cleared accumulates indefinitely. I review every Kredis declaration in a codebase for expiry policy before going to production.

Redis persistence. Redis RDB/AOF persistence covers Kredis data through restarts. Running Redis without persistence means Kredis state resets on restart. For counters that reset daily, that is fine. For flags that drive user-facing behavior, configure persistence or add a database fallback.

Redis Cluster. Kredis does not support Redis Cluster by default. Key hashing in Cluster distributes keys to different nodes, and Kredis expects all operations for a model instance on the same node. For most Rails apps a single Redis instance or Redis Sentinel handles Kredis load without Cluster. If Cluster is a hard requirement, use hash tags in the key configuration.

Kredis vs the Alternatives

Raw Redis calls. More flexible, more verbose, more error-prone on key naming. Use raw Redis for one-off ad hoc keys. When the same key pattern appears across multiple models or gets repeated in more than two places, extract to Kredis — it enforces consistent naming and removes the boilerplate.

Database columns. Always queryable, transactional, and durable through failures. Kredis is not a replacement for database state. Use a database column when you need to query across records, when the state must survive a Redis restart without recovery, or when it drives financial or compliance logic. Kredis and database columns coexist naturally — most models I build use both.

Flipper for feature flags. Flipper is the right tool for percentage rollouts, group targeting, and per-environment feature flag analytics. A Kredis flag is the right tool for a simple per-user toggle that does not need any of that. I use both in the same app without conflict.

FAQ

Is Rails Kredis stable enough for production?

Yes. It was extracted from Basecamp and Hey — both high-traffic production Rails apps — before being open-sourced and bundled with Rails 7+. The Rails team maintains it alongside the framework. I have run it in production on apps handling millions of requests per day without issues.

What happens to Kredis data when a model record is destroyed?

Nothing automatically. Kredis keys are independent of the model record in Postgres. Destroying a User leaves their Kredis keys in Redis until they expire naturally. For keys with expires_in:, this cleans up automatically. For permanent keys, add a before_destroy callback:

before_destroy :cleanup_kredis_attributes

private

def cleanup_kredis_attributes
  api_calls.reset
  welcome_email_sent.remove
  recent_searches.clear
end

Can I use Kredis without ActiveRecord?

Yes. The standalone API works anywhere:

counter = Kredis.counter("search:#{user_id}:queries")
counter.increment
counter.value

The model macros are the main ergonomic benefit, but the underlying Kredis.* class methods work in plain Ruby objects, service classes, or background jobs without any ActiveRecord dependency.

How does Kredis handle Redis downtime?

It raises a Redis::CannotConnectError. There is no automatic fallback. Wrap Kredis calls in rescue blocks where Redis downtime is a meaningful risk:

def api_rate_limited?
  api_calls_today.value >= API_DAILY_LIMIT
rescue Redis::CannotConnectError
  false  # fail open: let requests through if Redis is down
end

The fail-open versus fail-closed decision depends on business impact. Rate limiting and recent history can usually fail open. Any Kredis attribute that gates auth or payments needs a database fallback — or should not be using Kredis at all.


Working on a Rails app where ephemeral state is making a mess of your Postgres schema? TTB Software helps engineering teams design clean data architectures on Rails. We have been doing this for nineteen years.

#rails-kredis #kredis-counter #kredis-flag #rails-redis-model-attributes #kredis-rails-examples #redis-backed-rails

Related Articles

Last section. Then please call.

It's a phone call. That's the worst it can get.

No discovery deck. No 45-minute "qualification" call. 30 minutes, your problem, my opinion. If we're a fit, you'll know by minute 12.

Direct line — answered by Roger
+31 6 5123 6132
Mon–Fri, 09:00–18:00 CET · Currently available

OR
info@ttb.software