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-07-10T20:24:34Z", "stargazersCount": 136, "topics": [], "updatedAt": "2026-07-10T18:31:06Z", "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 2.7.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] = injectorLicense
Section titled “License”ResponseBank is released under the [MIT License]!(LICENSE.txt).