kemal-cache
kemal-cache
kemal-cache is a response caching middleware for Kemal. It is intentionally small, storage-agnostic, and safe by default.
Highlights
- Kemal-native middleware API
- safe defaults for authenticated, cookie-bearing, and non-cacheable responses
- in-memory and Redis-backed stores
- custom cache keys, filters, and invalidation
- HTTP validator support with
ETag,Last-Modified, and304 Not Modified - built-in observability via counters and event hooks
Installation
Add the shard to shard.yml:
dependencies:
kemal-cache:
github: kemalcr/kemal-cache
Then install dependencies:
shards install
Quick Start
require "kemal-cache"
use Kemal::Cache::Handler.new
get "/articles" do
"Expensive response"
end
Kemal.run
The middleware adds X-Kemal-Cache: MISS or X-Kemal-Cache: HIT so cache behavior is visible during development.
Default Behavior
Out of the box, kemal-cache:
- caches
GETrequests only - uses
context.request.resourceas the cache key - caches successful
2xxresponses only - stores entries for
10.minutes - uses the in-process
Kemal::Cache::MemoryStore - bypasses cache for requests with
AuthorizationorCookie - skips storing responses with
Set-Cookie - skips storing responses with
Cache-Control: no-store,no-cache, orprivate - skips storing responses with
Vary: * - skips storing responses larger than
1_048_576bytes - skips storing responses that call
flush - auto-generates
ETagandLast-Modifiedfor cached responses - answers matching conditional requests with
304 Not Modified
Configuration
Create a custom Kemal::Cache::Config when you want to override the defaults:
require "kemal-cache"
config = Kemal::Cache::Config.new(
expires_in: 2.minutes,
cacheable_methods: ["GET"],
cacheable_status_codes: [200, 202],
max_body_bytes: 128_000,
cache_streaming: false,
auto_etag: true,
auto_last_modified: true,
conditional_get: true,
skip_if: ->(context : HTTP::Server::Context) { context.request.path.starts_with?("/admin") },
should_cache: ->(context : HTTP::Server::Context) { context.response.status_code == 202 }
)
use Kemal::Cache::Handler.new(config)
Cache Keys
The default key is context.request.resource, which includes the path and query string. Override it with key_generator when you need coarser or finer cache granularity:
config = Kemal::Cache::Config.new(
key_generator: ->(context : HTTP::Server::Context) do
locale = context.request.headers["Accept-Language"]? || "default"
"#{context.request.path}:#{locale}"
end
)
Cacheable Methods and Status Codes
Opt in to additional HTTP methods:
config = Kemal::Cache::Config.new(
cacheable_methods: ["GET", "POST"]
)
Restrict or broaden the status-code policy:
config = Kemal::Cache::Config.new(
cacheable_status_codes: [200, 203, 301]
)
Pass nil to cache every response status code:
config = Kemal::Cache::Config.new(
cacheable_status_codes: nil
)
Request and Response Filters
Use skip_if to bypass both lookup and storage for matching requests:
config = Kemal::Cache::Config.new(
skip_if: ->(context : HTTP::Server::Context) do
context.request.query_params["preview"]? == "true"
end
)
Use should_cache for the final storage decision after the response has been built:
config = Kemal::Cache::Config.new(
should_cache: ->(context : HTTP::Server::Context) do
context.response.status_code == 202
end
)
Response Size and Streaming Guards
Adjust the body size limit:
config = Kemal::Cache::Config.new(
max_body_bytes: 128_000
)
Disable the size limit entirely:
config = Kemal::Cache::Config.new(
max_body_bytes: nil
)
Allow caching responses that call flush:
config = Kemal::Cache::Config.new(
cache_streaming: true
)
HTTP Validators
Validator support is enabled by default for cached responses:
config = Kemal::Cache::Config.new(
auto_etag: true,
auto_last_modified: true,
conditional_get: true
)
If your application already manages these headers, kemal-cache preserves them. You can also disable automatic validators or conditional handling:
config = Kemal::Cache::Config.new(
auto_etag: false,
auto_last_modified: false,
conditional_get: false
)
Stores
MemoryStore
Kemal::Cache::MemoryStore is the default store. It is protected by a Mutex and is safe to use in Crystal's multi-threaded runtime. Because it is process-local, it is best suited to development and single-instance deployments.
RedisStore
kemal-cache includes a built-in RedisStore backed by jgaskins/redis:
store = Kemal::Cache::RedisStore.new(
URI.parse("redis://localhost:6379/0"),
namespace: "my-app-cache"
)
config = Kemal::Cache::Config.new(store: store)
use Kemal::Cache::Handler.new(config)
You can also build a Redis store from an environment variable:
store = Kemal::Cache::RedisStore.from_env("REDIS_URL")
config = Kemal::Cache::Config.new(store: store)
Custom Stores
Create a custom store by inheriting from Kemal::Cache::Store:
class CustomStore < Kemal::Cache::Store
def get(key : String) : String?
# fetch from storage
end
def set(key : String, value : String, ttl : Time::Span) : Nil
# write to storage with ttl
end
def delete(key : String) : Nil
# delete a single key
end
def clear : Nil
# clear the namespace
end
end
Then wire it into the config:
config = Kemal::Cache::Config.new(store: CustomStore.new)
use Kemal::Cache::Handler.new(config)
Invalidation
Remove a cached entry by exact key:
config = Kemal::Cache::Config.new
config.invalidate("/articles?page=2")
If the key depends on the current request context, invalidate directly from a route:
post "/articles/cache/invalidate" do |env|
config.invalidate(env)
env.response.status_code = 204
end
Purge the configured store:
config.clear_cache
Observability
Each config instance exposes thread-safe counters through config.stats:
config.stats.hits
config.stats.misses
config.stats.stores
config.stats.bypasses
config.stats.not_modified
config.stats.invalidations
config.stats.clears
config.stats.requests
config.stats.hit_ratio
You can also subscribe to cache lifecycle events with on_event:
config = Kemal::Cache::Config.new(
on_event: ->(event : Kemal::Cache::Event) do
Log.info do
"type=#{event.type} key=#{event.key} path=#{event.path} " \
"method=#{event.http_method} status=#{event.status_code} detail=#{event.detail}"
end
end
)
use Kemal::Cache::Handler.new(config)
Available event types:
HitMissStoreBypassNotModifiedInvalidateClear
How It Works
On a cache miss, the middleware buffers the response body, stores it with the configured TTL, and then writes the response back to the client. On a cache hit, it restores the cached response without invoking the rest of the handler chain.
For safer defaults, the middleware bypasses authenticated and cookie-bearing requests and does not persist responses that explicitly opt out of storage.
Development
shards install
crystal spec
crystal tool format --check
To run the real Redis integration spec locally, start Redis and set REDIS_URL:
REDIS_URL=redis://localhost:6379/0 crystal spec
Contributing
- Fork it (https://github.com/kemalcr/kemal-cache/fork)
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
Contributors
- Serdar Dogruyol - Author
kemal-cache
- 1
- 0
- 0
- 0
- 3
- about 4 hours ago
- April 2, 2026
MIT License
Thu, 02 Apr 2026 18:17:01 GMT