Oct 28, 20266 min read

How to Add Redis to a Go API

Caching and rate limiting in a Go service with Encore's cache package or go-redis and ElastiCache.

Redis is the second piece of infrastructure many Go APIs pick up after Postgres. A slow upstream call needs a cache in front of it, a public endpoint needs a rate limit, and both need state shared across every instance of the service. The Go side is usually go-redis, and the work goes into running Redis locally, provisioning it in the cloud and reaching it from inside a VPC.

This guide adds both uses to a Go API that serves stock quotes: quotes are cached for 30 seconds in front of a slow upstream, and each client is limited to five requests per minute. It is shown with Encore and with go-redis against Amazon ElastiCache.

Redis options for a Go API at a glance

CapabilityEncorego-redis and ElastiCache
ProvisioningFrom cache.NewCluster on deployElastiCache cluster, subnet group, security group
Typed accessKeyspaces for strings, ints, structs, lists, setsStrings and manual JSON encoding
Key namingKeyPattern per keyspaceYour own prefixes
ExpiryDefaultExpiry per keyspaceEXPIRE or SET ... EX per call
Local RedisStarted by encore runDocker or a local install
CloudsElastiCache on AWS, Memorystore on GCPAWS only

Encore: a cache cluster with typed keyspaces

Encore combines an open source infrastructure SDK for Go and TypeScript with a platform that deploys into your own AWS or GCP account. The caching package declares a Redis cluster and typed keyspaces on top of it. A keyspace fixes the key pattern, the value type and the default expiry, so a cached quote can only be read back as a Quote:

// Service ratelimit caches quotes and limits requests with Redis. package ratelimit import ( "context" "errors" "time" "encore.dev/beta/errs" "encore.dev/storage/cache" ) // A Redis cluster: ElastiCache on AWS, Memorystore on GCP. var cluster = cache.NewCluster("quotes-cache", cache.ClusterConfig{ EvictionPolicy: cache.AllKeysLRU, }) type Quote struct { Symbol string `json:"symbol"` Price float64 `json:"price"` AsOf string `json:"asOf"` } // Cached quotes expire after 30 seconds. var quotes = cache.NewStructKeyspace[string, Quote](cluster, cache.KeyspaceConfig{ KeyPattern: "quote/:key", DefaultExpiry: cache.ExpireIn(30 * time.Second), }) // Requests per client per minute. var hits = cache.NewIntKeyspace[string](cluster, cache.KeyspaceConfig{ KeyPattern: "hits/:key", DefaultExpiry: cache.ExpireIn(time.Minute), }) const limitPerMinute = 5 type QuoteParams struct { ClientID string `header:"X-Client-ID"` } //encore:api public method=GET path=/quotes/:symbol func GetQuote(ctx context.Context, symbol string, p *QuoteParams) (*Quote, error) { n, err := hits.Increment(ctx, p.ClientID, 1) if err != nil { return nil, err } if n > limitPerMinute { return nil, &errs.Error{Code: errs.ResourceExhausted, Message: "rate limit exceeded"} } q, err := quotes.Get(ctx, symbol) if err == nil { return &q, nil } if !errors.Is(err, cache.Miss) { return nil, err } q = fetchQuote(symbol) if err := quotes.Set(ctx, symbol, q); err != nil { return nil, err } return &q, nil } // fetchQuote stands in for a slow upstream API call. func fetchQuote(symbol string) Quote { time.Sleep(200 * time.Millisecond) return Quote{Symbol: symbol, Price: 187.42, AsOf: time.Now().UTC().Format(time.RFC3339)} }

The X-Client-ID header keeps the example short, but a caller can send a new value on every request, so in production key the counter on a trusted identity, such as the user ID from auth.UserID() in an authenticated endpoint.

encore run starts Redis locally. The first request for a symbol takes the 200 ms upstream path, the second comes from the cache, and the sixth request from the same client within a minute is rejected with a 429:

~/quotes-app
$ encore run$ curl -s -o /dev/null -w "%{http_code} %{time_total}\n" localhost:4000/quotes/MSFT -H "X-Client-ID: c2"200 0.202824$ curl -s -o /dev/null -w "%{http_code} %{time_total}\n" localhost:4000/quotes/MSFT -H "X-Client-ID: c2"200 0.001216$ for i in 1 2 3 4 5 6; do curl -s localhost:4000/quotes/AAPL -H "X-Client-ID: c1"; done...{"code":"resource_exhausted","message":"rate limit exceeded","details":null}

On deploy, the cluster becomes ElastiCache on AWS or Memorystore on GCP in your own account, inside the same network as the service, and Encore wires the connection. Each cluster is a separate Redis instance, so a cache that can be flushed and a store you rely on can live in different clusters. Cache operations appear in request traces next to the API call, which makes a cache miss easy to spot when a request is slow.

An agent asked to cache a slow endpoint can add a keyspace and its expiry in the same file as the handler, and see the hit and miss timing under encore run before the change ships.

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

Run in your terminal to get started locally.

go-redis and ElastiCache

With go-redis, the cache and the limiter look like this. The struct is encoded as JSON by hand, and the expiry is set on the first increment so the window is fixed:

rdb := redis.NewClient(&redis.Options{ Addr: os.Getenv("REDIS_ADDR"), TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12}, // ElastiCache in-transit encryption }) func allow(ctx context.Context, clientID string) (bool, error) { key := "hits/" + clientID n, err := rdb.Incr(ctx, key).Result() if err != nil { return false, err } if n == 1 { rdb.Expire(ctx, key, time.Minute) } return n <= limitPerMinute, nil } func cachedQuote(ctx context.Context, symbol string) (Quote, error) { var q Quote raw, err := rdb.Get(ctx, "quote/"+symbol).Bytes() if err == nil { return q, json.Unmarshal(raw, &q) } if !errors.Is(err, redis.Nil) { return q, err } q = fetchQuote(symbol) b, _ := json.Marshal(q) return q, rdb.Set(ctx, "quote/"+symbol, b, 30*time.Second).Err() }

On AWS, the ElastiCache cluster lives in private subnets, so it needs a subnet group, a security group that allows port 6379 from the service's security group, and the service running in the same VPC. ElastiCache is not reachable from a laptop, so local development uses a Redis container, and the TLS setting above has to be switched off for it.

Good to know

Set a maxmemory-policy that matches the use. A cache can use allkeys-lru, while rate limit counters or anything you cannot lose should not be evicted. When the key from INCR is created but the EXPIRE call fails, the counter never resets, so wrapping both in a MULTI transaction or a Lua script is safer in production.

How to choose

Pick Encore when you want Redis declared in the Go service with typed keyspaces and expiry, a local Redis under encore run, and ElastiCache or Memorystore provisioned in your own cloud account. The same service can store data in Postgres and use the cache in front of it.

Pick go-redis with ElastiCache when Redis is already provisioned through Terraform, or when you need Redis commands such as streams or Lua scripting that the keyspace types do not cover.

Frequently asked questions

How do I add Redis to a Go API?

With Encore, declare a cluster with cache.NewCluster("quotes-cache", cache.ClusterConfig{}) and typed keyspaces such as cache.NewStructKeyspace[string, Quote](cluster, cache.KeyspaceConfig{KeyPattern: "quote/:key", DefaultExpiry: cache.ExpireIn(30 * time.Second)}). encore run starts Redis locally, and on deploy Encore provisions ElastiCache on AWS or Memorystore on GCP.

How do I rate limit a Go API with Redis?

Increment a counter per client with INCR and set an expiry on the key, then reject requests once the counter passes the limit within the window.

What happens when a key is missing from the Redis cache in Go?

With go-redis, Get returns the error redis.Nil. Check for it with errors.Is, load the value from the source, and write it back to the cache.

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