Cache API Responses in Go With Redis and Invalidate Safely
Cache API data in Redis from Go: cache-aside with singleflight, a version fence against stale writes, namespace versions for lists and TTL jitter, on Redis 8.

A cache is the cheapest way to make an API faster and the easiest way to make it wrong. The first half is a few lines of code: look in Redis, fall back to the database, store the result with an expiry. The second half is the question that decides whether anyone trusts the system: when the data changes, how long can a reader see the old version, and can that period ever become unbounded?
Tested with Go 1.25.6, go-redis v9.17.0 and Redis 8.10.1, macOS on Apple Silicon (arm64)
This article builds a Redis cache for a read-heavy endpoint in Go and attacks it: a stampede of readers on a cold key, a slow reader that overwrites a newer value, lists that become stale when any row changes, a thundering herd at expiry. It runs against a real Redis 8 server, and each failure is shown happening before the fix is applied. It complements conditional GETs and ETags, which cache at the HTTP layer, and the pagination in cursors, whose lists are what this caches. Everything ran on Go 1.25.6 with go-redis v9.17.0 and Redis 8.10.1.
The rules I hold to
Four rules from the project's own working notes carry over to any Go cache.
Cache plain data. The cached value is a struct that marshals to JSON. Never cache an ORM model, a library type or a collection of them. Serialising those into a cache breaks the first time the type changes shape, and the failure is a deserialisation error in production on a cache hit. JSON of a struct you own is boring and safe.
Every key has an expiry. Invalidation will have bugs. A TTL turns "stale forever" into "stale for at most a minute", and that bound is what makes the other bugs survivable.
A cache failure never fails the request. If Redis is down the request is slower, not broken. The code ignores errors from put and treats a failed get as a miss.
The database is the truth. The cache holds a copy with a version, and any disagreement is resolved in favour of the database.
The cache
package main
import (
"context"
"encoding/json"
"errors"
"math/rand/v2"
"strconv"
"time"
"github.com/redis/go-redis/v9"
"golang.org/x/sync/singleflight"
)
// Only plain data goes into the cache: a struct that marshals to JSON, never a database model or a library type.
type Product struct {
ID int `json:"id"`
Name string `json:"name"`
Price int `json:"price_cents"`
Version int `json:"version"`
}
var ErrNotFound = errors.New("not found")
type Cache struct {
rdb *redis.Client
group singleflight.Group
TTL time.Duration
}
func NewCache(rdb *redis.Client, ttl time.Duration) *Cache { return &Cache{rdb: rdb, TTL: ttl} }
// jitter spreads expiries over +-10% so a batch of keys written together does not expire together.
func jitter(d time.Duration) time.Duration {
return d - d/10 + time.Duration(rand.Int64N(int64(d/5)+1))
}
// setIfNewer stores data under key only if the cached version is older. A tombstone (empty data) from a writer holds a
// version, so a reader that loaded the row before the write cannot put the old row back after the tombstone.
var setIfNewer = redis.NewScript(`
local cur = redis.call('HGET', KEYS[1], 'v')
if cur then
local c, n = tonumber(cur), tonumber(ARGV[1])
local d = redis.call('HGET', KEYS[1], 'd')
if c > n or (c == n and d ~= '') then return 0 end
end
redis.call('HSET', KEYS[1], 'v', ARGV[1], 'd', ARGV[2])
redis.call('PEXPIRE', KEYS[1], ARGV[3])
return 1`)
func (c *Cache) get(ctx context.Context, key string) (Product, bool, error) {
vals, err := c.rdb.HMGet(ctx, key, "v", "d").Result()
if err != nil {
return Product{}, false, err
}
d, _ := vals[1].(string)
if vals[0] == nil || d == "" { // missing, or a tombstone
return Product{}, false, nil
}
var p Product
return p, true, json.Unmarshal([]byte(d), &p)
}
func (c *Cache) put(ctx context.Context, key string, p Product) error {
b, _ := json.Marshal(p)
return setIfNewer.Run(ctx, c.rdb, []string{key}, p.Version, string(b), jitter(c.TTL).Milliseconds()).Err()
}
// Tombstone is what a writer calls after committing version v: it kills the cached copy and fences off older readers.
func (c *Cache) Tombstone(ctx context.Context, key string, v int) error {
return setIfNewer.Run(ctx, c.rdb, []string{key}, v, "", (10 * time.Second).Milliseconds()).Err()
}
// Load is cache-aside with a single flight: concurrent misses for one key share one trip to the database.
func (c *Cache) Load(ctx context.Context, key string, fetch func() (Product, error)) (Product, string, error) {
if p, ok, err := c.get(ctx, key); err == nil && ok {
return p, "hit", nil
}
v, err, shared := c.group.Do(key, func() (any, error) {
// Check again inside the flight: a caller can arrive just after a previous flight finished and filled the cache.
if p, ok, err := c.get(ctx, key); err == nil && ok {
return p, nil
}
p, err := fetch()
if err != nil {
return Product{}, err
}
c.put(ctx, key, p) // a cache failure must not fail the request
return p, nil
})
src := "miss"
if shared {
src = "miss (shared)"
}
if err != nil {
return Product{}, src, err
}
return v.(Product), src, nil
}
// Namespace versions let one INCR invalidate every list cached under a prefix without scanning for keys.
func (c *Cache) ListKey(ctx context.Context, ns, query string) string {
v, _ := c.rdb.Get(ctx, "ns:"+ns).Result()
if v == "" {
v = "0"
}
return "list:" + ns + ":v" + v + ":" + query
}
func (c *Cache) BumpNamespace(ctx context.Context, ns string) int64 {
n, _ := c.rdb.Incr(ctx, "ns:"+ns).Result()
return n
}
func itoa(n int) string { return strconv.Itoa(n) }Load is cache-aside. It asks Redis for the key; on a hit it returns. On a miss it runs the fetch function, stores the result and returns it. Three details separate this from the version in most tutorials.
Jittered TTLs. jitter spreads each expiry over plus or minus ten percent of the base. A thousand draws of a 60-second TTL ranged from 54 to 66 seconds. Keys written together, as after a deploy or a cache flush, would otherwise expire together and send their misses to the database in one wave.
A single flight with a second check. singleflight.Group makes concurrent callers for one key share one fetch. The subtle part is the check inside the flight. My first version did not have it, and a test that starts 100 readers failed with two database reads for one cold key. The sequence: reader B checks the cache and sees a miss; reader A's flight completes and fills the cache; B then calls Do, finds no flight in progress and starts a new one. The singleflight only coalesces calls that overlap in time, and B did not overlap. Checking the cache again as the first thing in the flight closes the gap. With that fix, thirty repetitions of the test under the race detector stayed at exactly one read.
Versions. Each cached entry is a Redis hash with a version v and the JSON d, written by a Lua script. That is the next section.
Why DELETE is not enough
The usual invalidation is: update the database, delete the cache key. There is a race hiding in it.
- Reader R misses, reads the row from the database (version 1, price 4900) and is slow to get back.
- Writer W updates the row (version 2, price 5900), commits and deletes the key.
- R finally writes its row (version 1) into the cache.
Now the cache holds the old price, and it will keep it for a full TTL. The database is right and every reader gets the wrong answer. Any retry or garbage-collection pause can open this window; it is rare per request and certain over a month.
The fix is to give writes and reads a shared notion of order. The writer, after committing version 2, writes a tombstone into the cache: the version number with empty data. The reader's put goes through a script that refuses to store anything older than what is there, and refuses to store the same version over a live entry, while allowing it over a tombstone.
-- KEYS[1] = key; ARGV = version, data, ttl in ms
local cur = redis.call('HGET', KEYS[1], 'v')
if cur then
local c, n = tonumber(cur), tonumber(ARGV[1])
local d = redis.call('HGET', KEYS[1], 'd')
if c > n or (c == n and d ~= '') then return 0 end
end
redis.call('HSET', KEYS[1], 'v', ARGV[1], 'd', ARGV[2])
redis.call('PEXPIRE', KEYS[1], ARGV[3])
return 1The script is in cache.go as setIfNewer. It runs atomically inside Redis, so there is no gap between the comparison and the write. A tombstone expires after ten seconds, long enough to cover any reader in flight.
Running it
The lab starts a throwaway redis-server on a high port, so it needs no Docker; in your own project run docker run -p 6379:6379 redis:8 and point the client at it.
package main
import (
"sync"
"sync/atomic"
"time"
)
// DB stands in for the real store: slow on purpose so a cache has something to save, and it counts its reads.
type DB struct {
mu sync.Mutex
rows map[int]Product
Latency time.Duration
Reads atomic.Int64
// BeforeReturn lets a test pause a reader after it has read the row and before it can cache it.
BeforeReturn func(p Product)
}
func NewDB(latency time.Duration) *DB {
return &DB{rows: map[int]Product{1: {1, "Keyboard", 4900, 1}, 2: {2, "Mouse", 2500, 1}}, Latency: latency}
}
func (d *DB) Get(id int) (Product, error) {
d.Reads.Add(1)
d.mu.Lock()
p, ok := d.rows[id]
d.mu.Unlock()
time.Sleep(d.Latency)
if !ok {
return Product{}, ErrNotFound
}
if d.BeforeReturn != nil {
d.BeforeReturn(p)
}
return p, nil
}
// Update commits a new price and returns the new row; the version increases with every write.
func (d *DB) Update(id, price int) Product {
d.mu.Lock()
defer d.mu.Unlock()
p := d.rows[id]
p.Price, p.Version = price, p.Version+1
d.rows[id] = p
return p
}package main
import (
"context"
"fmt"
"os/exec"
"time"
"github.com/redis/go-redis/v9"
)
// StartRedis runs a throwaway redis-server on a high port, so the lab needs no Docker. In your own project use
// `docker run -p 6379:6379 redis:8` and point the client at it.
func StartRedis(port int) (*redis.Client, func()) {
cmd := exec.Command("redis-server", "--port", fmt.Sprint(port), "--save", "", "--appendonly", "no", "--loglevel", "warning")
if err := cmd.Start(); err != nil {
panic(err)
}
rdb := redis.NewClient(&redis.Options{Addr: fmt.Sprintf("127.0.0.1:%d", port)})
for i := 0; i < 100; i++ {
if rdb.Ping(context.Background()).Err() == nil {
break
}
time.Sleep(20 * time.Millisecond)
}
return rdb, func() { rdb.Close(); cmd.Process.Kill(); cmd.Wait() }
}package main
import (
"context"
"fmt"
"sync"
"time"
)
func main() {
ctx := context.Background()
rdb, stop := StartRedis(16391)
defer stop()
info, _ := rdb.Info(ctx, "server").Result()
for _, l := range splitLines(info) {
if len(l) > 13 && l[:13] == "redis_version" {
fmt.Println("server", l)
}
}
db := NewDB(5 * time.Millisecond)
cache := NewCache(rdb, time.Minute)
read := func(id int) (Product, string, time.Duration) {
t0 := time.Now()
p, src, _ := cache.Load(ctx, "product:"+itoa(id), func() (Product, error) { return db.Get(id) })
return p, src, time.Since(t0)
}
fmt.Println("\n== cache-aside")
for i := 0; i < 3; i++ {
p, src, d := read(1)
fmt.Printf("read %d: %-6s %v %v\n", i+1, src, p, d.Round(10*time.Microsecond))
}
_, _, err := cache.Load(ctx, "product:99", func() (Product, error) { return db.Get(99) })
fmt.Println("missing product:", err)
fmt.Println("\n== 200 concurrent readers of a cold key")
rdb.Del(ctx, "product:2")
db.Reads.Store(0)
var wg sync.WaitGroup
for i := 0; i < 200; i++ {
wg.Add(1)
go func() { defer wg.Done(); read(2) }()
}
wg.Wait()
fmt.Println("database reads with a single flight:", db.Reads.Load())
rdb.Del(ctx, "product:2")
db.Reads.Store(0)
for i := 0; i < 200; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, ok, _ := cache.get(ctx, "product:2"); !ok {
p, _ := db.Get(2)
cache.put(ctx, "product:2", p)
}
}()
}
wg.Wait()
fmt.Println("database reads with a plain check-then-load:", db.Reads.Load())
fmt.Println("\n== the stale write: a reader loads, a writer commits and invalidates, the reader caches the old row")
for _, mode := range []string{"delete the key", "version-fenced tombstone"} {
rdb.Del(ctx, "product:1")
db2 := NewDB(0)
paused, resume := make(chan struct{}), make(chan struct{})
first := true
db2.BeforeReturn = func(p Product) {
if first { // only the first reader pauses, holding the old row
first = false
close(paused)
<-resume
}
}
done := make(chan struct{})
go func() {
defer close(done)
cache.Load(ctx, "product:1", func() (Product, error) { return db2.Get(1) })
}()
<-paused
fresh := db2.Update(1, 5900) // price change commits while the reader still holds version 1
if mode == "delete the key" {
rdb.Del(ctx, "product:1")
} else {
cache.Tombstone(ctx, "product:1", fresh.Version)
}
close(resume)
<-done
p, src, _ := cache.Load(ctx, "product:1", func() (Product, error) { return db2.Get(1) })
fmt.Printf("%-26s next reader gets price=%d version=%d (%s); the database says %d\n", mode+":", p.Price, p.Version, src, fresh.Price)
}
fmt.Println("\n== list caches invalidated by a namespace version")
k1 := cache.ListKey(ctx, "products", "category=input")
cache.BumpNamespace(ctx, "products")
k2 := cache.ListKey(ctx, "products", "category=input")
fmt.Println("before a write:", k1)
fmt.Println("after a write: ", k2)
fmt.Println("\n== TTL jitter on a 60 s base")
min, max := time.Hour, time.Duration(0)
for i := 0; i < 1000; i++ {
j := jitter(time.Minute)
min, max = minDur(min, j), maxDur(max, j)
}
fmt.Printf("1000 draws ranged from %v to %v\n", min.Round(100*time.Millisecond), max.Round(100*time.Millisecond))
}
func splitLines(s string) []string {
var out []string
cur := ""
for _, r := range s {
if r == '\n' || r == '\r' {
if cur != "" {
out = append(out, cur)
}
cur = ""
continue
}
cur += string(r)
}
return append(out, cur)
}
func minDur(a, b time.Duration) time.Duration {
if a < b {
return a
}
return b
}
func maxDur(a, b time.Duration) time.Duration {
if a > b {
return a
}
return b
}server redis_version:8.10.1
== cache-aside
read 1: miss {1 Keyboard 4900 1} 7.26ms
read 2: hit {1 Keyboard 4900 1} 210µs
read 3: hit {1 Keyboard 4900 1} 140µs
missing product: not found
== 200 concurrent readers of a cold key
database reads with a single flight: 1
database reads with a plain check-then-load: 200
== the stale write: a reader loads, a writer commits and invalidates, the reader caches the old row
delete the key: next reader gets price=4900 version=1 (hit); the database says 5900
version-fenced tombstone: next reader gets price=5900 version=2 (miss); the database says 5900
== list caches invalidated by a namespace version
before a write: list:products:v0:category=input
after a write: list:products:v1:category=input
== TTL jitter on a 60 s base
1000 draws ranged from 54s to 1m6sCache-aside. The first read is a miss that costs the database's 5 ms plus the write, 7.3 ms in this run. The second and third are hits at 210 and 140 µs, cold connections included. A missing product returns not found, and the error is not stored.
Stampede. Two hundred goroutines read a cold key. With the single flight, the database was read once. With a plain check-then-load, 200 times, which is the stampede a popular key causes at the moment it expires or after a deploy.
The stale write. The program pauses a reader holding version 1, commits a price change, invalidates, then lets the reader finish. With DELETE, the next reader gets price 4900 and version 1 from the cache while the database says 5900. With the version-fenced tombstone, the old reader's write is rejected, the next reader misses, loads version 2 and sees 5900.
Lists. Caching a list is harder than caching a row, because any change to any row can alter it. Tracking which lists contain which rows is a bookkeeping project. The cheap answer is a namespace version: a counter ns:products is part of every list key (list:products:v0:category=input), and a write increments it. All old list keys become unreachable at once and expire by TTL. The cost is that one write invalidates every list, which is right for a catalogue edited a few times an hour and wrong for one edited every second.
Tests
package main
import (
"context"
"os"
"sync"
"testing"
"time"
"github.com/redis/go-redis/v9"
)
var rdb *redis.Client
func TestMain(m *testing.M) {
var stop func()
rdb, stop = StartRedis(16392)
code := m.Run()
stop()
os.Exit(code)
}
func fresh(t *testing.T) (*Cache, *DB, context.Context) {
t.Helper()
ctx := context.Background()
rdb.FlushAll(ctx)
return NewCache(rdb, time.Minute), NewDB(2 * time.Millisecond), ctx
}
func TestConcurrentMissesReachTheDatabaseOnce(t *testing.T) {
c, db, ctx := fresh(t)
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.Load(ctx, "product:1", func() (Product, error) { return db.Get(1) })
}()
}
wg.Wait()
if n := db.Reads.Load(); n != 1 {
t.Fatalf("%d database reads for one cold key", n)
}
}
func TestAnOlderReaderCannotOverwriteANewerWrite(t *testing.T) {
c, db, ctx := fresh(t)
db.Latency = 0
paused, resume, done := make(chan struct{}), make(chan struct{}), make(chan struct{})
first := true
db.BeforeReturn = func(Product) {
if first {
first = false
close(paused)
<-resume
}
}
go func() {
defer close(done)
c.Load(ctx, "product:1", func() (Product, error) { return db.Get(1) })
}()
<-paused
v2 := db.Update(1, 9900)
c.Tombstone(ctx, "product:1", v2.Version)
close(resume)
<-done
p, _, _ := c.Load(ctx, "product:1", func() (Product, error) { return db.Get(1) })
if p.Price != 9900 {
t.Fatalf("stale price %d survived", p.Price)
}
}
func TestNotFoundIsNotCachedAsAProduct(t *testing.T) {
c, db, ctx := fresh(t)
if _, _, err := c.Load(ctx, "product:99", func() (Product, error) { return db.Get(99) }); err != ErrNotFound {
t.Fatalf("got %v", err)
}
if rdb.Exists(ctx, "product:99").Val() != 0 {
t.Fatal("an error was stored as a value")
}
}
func TestEntriesExpireWithJitterInsideTheBand(t *testing.T) {
c, db, ctx := fresh(t)
c.Load(ctx, "product:1", func() (Product, error) { return db.Get(1) })
ttl := rdb.PTTL(ctx, "product:1").Val()
if ttl < 53*time.Second || ttl > 66*time.Second {
t.Fatalf("ttl %v outside 60s +-10%%", ttl)
}
}
func TestTheCacheHoldsPlainJSON(t *testing.T) {
c, db, ctx := fresh(t)
c.Load(ctx, "product:1", func() (Product, error) { return db.Get(1) })
if d := rdb.HGet(ctx, "product:1", "d").Val(); d != `{"id":1,"name":"Keyboard","price_cents":4900,"version":1}` {
t.Fatalf("stored %q", d)
}
}=== RUN TestConcurrentMissesReachTheDatabaseOnce
--- PASS: TestConcurrentMissesReachTheDatabaseOnce (0.01s)
=== RUN TestAnOlderReaderCannotOverwriteANewerWrite
--- PASS: TestAnOlderReaderCannotOverwriteANewerWrite (0.00s)
=== RUN TestNotFoundIsNotCachedAsAProduct
--- PASS: TestNotFoundIsNotCachedAsAProduct (0.00s)
=== RUN TestEntriesExpireWithJitterInsideTheBand
--- PASS: TestEntriesExpireWithJitterInsideTheBand (0.00s)
=== RUN TestTheCacheHoldsPlainJSON
--- PASS: TestTheCacheHoldsPlainJSON (0.00s)
PASS
ok example.test/lab 1.451sThe test suite starts its own Redis in TestMain. It covers the single flight, the stale-write fence, that errors are not cached, that expiry stays inside the jitter band, and that the stored value is plain JSON, so a change to the Go type does not need a cache flush to be safe.
What it saves
package main
import (
"context"
"testing"
)
func BenchmarkCacheHit(b *testing.B) {
c, db, ctx := fresh2()
c.Load(ctx, "product:1", func() (Product, error) { return db.Get(1) })
b.ReportAllocs()
for b.Loop() {
c.Load(ctx, "product:1", func() (Product, error) { return db.Get(1) })
}
}
func BenchmarkDatabaseRead(b *testing.B) {
db := NewDB(5 * 1000 * 1000) // 5 ms
for b.Loop() {
db.Get(1)
}
}
func fresh2() (*Cache, *DB, context.Context) {
ctx := context.Background()
rdb.FlushAll(ctx)
return NewCache(rdb, 60*1000*1000*1000), NewDB(0), ctx
}goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkCacheHit-10 34629 32285 ns/op 736 B/op 20 allocs/op
BenchmarkDatabaseRead-10 208 5706643 ns/op 0 B/op 0 allocs/op
PASS
ok example.test/lab 2.694sA cache hit took about 32 µs with 20 allocations, which includes a network round trip to Redis on localhost and an HMGET. The stand-in database read took 5.7 ms. The ratio, about 175 to one, is the real number to carry away only if your database is that slow. A well-indexed primary-key lookup on a nearby PostgreSQL may take half a millisecond, and a Redis hit across a network still costs a few hundred microseconds. Measure both before deciding a cache is worth its invalidation problems.
Decisions
What to cache. Rows read far more often than they change, and results of expensive queries. Not per-user data that is cheap to compute, and not anything where a minute of staleness is a bug, such as balances.
Where. An in-process cache is faster and per-instance, so instances disagree after a write. Redis is shared and slower. A short in-process layer over Redis is a common compromise, with the in-process TTL in seconds.
HTTP caching first. Before building any of this, check whether Cache-Control and ETags solve it (cache headers). They move the work to clients and CDNs for free.
Negative caching. Caching "not found" for a few seconds protects the database from repeated lookups of ids that do not exist. Store it as a separate short-lived marker, never as a product.
Keys. Prefix by purpose and include a schema version (v1:product:7), so a change to the cached shape is a new key rather than a migration.
Eviction. Set maxmemory and an eviction policy such as allkeys-lru so Redis cannot fill; the TTLs then bound staleness and the policy bounds memory.
The client is documented at pkg.go.dev/github.com/redis/go-redis/v9, the coalescing package at pkg.go.dev/golang.org/x/sync/singleflight, and Redis's own guidance on eviction and scripting is at redis.io/docs.


