Redis

Caching Database Queries in Elixir with Nebulex and Redis

June 9, 2026
8 min read

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

  • cacheable implements cache-aside: check Redis first, hit the DB on a miss, store the result — all transparently via a decorator
  • cache_put always writes to cache after executing the function — use it on updates to keep the cache in sync
  • cache_evict removes 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/1 and :timer.hours/1 for 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.