Shopify/response_bank
{ "createdAt": "2008-04-21T18:25:42Z", "defaultBranch": "main", "description": "Simple response caching for Ruby applications", "fullName": "Shopify/response_bank", "homepage": "http://www.shopify.com", "language": "Ruby", "name": "response_bank", "pushedAt": "2026-08-04T16:05:29Z", "stargazersCount": 136, "topics": [], "updatedAt": "2026-08-04T16:07:02Z", "url": "https://github.com/Shopify/response_bank"}ResponseBank

Section titled “ResponseBank ”Features
Section titled “Features”- Serve gzip’d content
- Add ETag and 304 Not Modified headers
- Generational caching
- No explicit expiry
Support
Section titled “Support”This gem supports the following versions of Ruby and Rails:
- Ruby 3.1.0+
- Rails 6.0.0+
-
include the gem in your Gemfile
gem 'response_bank' -
add an initializer file. We need to configure the
acquire_lockmethod, set the cache store and the loggerrequire 'response_bank'module ResponseBankLOCK_TTL = 90class << selfdef acquire_lock(cache_key)cache_store.write("#{cache_key}:lock", '1', unless_exist: true, expires_in: LOCK_TTL, raw: true)endendendResponseBank.cache_store = ActiveSupport::Cache.lookup_store(Rails.configuration.cache_store)ResponseBank.logger = Rails.logger -
enables caching on your application
config.action_controller.perform_caching = true -
use
#response_cachemethod to any desired controller’s actionclass PostsController < ApplicationControllerdef showresponse_cache do@post = @shop.posts.find(params[:id])respond_with(@post)endendend -
(optional) set a custom TTL for the cache by overriding the
write_to_backing_cache_storemethod in your initializer filemodule ResponseBankCACHE_TTL = 30.minutesdef write_to_backing_cache_store(_env, key, payload, expires_in: nil)cache_store.write(key, payload, raw: true, expires_in: expires_in || CACHE_TTL)endend -
(optional) override custom cache key data. For default, cache key is defined by URL and query string
class PostsController < ApplicationControllerbefore_action :set_shopdef indexresponse_cache do@post = @shop.postsrespond_with(@post)endenddef showresponse_cache do@post = @shop.posts.find(params[:id])respond_with(@post)endenddef another_action# custom cache key datacache_key = {action: action_name,format: request.format,shop_updated_at: @shop.updated_at# you may add more keys here}response_cache cache_key do@post = @shop.posts.find(params[:id])respond_with(@post)endend# override default cache key data globally per classdef cache_key_data{action: action_name,format: request.format,params: params.slice(:id),shop_version: @shop.version# you may add more keys here}enddef set_shop# @shop = ...endend
Brotli Splice Slots
Section titled “Brotli Splice Slots”Applications that need per-request replacement inside cached Brotli HTML responses can pass an injector builder to ResponseBank::Middleware:
use ResponseBank::Middleware, ->(env) { HtmlMetadataInjector.new(env) }Rails applications can configure the same builder through config.response_bank:
config.response_bank.brotli_splice_injector = ->(env) { HtmlMetadataInjector.new(env) }The injector is optional. If it is not configured, ResponseBank uses the normal Brotli compression path. Applications own the concrete injector implementation because they know how to read their request-specific metadata.
Injectors may include ResponseBank::BrotliSpliceInjector to document the required methods:
class HtmlMetadataInjector include ResponseBank::BrotliSpliceInjector
# The per-request value spliced in on cache hits (a 36-byte UUID). TOKEN_PLACEHOLDER = "00000000-0000-0000-0000-000000000000" # BrotliSplice reserves the LAST 2 bytes of a slot as a fixed "\r\n" context # suffix, so a slot must span 2 more bytes than its replaceable region and # `replacement_length` always comes back as `slot length - 2`. We let those # 2 bytes be a real "\r\n" placed after the tag, where a line break is # harmless — the replaceable region is therefore `<uuid>">` (38 bytes). CONTEXT_SUFFIX = "\r\n" PLACEHOLDER_TAG = %(<meta name="shopify-y" content="#{TOKEN_PLACEHOLDER}">#{CONTEXT_SUFFIX}) SLOT = %(#{TOKEN_PLACEHOLDER}">#{CONTEXT_SUFFIX}) # 38 replaceable bytes + 2 reserved
def initialize(env) @env = env end
def prepare_response_bank_brotli_splice(body, _headers) body_with_placeholder = body.sub("</head>", "#{PLACEHOLDER_TAG}</head>") # Use a byte offset: BrotliSplice.encode addresses the slot by bytes. offset = body_with_placeholder.b.index(TOKEN_PLACEHOLDER) return unless offset
{ body: body_with_placeholder, slots: [ { name: "shopify_y", offset: offset, # Include the 2 bytes reserved for the context suffix, or the # replacement below will be 2 bytes too long and silently dropped. length: SLOT.bytesize, }, ], } end
def response_bank_brotli_splice_replacement(slot) # Must return EXACTLY slot["replacement_length"] bytes (== slot length - 2). # If it does not, ResponseBank skips the splice and serves the neutral # placeholder, so guard the length rather than assuming it. replacement = %(#{shopify_y}">) return unless replacement.bytesize == slot.fetch("replacement_length")
replacement end
def replace_response_bank_brotli_splice_placeholders(body, slots) slots.reduce(body) do |current, slot| replacement = response_bank_brotli_splice_replacement(slot) next current unless replacement
offset = slot.fetch("html_placeholder_offset") length = slot.fetch("html_placeholder_length") suffix = slot.fetch("context_suffix", CONTEXT_SUFFIX)
# replacement + suffix must equal the original slot length (byteslice # replaces `length` bytes), keeping the body byte-for-byte consistent. current.byteslice(0, offset) + replacement + suffix + current.byteslice(offset + length, current.bytesize) end end
private
def shopify_y @env.fetch("HTTP_SHOPIFY_Y") # 36-byte UUID endendprepare_response_bank_brotli_splice is used on cache writes. It returns HTML
containing a neutral placeholder and one slot describing that placeholder.
ResponseBank stores the slot metadata with the cached Brotli body. The slot’s
offset and length are byte offsets/counts — BrotliSplice.encode
addresses the stream by bytes — so locate the placeholder on a binary view
(body.b.index(...)) and size it with bytesize. A plain String#index/size
silently breaks once any multi-byte UTF-8 precedes the placeholder: a character
offset is always ≤ the byte length, so the bounds check still passes.
response_bank_brotli_splice_replacement is used on Brotli cache hits. It must
return exactly slot["replacement_length"] bytes. Note that replacement_length
is 2 fewer than the length you registered in the slot: BrotliSplice.encode
reserves the last 2 bytes of every slot as a fixed \r\n context suffix. So size
your placeholder to include those 2 bytes (as the example does with the trailing
\r\n), and have the replacement match replacement_length. If the byte length
does not match, ResponseBank silently skips the splice and serves the neutral
placeholder — no exception is raised — so guard the length instead of assuming it.
replace_response_bank_brotli_splice_placeholders is used when a cached Brotli response is decompressed for a client that does not accept Brotli.
Advanced integrations can still install the per-request injector directly in the Rack env before ResponseBank reads or writes the cached body:
env[ResponseBank::BrotliSpliceSlot::INJECTOR_ENV_KEY] = injectorException Handling
Section titled “Exception Handling”ResponseBank handles all exceptions gracefully during cache operations. If an exception occurs while reading from cache, deserializing cached data, or writing to cache, the middleware will:
- On cache read failures: Fall back to rendering the page normally (as if it was a cache miss).
- On cache write failures: Still serve the successfully rendered page to the user, but log the cache write failure.
This ensures that issues with cache stores (Redis/Memcached down), serialization errors, or compression/decompression failures don’t cause 500 errors for your users.
Custom Exception Handlers
Section titled “Custom Exception Handlers”You can set a custom exception handler in the Rack environment to be notified when cache operations fail (e.g., to report to Bugsnag, Sentry, etc.):
# In an initializer or middlewareclass MyMiddleware def call(env) env['response_bank.on_exception'] = ->(e) { Bugsnag.notify(e) } @app.call(env) endendLicense
Section titled “License”ResponseBank is released under the [MIT License]!(LICENSE.txt).