Redis cache
Speed up reads, throttle, and store ephemeral state.
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
package cacheimport ("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 errorif 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
func (s *ProductService) ListFeatured(ctx context.Context) ([]models.Product, error) {const key = "products:featured:v1"// 1. try cachevar cached []models.Productif hit, _ := s.cache.Get(ctx, key, &cached); hit {return cached, nil}// 2. miss ā go to DBvar products []models.Productif 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 inredis-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
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 []byteif 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 itwriter := newCaptureWriter(ctx.Writer)ctx.Writer = writerctx.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.
/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.Minuteand adjust. - Swap Redis for an alternative (Dragonfly, KeyDB) ā they speak the Redis protocol, so just change
REDIS_URL. - Add metrics ā wrap
Get/Setwith 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
redis:image: redis:7-alpineports: ["6379:6379"]
REDIS_URL=redis://localhost:6379
In production, point REDIS_URL at Upstash, Redis Cloud, ElastiCache, or self-hosted. Same code, no change.
Quick check
Try it
Add a cache to a real endpoint:
- Pick a GET endpoint with no per-user data (e.g., the products list).
- In the service, wrap the DB call with Get / Set / 30s TTL.
- In the Update + Delete services for the same model, add a
cache.Del. - Test: hit the list endpoint twice with
-vvia curl. Second request should be faster. (You can also add a temporary log line in the service to confirm cache hit / miss.) - 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