How to Improve Rails Caching with Brotli Compression

 
Rails app cache brotli compression is represented by a smol duck Photo by Pixabay from Pexels

Caching is an effective way to speed up the performance of Rails applications. However, the costs of an in-memory cache database could become significant for larger-scale projects. In this blog post, I’ll describe optimizing the Rails caching mechanism using the Brotli compression algorithm instead of the default Gzip. I’ll also discuss a more advanced technique of using in-memory cache for extreme performance bottlenecks.

Brotli vs Gzip 101

In one of my previous posts, I’ve covered how Rails leverages compression algorithms for the HTTP transport layer. Please check it out for more details on how Gzip and Brotli differ in compression ratios and performance. Here, let’s quickly recap the basics.

You can use Gzip and Brotli using Ruby API wrapping underlying C libraries. To Gzip a sample JSON file and measure the compression rate, run the following code:

require 'json'
require 'zlib'

json = File.read("sample.json")
puts json.size # => 1127380 bytes ~ 1127kb
gzipped = Zlib::Deflate.deflate(json)
puts gzipped.size # => 167155 bytes ~ 167kb

As you can see, a standard Gzip compression on a JSON reduced the size by ~85%.

Let’s now see how to use Brotli. You have to start by installing a Brotli gem:

gem install brotli

Now you can run:

require 'json'
require 'brotli'
json = File.read("sample.json")
puts json.size # => 1127380 bytes ~ 1127kb
brotlied = ::Brotli.deflate(json, quality: 6)
puts brotlied.size # => 145056 bytes ~ 145kb
Brotli is slow with the default quality settings, so 6 is recommended for on-the-fly compression.


Brotli compression is ~13% better than Gzip for this sample JSON. But, according to the sources, improvement can be as high as 25%.

Another advantage of Brotli is the speed of the compression and decompression process. Based on my benchmarks, Brotli can be ~20% faster than Gzip when used for Rails cache. The overhead of the caching layer will probably not be the bottleneck of your Rails app. But, if you’re using caching extensively, then a 20% improvement might translate to a measurable global speed-up.

But better compression could be even more impactful than read/write performance. I’ve discussed how to reduce the costs of in-memory cache databases when caching ActiveRecord queries in my other blog post. Combined with the tips on reducing the size of cache payloads, Brotli compression could lower caching infrastructure costs. Also, more space for cache means less frequent rotation of older entries, translating to better performance.

How to use Brotli for Rails cache?

By default, Rails cache uses the Gzip algorithm for payloads larger than 1kb. But, as we’ve discussed, Brotli offers better compression ratios and speed. You can start using Brotli in your Rails app with the help of the rails-brotli-cache gem that I’ve recently released.

It works as a proxy wrapper for standard cache stores, precompressing the payloads with Brotli instead of Gzip. I’ve measured a 20%-40% performance improvement in Rails.cache depending on the underlying data store. You can check out the benchmarks.

After adding it to your Gemfile, you have to change a single line of config to hook it up:

config/environments/production.rb

config.cache_store = RailsBrotliCache::Store.new(
  ActiveSupport::Cache::RedisCacheStore.new(url: ENV['REDIS_URL'])
)

Check out the gem readme for information on using it with different cache store types.

Also, remember to run Rails.cache.clear after after first enabling it to remove previously gzipped cache entries. But watch out not to wipe your Sidekiq in the process!

Optionally, you can hook up a custom compression algorithm. For example, Google Snappy offers even better performance than Brotli for the cost of worse compression ratios. ZSTD by Facebook is also worth checking out.

Gem uses an API compatibility spec to ensure that it behaves exactly as the underlying data store. I’m currently using it in a few production projects, but please submit a GH issue if you notice any inconsistencies.

The great news is that support for custom cache compression algorithms has recently been merged into the Rails main branch. A new :compressor config option should be available in the upcoming Rails 7.1.

Using and misusing in-memory cache store

I’ve mentioned that rails-brotli-cache offers ~20% speed improvement compared to the other cache stores. But there’s a catch. Let’s consider the following benchmark:

require 'active_support'
require 'active_support/core_ext/hash'
require 'net/http'
require 'rails-brotli-cache'
require 'benchmark'

json_uri = URI("https://raw.githubusercontent.com/pawurb/rails-brotli-cache/main/spec/fixtures/sample.json")
json = Net::HTTP.get(json_uri)

redis_cache = ActiveSupport::Cache::RedisCacheStore.new
brotli_redis_cache = RailsBrotliCache::Store.new(redis_cache)
memcached_cache = ActiveSupport::Cache::MemCacheStore.new
brotli_memcached_cache = RailsBrotliCache::Store.new(memcached_cache)
file_cache = ActiveSupport::Cache::FileStore.new('/tmp')
brotli_file_cache = RailsBrotliCache::Store.new(file_cache)
memory_cache = ActiveSupport::Cache::MemoryStore.new
brotli_memory_cache = RailsBrotliCache::Store.new(memory_cache)

iterations = 100

Benchmark.bm do |x|
  x.report("redis_cache") do
    iterations.times do
      redis_cache.write("test", json)
      redis_cache.read("test")
    end
  end

  x.report("brotli_redis_cache") do
    iterations.times do
      brotli_redis_cache.write("test", json)
      brotli_redis_cache.read("test")
    end
  end

  x.report("memcached_cache") do
    iterations.times do
      memcached_cache.write("test", json)
      memcached_cache.read("test")
    end
  end

  x.report("brotli_memcached_cache") do
    iterations.times do
      brotli_memcached_cache.write("test", json)
      brotli_memcached_cache.read("test")
    end
  end

  x.report("file_cache") do
    iterations.times do
      file_cache.write("test", json)
      file_cache.read("test")
    end
  end

  x.report("brotli_file_cache") do
    iterations.times do
      brotli_file_cache.write("test", json)
      brotli_file_cache.read("test")
    end
  end

  x.report("memory_cache") do
    iterations.times do
      memory_cache.write("test", json)
      memory_cache.read("test")
    end
  end

  x.report("brotli_memory_cache") do
    iterations.times do
      brotli_memory_cache.write("test", json)
      brotli_memory_cache.read("test")
    end
  end
end
Check out the gem repo for a complete benchmark code


You should get similar results:

                       user     system   total    real
redis_cache            1.770976 0.040232 1.811208 (2.587884)
brotli_redis_cache     1.387118 0.077257 1.464375 (2.137552)
memcached_cache        1.787665 0.058871 1.846536 (2.534051)
brotli_memcached_cache 1.368171 0.088934 1.457105 (2.043203)
file_cache             1.716816 0.055140 1.771956 (1.772132)
brotli_file_cache      1.319149 0.068127 1.387276 (1.387309)
memory_cache           0.001370 0.000117 0.001487 (0.001480)
brotli_memory_cache    1.330853 0.043904 1.374757 (1.374784)

Here’s an ASCII chart for easier comprehension:

redis_cache            |████████████████████
brotli_redis_cache     |████████████████

memcached_cache        |████████████████████
brotli_memcached_cache |████████████████

file_cache             |████████████████
brotli_file_cache      |███████████████

memory_cache           |
brotli_memory_cache    |███████████

As you can see, rails-brotli-cache proxy improves the performance for all the cache storage types apart from ActiveSupport::Cache::MemoryStore. And this storage type is ~99% faster than all the other types. What’s going on?

Nested cache layers

ActiveSupport::Cache::MemoryStore is unique because compared to other cache store types, it does not compress cached entries. Also, it keeps cached entries directly in the process RAM, so there’s no serialization, networking, or filesystem IO overhead. If we run the benchmark with in-memory compression enabled, we’ll get the following results:

memory_cache = ActiveSupport::Cache::MemoryStore.new(compress: true)
memory_cache           |█████████████████
brotli_memory_cache    |███████████

As you can see, compression causes significant overhead for all the cache store types. For some extreme cases, you can use this order of magnitude better performance of uncompressed in-memory cache-store. The tradeoff will be higher RAM usage of your Ruby processes, but 99% performance improvement could be worth it.

To do it, you can wrap your standard Rails cache into a custom in-memory store like this:

config/application.rb

$memory_cache = ActiveSupport::Cache::MemoryStore.new

and now in your bottleneck endpoint:

app/controller/users_controller.rb

class ProductsController < ApplicationController

# ...

def index
  cache_key = "cached_products-#{params.hash}"
  cached_products = $memory_cache.fetch(
    cache_key,
    expires_in: 30.seconds, race_condition_ttl: 5.seconds
  ) do
    Rails.cache.fetch(cache_key, 5.minutes) do
      Product::FetchIndex.call(params)
    end
  end

  render json: cached_products
end

In the above example, we’re using two layers of caching. The outer layer using $memory_store in-memory cache is set to expire every 30 seconds. It means that our underlying standard cache store will be called significantly less often. As discussed, the performance of the caching layer for most of the requests will be ~99% better. For endpoints under heavy load, configuring race_condition_ttl will also reduce the number of redundant cache refresh calls.

This example might seem a bit convoluted. But for bottleneck endpoints that serve identical data to multiple clients, similar hacks could measurably reduce the load on your Redis/Memcache database and improve performance. But remember that it’s easy to misuse this technique and bloat the app’s RAM usage.

Summary

Replacing a standard Gzip compression with rails-brotli-cache gem is a relatively simple change that might result in a global speed-up for your Rails app. Another benefit is that it could help you reduce the costs of the in-memory cache database thanks to better compression. I invite you to give it a try. Feedback and PRs on how to improve the gem are welcome.



Back to index