Rails cache: what is it and how it should be used

Learn rails cache: what is it and how it should be used with practical examples, diagrams, and best practices. Covers ruby-on-rails, ruby, caching development techniques with visual explanations.

Rails Cache: Understanding and Effective Usage

Hero image for Rails cache: what is it and how it should be used

Explore the fundamentals of Rails caching, its various strategies, and how to implement it effectively to boost your application's performance and scalability.

In the world of web development, performance is paramount. Users expect fast, responsive applications, and slow load times can lead to a poor user experience and lost engagement. Ruby on Rails, a popular web framework, provides a robust caching mechanism to help developers address these performance challenges. Rails caching allows you to store frequently accessed data or rendered content, reducing the need to re-execute expensive operations like database queries or complex view rendering. This article will delve into what Rails cache is, its different types, and how to leverage it effectively in your applications.

What is Rails Cache?

At its core, Rails cache is a system designed to store temporary data that can be quickly retrieved later. This data can be anything from database query results, rendered HTML fragments, to computed values. By serving cached data, your application avoids repeating computationally intensive tasks, leading to faster response times and reduced server load. Rails offers several caching stores, each suited for different scenarios and deployment environments.

flowchart TD
    A[User Request] --> B{Application Logic}
    B --> C{Check Cache}
    C -->|Cache Hit| D[Serve Cached Data]
    C -->|Cache Miss| E{Perform Expensive Operation}
    E --> F[Store in Cache]
    F --> D
    D --> G[Respond to User]

Basic flow of a Rails caching mechanism

Types of Caching in Rails

Rails provides several layers of caching, each targeting different aspects of your application's performance. Understanding these types is crucial for implementing an effective caching strategy.

1. Page Caching (Deprecated in Rails 4+)

Page caching involves caching the entire HTML output of a page. When a request comes in, if a cached version of the page exists, it's served directly by the web server (e.g., Nginx, Apache) without even hitting the Rails application stack. While extremely fast, it's only suitable for pages that are completely static and don't require any dynamic content or user-specific data. Due to its limitations and security concerns (e.g., serving stale content or private data), it has been deprecated in favor of more flexible options.

2. Action Caching (Deprecated in Rails 4+)

Action caching is similar to page caching but allows for filters to be run before serving the cached content. This means you could, for example, check for user authentication before serving a cached page. However, like page caching, it still caches the entire response and has similar drawbacks regarding dynamic content and invalidation. It has also been deprecated in Rails 4+.

3. Fragment Caching

Fragment caching is the most commonly used and flexible caching strategy in modern Rails applications. Instead of caching entire pages, it allows you to cache specific "fragments" of a view. This is incredibly powerful because you can cache parts of a page that are static while leaving other parts dynamic. For example, a sidebar with popular articles might be cached, while the main content of a blog post remains dynamic.

<% cache @product do %>
  <h1><%= @product.name %></h1>
  <p><%= @product.description %></p>
  <%= render 'reviews', reviews: @product.reviews %>
<% end %>

Example of fragment caching in an ERB template.

Rails automatically generates a cache key based on the object's cache_key method (which typically includes the object's ID and updated_at timestamp). This ensures that the cache is automatically invalidated when the object changes.

4. Russian Doll Caching

Russian Doll Caching is an advanced technique built upon fragment caching. It involves caching nested fragments, where an outer fragment contains inner cached fragments. The beauty of this approach is that if an inner fragment changes, only that inner fragment needs to be re-rendered and re-cached. The outer fragment can still be served from the cache, significantly reducing rendering time. This is achieved by ensuring that the cache keys of parent fragments depend on the cache keys of their children.

<% cache @product do %>
  <h1><%= @product.name %></h1>
  <% cache @product.category do %>
    <p>Category: <%= @product.category.name %></p>
  <% end %>
  <%= render 'reviews', reviews: @product.reviews %>
<% end %>

Example of Russian Doll Caching with nested fragments.

5. Low-Level Caching

Low-level caching is used to cache arbitrary data or results of expensive computations that are not directly tied to view fragments. This is useful for caching database query results, API responses, or complex calculations that are frequently needed across different parts of your application. The Rails.cache object provides methods like fetch, read, and write for interacting with the cache store.

class Product < ApplicationRecord
  def self.expensive_calculation(product_id)
    Rails.cache.fetch("product_calculation_#{product_id}", expires_in: 1.hour) do
      # Simulate an expensive calculation
      sleep 2
      Product.find(product_id).price * 1.15
    end
  end
end

Using Rails.cache.fetch for low-level caching of a calculation.

Choosing a Cache Store

Rails supports various cache stores, each with its own characteristics and best use cases. You configure the cache store in your config/environments/*.rb files.

1. MemoryStore

This is the default cache store in development and test environments. It stores cached data in the application's memory. It's fast but not suitable for production as cached data is lost when the application restarts, and it doesn't scale across multiple application instances.

# config/environments/development.rb
config.cache_store = :memory_store

Configuring MemoryStore in development.

2. FileStore

FileStore caches data to files on the disk. It's persistent across application restarts and can be used in production for single-server deployments, but it's slower than in-memory stores and doesn't scale well for multiple servers.

# config/environments/production.rb
config.cache_store = :file_store, "#{Rails.root}/tmp/cache"

Configuring FileStore for production.

3. MemCacheStore

This store uses Memcached, a popular distributed memory caching system. It's highly performant and scalable, making it a common choice for production environments with multiple application servers. Data is stored in RAM across a cluster of Memcached servers.

# config/environments/production.rb
config.cache_store = :mem_cache_store, "cache-1.example.com:11211", "cache-2.example.com:11211"

Configuring MemCacheStore with multiple servers.

4. RedisCacheStore

RedisCacheStore uses Redis, an in-memory data structure store, as its backend. Redis offers more advanced features than Memcached, such as persistence, data structures (lists, hashes, sets), and pub/sub capabilities. It's an excellent choice for robust caching and can also be used for other purposes like background job queues.

# config/environments/production.rb
config.cache_store = :redis_cache_store, { url: ENV['REDIS_URL'], expires_in: 90.minutes }

Configuring RedisCacheStore with a URL and expiration.

Cache Invalidation

One of the trickiest aspects of caching is ensuring that users always see fresh, up-to-date data. This is where cache invalidation comes in. Rails' fragment and low-level caching mechanisms often handle this automatically through cache_key and expires_in options, but sometimes manual invalidation is necessary.

Automatic Invalidation with cache_key

For ActiveRecord objects, Rails automatically generates a cache key that includes the object's ID and updated_at timestamp. When the object is updated, its updated_at timestamp changes, leading to a new cache key and thus invalidating the old cached entry. This is the foundation of Russian Doll Caching.

Manual Invalidation

Sometimes you need to explicitly expire cache entries. Rails.cache provides methods for this:

# Expire a specific key
Rails.cache.delete("product_calculation_123")

# Expire all keys matching a pattern (supported by some stores like Redis)
Rails.cache.delete_matched("product_calculation_*")

# Clear the entire cache (use with caution in production!)
Rails.cache.clear

Examples of manual cache invalidation methods.

Best Practices for Rails Caching

To get the most out of Rails caching, follow these best practices:

1. Identify Bottlenecks

Before implementing caching, use performance monitoring tools (e.g., New Relic, Skylight, or even Rack::MiniProfiler) to identify the slowest parts of your application. Cache only what truly needs caching.

2. Start with Fragment Caching

Fragment caching is the most versatile and generally the best starting point for view caching. It allows fine-grained control over what gets cached.

3. Embrace Russian Doll Caching

For complex views with nested components, Russian Doll Caching can significantly improve performance and simplify invalidation. Ensure your models have touch: true on associations to propagate updated_at changes.

4. Use Rails.cache.fetch for Low-Level Caching

This method is ideal for caching expensive computations or data that isn't directly tied to view fragments. It handles both reading and writing to the cache efficiently.

5. Choose the Right Cache Store

For production, always opt for a distributed cache store like Memcached or Redis. MemoryStore and FileStore are generally not suitable for production environments due to scalability and persistence limitations.

6. Monitor Your Cache

Keep an eye on cache hit rates, cache size, and eviction policies. Tools like ActiveSupport::Cache::Store provide basic statistics, and dedicated monitoring for Memcached/Redis can offer deeper insights.

7. Plan for Invalidation

Think about how and when your cached data will become stale. Leverage cache_key for automatic invalidation, and have a strategy for manual invalidation when necessary.