Updated Nov 5, 20266 min read

Caching in Go: In-Memory Caches, Redis and Cache-Aside Done Right

How to add caching to a Go backend: when an in-process cache is enough, using Redis with go-redis, the cache-aside pattern, TTLs and invalidation, and preventing cache stampedes with singleflight.

Caching is usually the first performance fix a Go backend gets: a product page or a permissions lookup hits Postgres on every request, the same rows come back each time, and a cache in front cuts both latency and database load. Go makes it easy to start with a map and a mutex, and the trouble comes later with stale data after updates, a cache that differs between instances, and a burst of misses that sends a thousand identical queries to the database at once.

This guide covers the choices in order: in-process caches, Redis, the cache-aside pattern with invalidation, and stampede protection. It ends with how Encore (a tool that works alongside your framework) handles caches for Go services so the same code runs locally and on AWS or GCP.

Go caching options at a glance

Encore cache keyspacessync.Map or map with mutexristrettoRedis with go-redis
Shared across instancesYesNoNoYes
EvictionRedis eviction policyNoneCost-based with admission policyRedis eviction policy
TTLPer keyspace and per writeManualPer itemPer key
Typed keys and valuesYesgenericsManualGeneric keys and valuesStrings and bytes, you serialize
InfrastructureProvisioned per environmentNoneNoneA Redis you run or rent
Local developmentencore run starts RedisBuilt inBuilt inDocker or a dev instance

Typed cache keyspaces on Redis with Encore

Encore

Encore is a tool that works alongside your framework for TypeScript and Go, with a platform that provisions and deploys into your own AWS or GCP account. A cache cluster declared in Go becomes ElastiCache on AWS or Memorystore on GCP, and each keyspace types its keys and values with generics, as described in the Go caching docs.

The Infrastructure page in the Encore dashboard with a database's configuration panel open, showing fields for vCPUs, memory, storage, availability type, max connections and backups.
Each provisioned resource can be reviewed and its configuration edited per environment in the Encore dashboard. The screenshot shows a GCP Cloud SQL database.

Set up the app

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create shop --example=go/hello-world$ cd shop && mkdir -p product/migrations

Cache-aside for a product lookup, with the entry deleted when the product changes:

// product/product.go package product import ( "context" "errors" "time" "encore.dev/storage/cache" "encore.dev/storage/sqldb" ) var db = sqldb.NewDatabase("products", sqldb.DatabaseConfig{ Migrations: "./migrations", }) // ElastiCache on AWS, Memorystore on GCP, a local Redis under encore run. var cluster = cache.NewCluster("products", cache.ClusterConfig{ EvictionPolicy: cache.AllKeysLRU, }) type Product struct { ID int64 `json:"id"` Name string `json:"name"` PriceMinor int64 `json:"price_minor"` } var productCache = cache.NewStructKeyspace[int64, Product](cluster, cache.KeyspaceConfig{ KeyPattern: "product/:key", DefaultExpiry: cache.ExpireIn(5 * time.Minute), }) //encore:api public method=GET path=/products/:id func Get(ctx context.Context, id int64) (*Product, error) { p, err := productCache.Get(ctx, id) if err == nil { return &p, nil } if !errors.Is(err, cache.Miss) { return nil, err } p = Product{ID: id} err = db.QueryRow(ctx, `SELECT name, price_minor FROM products WHERE id = $1`, id). Scan(&p.Name, &p.PriceMinor) if err != nil { return nil, err } _ = productCache.Set(ctx, id, p) return &p, nil } type UpdateParams struct { PriceMinor int64 `json:"price_minor"` } //encore:api public method=PUT path=/products/:id/price func UpdatePrice(ctx context.Context, id int64, p *UpdateParams) error { if _, err := db.Exec(ctx, `UPDATE products SET price_minor = $1 WHERE id = $2`, p.PriceMinor, id); err != nil { return err } _, err := productCache.Delete(ctx, id) return err }
-- product/migrations/1_create_products.up.sql CREATE TABLE products ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, price_minor BIGINT NOT NULL );

encore run starts Postgres and Redis locally, and cache operations appear in each request's trace in the local development dashboard, so a hit or miss is visible next to the database query it saved. The keyspace's types stop another part of the code from writing a different shape under product/:key. On deploy, Encore provisions the cache cluster in your cloud account and manages its credentials, so no Redis URL sits in config. The same model is available in TypeScript.

An AI coding agent adding a cached lookup gets compile errors if it reads a keyspace with the wrong type, and can watch hits and misses in the trace while testing locally.

On deploy, the cluster becomes ElastiCache on AWS or Memorystore on GCP, as listed in the AWS and GCP infrastructure docs.

Best fit: Go teams who want a shared Redis cache with typed keys that works the same locally and on AWS or GCP.

In-process caches

A map guarded by sync.RWMutex, or a sync.Map for read-heavy keys written once, is enough for configuration, feature flags and small lookup tables. Neither evicts anything, so they grow without bound if keys come from user input.

ristretto adds bounded memory with cost-based eviction and an admission policy that keeps frequently used keys, plus per-item TTLs:

c, err := ristretto.NewCache(&ristretto.Config[string, Product]{ NumCounters: 1e6, // keys to track frequency for, about 10x expected items MaxCost: 1 << 26, // 64 MB budget BufferItems: 64, }) if err != nil { panic(err) } c.SetWithTTL("product:42", Product{ID: 42, Name: "Mug"}, 1, 5*time.Minute) c.Wait() if p, ok := c.Get("product:42"); ok { fmt.Println(p.Name) }

In-process caches avoid a network hop and need nothing extra, but each instance has its own copy. With four instances, an update invalidates the cache in one of them, and the other three serve the old value until the TTL runs out.

Redis with go-redis

go-redis is the standard Redis client for Go. Values are bytes, so structs are usually encoded as JSON, and a miss is reported as redis.Nil:

rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"}) data, err := rdb.Get(ctx, "product:42").Bytes() switch { case errors.Is(err, redis.Nil): // miss: load from the database, then b, _ := json.Marshal(Product{ID: 42, Name: "Mug"}) err = rdb.Set(ctx, "product:42", b, 5*time.Minute).Err() case err != nil: return err default: var p Product err = json.Unmarshal(data, &p) }

Running Redis in production by hand means creating ElastiCache or Memorystore, placing it in the right network, passing the address and credentials to each service, and running a Redis container for development.

Invalidation and stampedes

Cache-aside reads are simple, and writes are where bugs come from. Deleting the key after the database update, as in the Encore example above, is safer than writing the new value into the cache, which can race with a concurrent read that caches the old row. A TTL bounds how long any missed invalidation can last.

When a popular key expires, every concurrent request misses at once and runs the same query. singleflight collapses those into one call per key within a process:

var group singleflight.Group func loadProduct(ctx context.Context, id int64) (Product, error) { v, err, _ := group.Do(strconv.FormatInt(id, 10), func() (any, error) { return queryProduct(ctx, id) }) if err != nil { return Product{}, err } return v.(Product), nil }

Adding a little random jitter to TTLs keeps keys written at the same time from expiring together.

Good to know

  • Cache what is expensive and read often, and measure hit rates before adding more keys.
  • Never cache per-user data under a key that does not include the user.
  • Treat the cache as optional: a Redis outage should slow requests down, not fail them, unless the cache holds state such as rate limits.

Frequently asked questions

What is the best way to cache in Go?

Start with the cache-aside pattern: read from the cache, fall back to the database on a miss, and write the result back with a TTL. Use an in-process cache such as ristretto for data that can differ briefly between instances, and Redis when several instances must share the cache or counters.

Should I use an in-memory cache or Redis in Go?

In-memory caches are fastest and need no infrastructure, but each instance has its own copy, they are lost on restart and invalidation only reaches one process. Redis adds a network hop and is shared by every instance, which suits sessions, rate limits and data that must be invalidated consistently.

How do I prevent a cache stampede in Go?

Wrap the database load in golang.org/x/sync/singleflight so concurrent misses for the same key share one call. Adding random jitter to TTLs also stops many keys from expiring at the same moment.

How do I cache database results in a Go API with Redis without managing Redis?

With Encore, declare a cluster with cache.NewCluster and a typed keyspace with cache.NewStructKeyspace, then use Get, Set and Delete, checking for cache.Miss on a miss. encore run starts a local Redis, and on deploy Encore provisions ElastiCache on AWS or Memorystore on GCP.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders