Caching Database Queries in Elixir with Nebulex and Redis
Database queries are often the slowest part of a web application. In Elixir, Nebulex gives you a clean, decorator-based caching layer that sits on top of Redis with almost no boilerplate. Instead of manually calling GET and SET around every query, you annotate your functions and Nebulex handles the rest.
What Is Nebulex?
Nebulex is a caching library for Elixir built around two main ideas: a unified Cache API that works across different backends, and a declarative decorator system that wraps functions transparently. The NebulexRedisAdapter connects that API to Redis via the Redix driver, supporting standalone nodes, Redis Cluster, and client-side sharding.
Setup
Add the dependencies to mix.exs:
defp deps do
[
{:nebulex, "~> 3.0"},
{:nebulex_redis_adapter, "~> 2.0"},
{:redix, "~> 1.5"},
{:decorator, "~> 1.4"},
{:telemetry, "~> 1.0"}
]
end
Create your cache module:
# lib/my_app/cache.ex
defmodule MyApp.Cache do
use Nebulex.Cache,
otp_app: :my_app,
adapter: NebulexRedisAdapter
end
Configure the Redis connection in config/config.exs:
config :my_app, MyApp.Cache,
conn_opts: [
host: "localhost",
port: 6379
]
Finally, add the cache to your application supervisor so it starts with your app:
# lib/my_app/application.ex
def start(_type, _args) do
children = [
MyApp.Repo,
MyApp.Cache
]
Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
end
The cacheable Decorator — Cache-Aside Pattern
The @decorate cacheable(...) decorator implements cache-aside: on the first call it executes the function and stores the result; on subsequent calls with the same key it returns the cached value directly, skipping the function body entirely.
defmodule MyApp.Users do
use Nebulex.Caching, cache: MyApp.Cache
alias MyApp.{Repo, User}
@decorate cacheable(key: id, opts: [ttl: :timer.minutes(5)])
def get_user(id) do
Repo.get!(User, id)
end
@decorate cacheable(key: email, opts: [ttl: :timer.minutes(5)])
def get_user_by_email(email) do
Repo.get_by(User, email: email)
end
end
The key: option determines which function argument becomes the cache key. The opts: [ttl: ...] sets expiration using Erlang's :timer helpers — :timer.minutes/1, :timer.hours/1, and :timer.seconds/1 all return milliseconds, which is what Nebulex expects.
Choosing the Right TTL
TTL controls how long data stays cached before Nebulex re-fetches it from the database. Match it to how often the underlying data actually changes:
- User profiles, product details —
:timer.minutes(5)to:timer.minutes(30) - Reference data (categories, config) —
:timer.hours(1)to:timer.hours(24) - Search results, aggregations —
:timer.minutes(1)to:timer.minutes(10) - Real-time data (counters, live scores) —
:timer.seconds(5)to:timer.seconds(30)
Composite Cache Keys
When a function takes multiple arguments but you want a key derived from a specific combination, pass a tuple or use an anonymous function:
defmodule MyApp.Products do
use Nebulex.Caching, cache: MyApp.Cache
alias MyApp.{Repo, Product}
import Ecto.Query
# Key combines both arguments
@decorate cacheable(key: {category, page}, opts: [ttl: :timer.minutes(10)])
def list_by_category(category, page) do
Product
|> where(category: ^category)
|> order_by(:inserted_at)
|> Repo.paginate(page: page)
end
# Key derived dynamically from decorator context
@decorate cacheable(
key: &{&1.function_name, hd(&1.args)},
opts: [ttl: :timer.hours(1)]
)
def get_featured(_opts \\ []) do
Repo.all(from p in Product, where: p.featured == true)
end
end
Write-Through with cache_put
While cacheable only populates the cache on a miss, cache_put always executes the function and updates the cache with the result. This is the write-through pattern — the cache stays current after every mutation.
defmodule MyApp.Users do
use Nebulex.Caching, cache: MyApp.Cache
alias MyApp.{Repo, User}
@decorate cache_put(key: user.id, match: &match_ok/1, opts: [ttl: :timer.minutes(5)])
def update_user(user, attrs) do
user
|> User.changeset(attrs)
|> Repo.update()
end
# Only cache on success; skip caching on changeset errors
defp match_ok({:ok, updated_user}), do: {true, updated_user}
defp match_ok({:error, _changeset}), do: false
end
The match: function controls what gets stored. Returning {true, value} caches a transformed value (here, the unwrapped struct instead of the {:ok, struct} tuple). Returning false skips caching entirely, which is exactly what you want when the update fails.
Cache Invalidation with cache_evict
When data is deleted or fundamentally changed, use cache_evict to remove the stale entry. Nebulex runs the eviction after the function by default, so the database operation succeeds before the cache is touched.
defmodule MyApp.Users do
use Nebulex.Caching, cache: MyApp.Cache
alias MyApp.{Repo, User}
@decorate cache_evict(key: user_id)
def delete_user(user_id) do
Repo.get!(User, user_id) |> Repo.delete()
end
# Evict multiple related keys at once
@decorate cache_evict(keys: [user.id, user.email])
def deactivate_user(user) do
user |> User.changeset(%{active: false}) |> Repo.update()
end
end
To flush an entire namespace — for example after a bulk import — pass all_entries: true:
@decorate cache_evict(all_entries: true)
def import_users(csv_path) do
csv_path |> File.stream!() |> bulk_insert()
end
Referenced Keys — One Value, Many Lookups
A common pattern is fetching a user by either their ID or their email. You want a single cached value stored under the primary key (ID), with secondary keys (email) pointing to it. Nebulex handles this with the references: option:
defmodule MyApp.Users do
use Nebulex.Caching, cache: MyApp.Cache
alias MyApp.{Repo, User}
@decorate cacheable(key: id, opts: [ttl: :timer.minutes(5)])
def get_user(id) do
Repo.get!(User, id)
end
# Stores the user under their id; the email key is a reference pointing to it
@decorate cacheable(
key: email,
references: &(&1 && &1.id),
opts: [ttl: :timer.minutes(5)]
)
def get_user_by_email(email) do
Repo.get_by(User, email: email)
end
# Evicting by id automatically clears the email reference too
@decorate cache_evict(key: user_id)
def delete_user(user_id) do
Repo.get!(User, user_id) |> Repo.delete()
end
end
With this setup you never store the same struct twice, and a single eviction keeps both lookup paths consistent.
Error Handling
By default, Nebulex uses on_error: :nothing — if Redis is temporarily unavailable, the decorated function executes normally as if the cache didn't exist. This is the right default for most production apps: a Redis hiccup should degrade gracefully, not take down the application.
If you want cache failures to surface as exceptions (useful in tests or strict environments), set on_error: :raise:
use Nebulex.Caching, cache: MyApp.Cache, on_error: :raise
You can also override this per decorator:
@decorate cacheable(key: id, on_error: :raise, opts: [ttl: :timer.minutes(5)])
def get_critical_config(id) do
Repo.get!(Config, id)
end
Redis Cluster Mode
When you need horizontal scale, switch the adapter to Redis Cluster mode by setting master node entry points in config. The adapter handles key slot routing automatically:
config :my_app, MyApp.Cache,
mode: :redis_cluster,
master_nodes: [
[host: "redis-node-1", port: 7000],
[host: "redis-node-2", port: 7001],
[host: "redis-node-3", port: 7002]
]
Your application code — decorators, keys, TTLs — stays identical. Only the config changes.
Key Takeaways
cacheableimplements cache-aside: check Redis first, hit the DB on a miss, store the result — all transparently via a decoratorcache_putalways writes to cache after executing the function — use it on updates to keep the cache in synccache_evictremoves stale entries after deletes or invalidating changes — target specific keys or flush all entries- Referenced keys let you look up the same cached value by multiple identifiers without duplicating the stored data
on_error: :nothing(the default) makes Redis failures transparent — the function runs as if there's no cache, keeping your app resilient- TTLs in milliseconds — use
:timer.minutes/1and:timer.hours/1for readable, correct values
Nebulex keeps caching concerns out of your business logic. Once the setup is in place, adding a cache to a slow query is a single line annotation — and switching from an in-memory adapter in dev to Redis in production is purely a config change.