Caching in Go: Redis, Cache-Aside and Cache Invalidation
A practical cache-aside implementation in Go with Redis: TTLs, cache stampedes, invalidation strategies, and when caching is the wrong call.
The problem
A GetProduct endpoint that used to answer in 4ms starts taking 80ms once
traffic grows, and the culprit is never a slow query in isolation — it's
the same query, run ten thousand times a minute, for a handful of products
that make up most of the traffic. PostgreSQL is doing exactly what it was
asked: parse the query, hit the buffer pool or disk, apply the WHERE
clause, return a row. It's just being asked to do that same real work over
and over for data that hasn't changed since the last request.
The fix isn't a faster database. It's not asking the database at all for requests that don't need a fresh answer.
Why it matters
Redis stores values in memory and answers with a single key lookup — no query planner, no disk I/O, no lock contention with writers. Moving repeated reads onto Redis and off PostgreSQL does two things at once: it makes the cached response faster (sub-millisecond instead of single-digit milliseconds), and it removes load from the database so the uncached queries — the ones that actually need PostgreSQL's query engine — get more headroom.
This only pays off for read-heavy, repeat-key access patterns. A
product-by-id lookup where 1% of products account for 60% of traffic is
an ideal candidate. A one-off analytics query that's never run with the
same parameters twice gets nothing from a cache — there's no repetition
to exploit.
Caching is an optimization for a specific, measured access pattern, not a default you bolt onto every repository method. Add it where you can point at a hot key and a real latency or load number, not preemptively.
The cache-aside pattern
Cache-aside (also called "lazy loading") is the right default because the application stays in control of both reads and invalidation, and it degrades gracefully — if Redis is down, the app still works, just slower.
The flow: check the cache first; on a hit, return it; on a miss, read the source of truth, populate the cache, then return.
Rendering diagram…
Two properties matter here. First, PostgreSQL remains the source of
truth — Redis never holds data that doesn't also exist (or didn't
recently exist) in the database, so a cache flush is never a data-loss
event. Second, the cache-population step happens in application code,
not as a side effect of the database or the cache client — which is
exactly what makes it debuggable: a hit or miss is one if branch you
can log and reason about.
Read-through and write-through, briefly
Two related patterns worth knowing conceptually, even though this article doesn't implement either:
Read-through moves the cache-population logic out of the application
and into the caching layer itself — the cache is configured with a loader
function, and a miss triggers the cache to fetch from the source of truth
transparently. The application only ever talks to the cache. This is
common in caching libraries and some managed cache products, but plain
Redis plus go-redis doesn't give you this for free — you'd build it, and
at that point it's cache-aside with extra indirection.
Write-through updates the cache synchronously as part of every write, so the cache is never stale by more than the write's own latency. It trades write latency (every write now touches two systems) for read consistency. It's a reasonable choice when reads vastly outnumber writes and staleness is unacceptable, but it means every write path has to handle "the database write succeeded but the cache write failed" — complexity cache-aside avoids by only ever writing to the cache from the read path, on a miss.
For most Go APIs backed by PostgreSQL, cache-aside with a TTL is the right starting point. Reach for write-through only when you've measured that TTL-driven staleness is actually a problem.
Practical implementation
Cache key design
The key needs three things: a namespace (so keys don't collide across entity types), the identifier, and something that changes when the shape of the cached value changes.
// internal/cache/keys.go
package cache
import "fmt"
// schemaVersion bumps whenever the cached JSON shape changes —
// a renamed field, a new required field, a changed type. Bumping it
// invalidates every existing key for this type instantly, without
// needing to touch Redis directly.
const productSchemaVersion = "v2"
func ProductKey(id string) string {
return fmt.Sprintf("product:%s:%s", productSchemaVersion, id)
}Skipping the schema version is the most common way cache-aside breaks
in practice. Deploy a change that renames a JSON field, and every request
that hits an old cached value will either fail to unmarshal or silently
populate a zero value — and it'll look like a data bug, not a caching
bug, because the database is fine. Bumping the version (or keying off the
row's updated_at) makes deploys safe by construction: old keys become
unreachable and simply expire.
An alternative to a hardcoded version is folding the record's updated_at
into the key itself (product:42:2026-04-10T12:00:00Z), which invalidates
per-record instead of globally — useful when different rows are updated
at different times and you don't want a single edit to cost you the whole
cache.
The repository and cache wrapper
Using github.com/redis/go-redis/v9, the current and actively maintained
client:
// internal/repository/product.go
package repository
import (
"context"
"github.com/gobackend-dev/example/internal/model"
"github.com/jackc/pgx/v5/pgxpool"
)
type ProductRepository struct {
db *pgxpool.Pool
}
func NewProductRepository(db *pgxpool.Pool) *ProductRepository {
return &ProductRepository{db: db}
}
func (r *ProductRepository) GetByID(ctx context.Context, id string) (model.Product, error) {
const query = `
SELECT id, name, price_cents, updated_at
FROM products
WHERE id = $1
`
var p model.Product
err := r.db.QueryRow(ctx, query, id).Scan(&p.ID, &p.Name, &p.PriceCents, &p.UpdatedAt)
return p, err
}// internal/cache/product.go
package cache
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/gobackend-dev/example/internal/model"
"github.com/redis/go-redis/v9"
)
// ProductLoader is the source of truth the cache falls back to on a miss.
// Defined here, at the point of use — the cache layer doesn't need to
// know it's PostgreSQL underneath.
type ProductLoader interface {
GetByID(ctx context.Context, id string) (model.Product, error)
}
type ProductCache struct {
rdb *redis.Client
loader ProductLoader
ttl time.Duration
}
func NewProductCache(rdb *redis.Client, loader ProductLoader, ttl time.Duration) *ProductCache {
return &ProductCache{rdb: rdb, loader: loader, ttl: ttl}
}
func (c *ProductCache) GetByID(ctx context.Context, id string) (model.Product, error) {
key := ProductKey(id)
// 1. Try the cache.
raw, err := c.rdb.Get(ctx, key).Bytes()
if err == nil {
var p model.Product
if jsonErr := json.Unmarshal(raw, &p); jsonErr == nil {
return p, nil
}
// Corrupt or incompatible cached value — fall through to the
// database rather than failing the request.
} else if !errors.Is(err, redis.Nil) {
// Redis is reachable but returned a real error (timeout,
// connection reset). Don't fail the request over a cache
// problem — fall back to the source of truth.
}
// 2. Cache miss (or cache error): read the source of truth.
product, err := c.loader.GetByID(ctx, id)
if err != nil {
return model.Product{}, fmt.Errorf("load product %s: %w", id, err)
}
// 3. Populate the cache for next time. Best-effort: a failed SET
// shouldn't fail a request that already has a valid result.
if encoded, jsonErr := json.Marshal(product); jsonErr == nil {
_ = c.rdb.Set(ctx, key, encoded, c.ttl).Err()
}
return product, nil
}redis.Nil is go-redis's explicit sentinel for "key doesn't exist" —
it's returned by Get and is the correct way to distinguish a real cache
miss from a connection or timeout error, which should be handled
differently (fail open to the database, don't treat it as "not cached").
JSON is the pragmatic default for serialization: every language and tool
can read it, it needs no schema registry, and Go's encoding/json is
good enough for values in the low kilobytes. The cost is real, though —
JSON marshaling and unmarshaling shows up in CPU profiles for
high-QPS caches, and JSON is larger on the wire than a binary format like
Protocol Buffers or MessagePack. Don't reach for a binary format until a
profiler actually points at json.Marshal; for most APIs, the network
and Redis round-trip dominate anyway.
TTL as the default invalidation mechanism
A TTL is a promise that stale data self-heals within a bounded window, without any invalidation code having to run at all:
const (
productTTL = 10 * time.Minute
)Ten minutes is a starting point, not a rule — the right TTL is a function of how often the data changes and how expensive a slightly stale read is. Product prices might tolerate ten minutes; a user's session permissions probably shouldn't.
Cache stampede
TTLs create a specific failure mode: when a hot key expires, every concurrent request that misses at the same moment falls through to PostgreSQL simultaneously. A key with 500 requests/second suddenly sends a burst of near-500 identical queries at the database in the same instant its TTL lapses — a self-inflicted spike that looks like an outage.
Two practical mitigations:
Per-key lock (singleflight). Only one goroutine per process
regenerates a given key; the rest wait for that result instead of
re-querying the database themselves. Go's golang.org/x/sync/singleflight
does this within a single instance:
import "golang.org/x/sync/singleflight"
type ProductCache struct {
rdb *redis.Client
loader ProductLoader
ttl time.Duration
group singleflight.Group
}
func (c *ProductCache) GetByID(ctx context.Context, id string) (model.Product, error) {
key := ProductKey(id)
if raw, err := c.rdb.Get(ctx, key).Bytes(); err == nil {
var p model.Product
if json.Unmarshal(raw, &p) == nil {
return p, nil
}
}
// Collapse concurrent misses for the same key into one database call.
v, err, _ := c.group.Do(key, func() (interface{}, error) {
product, err := c.loader.GetByID(ctx, id)
if err != nil {
return nil, err
}
if encoded, jsonErr := json.Marshal(product); jsonErr == nil {
_ = c.rdb.Set(context.Background(), key, encoded, c.ttl).Err()
}
return product, nil
})
if err != nil {
return model.Product{}, fmt.Errorf("load product %s: %w", id, err)
}
return v.(model.Product), nil
}This collapses the stampede per-instance; across many instances you'd
extend it with a short-lived Redis lock (SET key value NX PX 5000) so
only one instance in the fleet refreshes a given key at a time.
Serve stale while refreshing. Store the value with a longer physical TTL than its logical one, keep a separate "freshness" timestamp, and when a read finds the value logically expired but still physically present, return it immediately while kicking off a background refresh. Readers never wait on the database at all during the stampede window — they get data that's a few seconds stale instead of a queue behind a cold cache. This is more moving parts than a lock, but it trades stampede risk for staleness instead of trading it for latency, which is usually the better deal for read-heavy endpoints.
Cache invalidation
The quote is inevitable so let's get it out of the way: "There are only two hard things in Computer Science: cache invalidation and naming things." True, and not very actionable — the actual mechanism is a choice between two strategies, and most services should use both.
TTL-only. Simplest option. Every write just happens; readers see stale data for up to the TTL window. Fine when staleness for that window is acceptable and you don't want write-path code coupled to cache internals.
Explicit invalidation on write. The write path deletes (or overwrites) the cache key as part of the same operation that changes the row:
func (s *ProductService) UpdatePrice(ctx context.Context, id string, priceCents int64) error {
if err := s.repo.UpdatePrice(ctx, id, priceCents); err != nil {
return fmt.Errorf("update price: %w", err)
}
// Same code path as the write — not a background job, not a
// separate deploy, not something that can be forgotten in a
// different PR six months from now.
if err := s.rdb.Del(ctx, cache.ProductKey(id)).Err(); err != nil {
// Log and continue: the TTL is still the backstop.
s.logger.Warn("cache invalidation failed", "key", id, "err", err)
}
return nil
}The reason explicit invalidation has to live in the same function as the write, not in a listener, queue consumer, or "someone remembers to call it" convention, is that any indirection between the two is a place a stale-cache bug gets introduced later, by someone who doesn't know the cache exists. If the delete fails, don't fail the write over it — log it and let the TTL be the backstop. Combining both — explicit invalidation for the common case, a TTL as a safety net for the cases where invalidation is missed or fails — is the strategy most production services land on.
Production considerations
Monitor hit rate, not just latency. A cache with a 40% hit rate on a
key that's supposed to be hot means either the TTL is too short, the key
design is fragmenting what should be one key into many, or the access
pattern isn't as skewed as assumed. go-redis exposes command latency
via hooks; pair that with INFO stats (keyspace_hits /
keyspace_misses) on the Redis side for the actual ratio.
Cold cache after a deploy or restart. A fresh Redis instance — after a restart, a failover, or a deploy that changed the schema version in every key — has a 0% hit rate until it warms back up, and every request during that window falls through to PostgreSQL. Two options: accept the brief spike if the database can absorb it, or pre-warm the highest-traffic keys with a script that runs the same reads the app would, right after the new instance comes up.
Redis is a new operational dependency, not a free upgrade. It needs
memory sizing, an eviction policy (allkeys-lru is the usual default for
a pure cache), monitoring, and a plan for what happens when it's
unreachable. The code above already handles that last case by falling
through to PostgreSQL on any Redis error — verify that behavior with a
test, because "the cache is optional" is a claim, not a fact, until it's
exercised.
When caching is a bad idea
- Data that changes on every read anyway. A live inventory count during a flash sale is stale the instant it's cached; you're adding a system for no hit-rate benefit.
- Data where staleness has real consequences. An account balance displayed immediately after a transaction needs to be correct, not fast — a support ticket from a customer who sees a stale number after a payment costs more than the milliseconds a cache would have saved.
- Data cheap enough to query directly. If PostgreSQL already answers in 1-2ms because the query hits an index on a small table, a cache adds a second system, a second failure mode, and a key-invalidation surface for a latency win nobody will notice.
- Low-traffic endpoints. If a key is requested once every few minutes and its TTL is five minutes, it will rarely still be warm on the next request — you pay the complexity of caching without ever collecting the benefit of a hit.
Common mistakes
- No schema version or
updated_atin the key. A field rename ships, old cached JSON either fails to unmarshal or silently zero-values a field, and it presents as a data bug days after the actual deploy. - Treating every Redis error as a cache miss. A timeout is not the same as "not cached" — conflating them means a Redis outage causes requests to look up the database and re-populate the cache on every single request, which is worse than not caching at all under load.
- Invalidation living far from the write. A cache
DELcall in a cron job or a separate service that "cleans up stale keys" is a guarantee that someone eventually adds a new write path and forgets it. - No TTL at all. A cache entry with no expiration becomes permanent drift the moment its invalidation path fails once — TTL is the backstop that limits how wrong an entry can get, even when explicit invalidation is also in place.
- Caching without measuring first. Adding a cache to a repository method because "reads should be fast" without a hit-rate or load number to justify it is complexity added on faith.
Testing
The point of cache-aside is that the service still returns correct data when the cache is empty — that's the behavior worth testing, using a fake cache and a fake repository, no real Redis required:
package cache
import (
"context"
"testing"
"github.com/gobackend-dev/example/internal/model"
)
type fakeStore struct {
data map[string][]byte
}
func (f *fakeStore) get(key string) ([]byte, bool) {
v, ok := f.data[key]
return v, ok
}
func (f *fakeStore) set(key string, v []byte) {
if f.data == nil {
f.data = map[string][]byte{}
}
f.data[key] = v
}
type fakeLoader struct {
calls int
product model.Product
}
func (f *fakeLoader) GetByID(_ context.Context, id string) (model.Product, error) {
f.calls++
return f.product, nil
}
func TestProductCache_MissFallsBackToLoader(t *testing.T) {
loader := &fakeLoader{product: model.Product{ID: "p1", Name: "Widget"}}
store := &fakeStore{}
// A minimal stand-in for the Redis-backed cache that exercises the
// same get-miss-load-populate logic without a network call.
get := func(id string) (model.Product, error) {
key := ProductKey(id)
if raw, ok := store.get(key); ok {
var p model.Product
if err := unmarshal(raw, &p); err == nil {
return p, nil
}
}
p, err := loader.GetByID(context.Background(), id)
if err != nil {
return model.Product{}, err
}
store.set(key, marshal(p))
return p, nil
}
first, err := get("p1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if first.Name != "Widget" {
t.Fatalf("expected Widget, got %q", first.Name)
}
if loader.calls != 1 {
t.Fatalf("expected 1 loader call on miss, got %d", loader.calls)
}
// Second call should be served from the fake store, not the loader.
if _, err := get("p1"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if loader.calls != 1 {
t.Fatalf("expected loader NOT called again on hit, got %d calls", loader.calls)
}
}Testing against a fake store instead of a real Redis instance keeps this
suite fast and deterministic; the actual go-redis calls — network
errors, redis.Nil handling, TTL expiry — belong in a separate
integration test run against a real (or miniredis-backed) Redis, the
same way repository SQL gets its own integration coverage separate from
service-layer unit tests.
Summary
Cache-aside is the default for a reason: the application controls both
the read and the invalidation, and a Redis outage degrades to "slower,"
not "broken." Namespace cache keys and fold in a schema version or the
row's updated_at so a deploy can't serve a stale shape. Use JSON for
serialization until a profiler says otherwise. Treat TTL as the mandatory
backstop and explicit on-write invalidation as the optimization — never
the other way around — and put that invalidation in the same function as
the write, not somewhere it can be forgotten. Guard hot keys against
stampedes with a per-key lock or a stale-while-revalidate window. And
before adding Redis to a code path at all, confirm there's a real,
repeated-key access pattern behind it — caching a query that's already
cheap, or data where a stale read has real consequences, is added
operational risk with no offsetting benefit.
funcRelated()[]Article
API Rate Limiting in Go: Token Bucket, Middleware and Distributed Limits
Rate limiting a Go API in practice: token bucket vs sliding window, an in-memory middleware implementation, and why it breaks across multiple replicas.
PostgreSQL Indexes: A Practical Guide for Backend Engineers
A practical guide to PostgreSQL indexing for backend engineers: B-tree, GIN, composite and partial indexes, selectivity, overhead, and when indexes hurt performance.
Building a Go API with Clean Architecture Without Overengineering
A practical take on Clean Architecture in Go: handler/service/repository separation, dependency direction, and knowing when to stop adding abstractions.