Redis cache

Speed up reads, throttle, and store ephemeral state.

7 minmedium

Redis is an in-memory key-value store. In Grit, it's the cache that sits between your handler and the database — saving you a query on hot paths. This lesson is what it does, where the code lives, and how to add caching to your own endpoint.

Why we need it

A list endpoint that runs the same query 1000 times per minute wastes the DB. With a 60-second cache, that's 1 DB query per minute instead of 1000. The DB is happier, p95 latency drops, and you don't need a bigger DB to scale.

Where it lives

apps/api/internal/cache/
ā”œā”€ā”€ cache.go         ← Service struct + Get / Set / Delete
└── middleware.go    ← Optional HTTP-level cache (response caching)

How it's implemented

apps/api/internal/cache/cache.go (simplified)
package cache
import (
"context"
"encoding/json"
"time"
"github.com/redis/go-redis/v9"
)
type Service struct {
rdb *redis.Client
}
func New(url string) (*Service, error) {
opt, err := redis.ParseURL(url)
if err != nil { return nil, err }
return &Service{rdb: redis.NewClient(opt)}, nil
}
func (s *Service) Get(ctx context.Context, key string, dest any) (bool, error) {
raw, err := s.rdb.Get(ctx, key).Bytes()
if err == redis.Nil { return false, nil } // cache miss — not an error
if err != nil { return false, err }
return true, json.Unmarshal(raw, dest)
}
func (s *Service) Set(ctx context.Context, key string, val any, ttl time.Duration) error {
b, err := json.Marshal(val)
if err != nil { return err }
return s.rdb.Set(ctx, key, b, ttl).Err()
}
func (s *Service) Del(ctx context.Context, keys ...string) error {
return s.rdb.Del(ctx, keys...).Err()
}

Three operations, JSON serialization baked in, cache-miss treated as "not an error, just no data". That last detail matters: a cache miss is a normal case, not an exception.

How you call it from a service

apps/api/internal/services/product_service.go
func (s *ProductService) ListFeatured(ctx context.Context) ([]models.Product, error) {
const key = "products:featured:v1"
// 1. try cache
var cached []models.Product
if hit, _ := s.cache.Get(ctx, key, &cached); hit {
return cached, nil
}
// 2. miss — go to DB
var products []models.Product
if err := s.db.WithContext(ctx).Where("is_featured = ?", true).Find(&products).Error; err != nil {
return nil, err
}
// 3. populate cache for next time (1-minute TTL)
_ = s.cache.Set(ctx, key, products, time.Minute)
return products, nil
}

Three lines you'll see in every cached-read service:

  • Try cache. If hit, return immediately. The DB never gets touched.
  • Hit DB. Normal query.
  • Populate cache. So the NEXT request is a hit.

Key design — the silent skill

// Good keys:
"products:featured:v1" // collection
"product:" + strconv.Itoa(id) + ":v1" // single item
"user:" + uid + ":notes:v1" // user-scoped
// Bad keys:
"prod123" // unclear, collision-prone
"q=SELECT..."" // raw query — too brittle
"abc-" + time.Now() // includes time → never hits!
  • Versioned (:v1). When you change the value shape, bump the version. Old keys expire naturally; no manual invalidation.
  • Hierarchical with :. Lets you group + scan keys for debugging in redis-cli.
  • Deterministic. Same input → same key. Otherwise you never hit.

Invalidation — the hard part

"There are only two hard problems in computer science: cache invalidation and naming things." Three patterns to know:

1. TTL — let it expire

Set a short TTL (30s, 60s, 5m) and accept stale data for that long. Simple, robust, hides bugs. Right answer for most cases.

2. Explicit Delete on write

func (s *ProductService) Update(ctx context.Context, p Product) error {
if err := s.db.WithContext(ctx).Save(&p).Error; err != nil { return err }
_ = s.cache.Del(ctx, "products:featured:v1", "product:" + p.ID + ":v1")
return nil
}

After a write, blow away the cached read. Next read repopulates. Works well when you know exactly which keys are stale.

3. Pattern-based deletion

For broader invalidation (delete every product cache for a user), use SCAN + DEL in a loop. Avoid in production at scale — keep keys scoped instead.

HTTP-level cache middleware

apps/api/internal/cache/middleware.go (sketch)
func ResponseCache(c *Service, ttl time.Duration) gin.HandlerFunc {
return func(ctx *gin.Context) {
if ctx.Request.Method != "GET" { ctx.Next(); return }
key := "http:" + ctx.Request.URL.String()
var body []byte
if hit, _ := c.Get(ctx, key, &body); hit {
ctx.Header("X-Cache", "HIT")
ctx.Data(200, "application/json", body)
ctx.Abort()
return
}
// capture the response so we can cache it
writer := newCaptureWriter(ctx.Writer)
ctx.Writer = writer
ctx.Next()
if writer.Status() == 200 {
_ = c.Set(ctx, key, writer.Body(), ttl)
ctx.Header("X-Cache", "MISS")
}
}
}

Wire this on any GET endpoint where the response is identical for the same URL across users. The X-Cache: HIT/MISS header lets you verify in the browser DevTools.

Don't cache user-specific responses with HTTP middleware. If /api/me returns user A's profile and you cache by URL, user B gets user A's data. Use SERVICE-level caching with the user ID in the key for anything personalised.

How to modify the cache battery

  • Change the default TTL — find the service or middleware that sets time.Minute and adjust.
  • Swap Redis for an alternative (Dragonfly, KeyDB) — they speak the Redis protocol, so just change REDIS_URL.
  • Add metrics — wrap Get / Set with a Prometheus counter for hit ratio. One of the highest-signal metrics you can add.
  • Disable in dev — set REDIS_URL="" and Grit's service falls through to no-op (always returns miss). Useful when debugging stale-data bugs.

Local dev wiring

docker-compose.yml (excerpt)
redis:
image: redis:7-alpine
ports: ["6379:6379"]
.env (local)
REDIS_URL=redis://localhost:6379

In production, point REDIS_URL at Upstash, Redis Cloud, ElastiCache, or self-hosted. Same code, no change.

Quick check

You add a 60-second cache to /api/products/featured. A teammate complains: "I edited a product but it took a minute to show as featured." What's the cleanest fix?

Try it

Add a cache to a real endpoint:

  1. Pick a GET endpoint with no per-user data (e.g., the products list).
  2. In the service, wrap the DB call with Get / Set / 30s TTL.
  3. In the Update + Delete services for the same model, add a cache.Del.
  4. Test: hit the list endpoint twice with -v via curl. Second request should be faster. (You can also add a temporary log line in the service to confirm cache hit / miss.)
  5. Test invalidation: edit a row, immediately re-fetch the list, confirm the change shows up.

What's next

Next lesson — S3 file storage. Uploads, signed URLs, image processing — without taking on AWS' whole learning curve.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub