Nov 11, 20268 min read

How to Add Redis to an Echo App

Use Redis in a Labstack Echo API for idempotency keys on POST requests and a per-user recently viewed list, with Encore or with go-redis and Amazon ElastiCache.

Illustration for How to Add Redis to an Echo App

Echo handles routing, binding and middleware for a Go API, and nothing in it decides where state that outlives a request should live. The first time a mobile client retries a POST /orders on a flaky connection, or the product page needs a list that follows a user across devices, the answer is usually Redis, along with a Redis on every laptop and a managed one inside the same private network as the service in the cloud.

This guide adds two Redis features to an Echo shop API: idempotency keys that replay the first response when a client retries, and a recently viewed list per user capped at 20 items. The code is shown with Encore first and then with go-redis against Amazon ElastiCache.

Redis options for an Echo app at a glance

Encorego-redis + ElastiCache
Redis declared inGo code (cache.NewCluster)Terraform or the AWS console
Local RedisIn-memory Redis under encore runDocker or a local install
Key namingKeyPattern per keyspace, checked at compile timeString prefixes by convention
Lists and structsNewListKeyspace, NewStructKeyspaceLPUSH/LTRIM, JSON by hand
Connection settingsNone in codeREDIS_ADDR, TLS and an auth token per environment
Production RedisElastiCache on AWS, Memorystore on GCP, in your accountElastiCache you provision

How do you add Redis to Echo with Encore?

Encore

Encore works alongside Echo and provisions the infrastructure your Go code declares in your own AWS or GCP account. The *echo.Echo instance is mounted in a single raw endpoint with a fallback route, and the Redis cluster and its keyspaces sit in the same package as the handlers that read and write them.

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-api --example=hello-world$ cd shop-api && go get github.com/labstack/echo/v4@v4.16.3$ mkdir shop

Each keyspace fixes a key pattern, a value type and a default expiry. The idempotency feature uses two of them, one holding finished responses for a day and one holding a two-minute lock with an owner token, and the recently viewed feature uses a list keyspace:

// shop/shop.go package shop import ( "crypto/rand" "encoding/hex" "errors" "net/http" "time" "encore.dev/storage/cache" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" ) // One Redis cluster for the service: in-memory with `encore run`, // ElastiCache on AWS and Memorystore on GCP when deployed. var cluster = cache.NewCluster("shop-cache", cache.ClusterConfig{ EvictionPolicy: cache.AllKeysLRU, }) type Receipt struct { OrderID string `json:"order_id"` AmountCents int64 `json:"amount_cents"` Status string `json:"status"` } // Finished responses, replayed for 24 hours when a client retries with the same key. var idemResponses = cache.NewStructKeyspace[string, Receipt](cluster, cache.KeyspaceConfig{ KeyPattern: "idem/response/:key", DefaultExpiry: cache.ExpireIn(24 * time.Hour), }) // Lock lease is longer than the order+charge work. The value is a random // owner token so release deletes only this request's lock. var idemLocks = cache.NewStringKeyspace[string](cluster, cache.KeyspaceConfig{ KeyPattern: "idem/lock/:key", DefaultExpiry: cache.ExpireIn(2 * time.Minute), }) // Product IDs each user opened, newest first. var recentlyViewed = cache.NewListKeyspace[string, string](cluster, cache.KeyspaceConfig{ KeyPattern: "recent/:key", DefaultExpiry: cache.ExpireIn(30 * 24 * time.Hour), }) //encore:service type Service struct { e *echo.Echo } func initService() (*Service, error) { e := echo.New() e.Use(middleware.Recover()) e.POST("/orders", createOrder) e.GET("/products/:id", viewProduct) e.GET("/me/recently-viewed", listRecent) return &Service{e: e}, nil } // Fallback sends every request that no Encore endpoint matches to Echo. // //encore:api public raw path=/!fallback func (s *Service) Fallback(w http.ResponseWriter, req *http.Request) { s.e.ServeHTTP(w, req) } func createOrder(c echo.Context) error { ctx := c.Request().Context() key := c.Request().Header.Get("Idempotency-Key") if key == "" { return echo.NewHTTPError(http.StatusBadRequest, "Idempotency-Key header is required") } // A retry of a finished request gets the original response back. if r, err := idemResponses.Get(ctx, key); err == nil { c.Response().Header().Set("Idempotent-Replayed", "true") return c.JSON(http.StatusCreated, r) } else if !errors.Is(err, cache.Miss) { return err } // A retry that arrives while the first request is still running is rejected. token := newID() if err := idemLocks.SetIfNotExists(ctx, key, token); errors.Is(err, cache.KeyExists) { return echo.NewHTTPError(http.StatusConflict, "a request with this key is in progress") } else if err != nil { return err } defer func() { held, err := idemLocks.Get(ctx, key) if err == nil && held == token { _, _ = idemLocks.Delete(ctx, key) } }() var in struct { AmountCents int64 `json:"amount_cents"` } if err := c.Bind(&in); err != nil || in.AmountCents <= 0 { return echo.NewHTTPError(http.StatusBadRequest, "amount_cents must be positive") } // Write the order to the database and charge the customer here. r := Receipt{OrderID: newID(), AmountCents: in.AmountCents, Status: "created"} if err := idemResponses.Set(ctx, key, r); err != nil { return err } return c.JSON(http.StatusCreated, r) } func viewProduct(c echo.Context) error { ctx := c.Request().Context() id := c.Param("id") if user := userID(c); user != "" { // Move the product to the front and keep the 20 newest. _, _ = recentlyViewed.RemoveAll(ctx, user, id) _, _ = recentlyViewed.PushLeft(ctx, user, id) _ = recentlyViewed.Trim(ctx, user, 0, 19) } return c.JSON(http.StatusOK, map[string]string{"id": id}) } func listRecent(c echo.Context) error { user := userID(c) if user == "" { return echo.NewHTTPError(http.StatusUnauthorized) } ids, err := recentlyViewed.GetRange(c.Request().Context(), user, 0, 19) if err != nil && !errors.Is(err, cache.Miss) { return err } return c.JSON(http.StatusOK, map[string][]string{"product_ids": ids}) } // userID reads the ID your authentication middleware stored on the context. func userID(c echo.Context) string { id, _ := c.Get("userID").(string) return id } func newID() string { b := make([]byte, 8) _, _ = rand.Read(b) return "ord_" + hex.EncodeToString(b) }

Two POST /orders calls with the same Idempotency-Key return the same order_id, the second one with Idempotent-Replayed: true, and a retry that lands while the first call is still running gets a 409 instead of a second order. SetIfNotExists maps to Redis SET NX, so the lock holds across every replica of the service. The lease is two minutes, longer than the order and charge work, and release deletes the key only when the stored token still belongs to this request, so a slow request cannot drop a lock another retry has already taken. The expiry still releases the lock if the process dies mid-request. The list keyspace keeps its key pattern and element type in one declaration, which makes the RemoveAll, PushLeft and Trim sequence read like the feature it implements.

Locally, encore run serves the keyspaces from an in-memory Redis that holds a limited number of keys, so the code has to treat a miss as normal, which is also true once ElastiCache starts evicting. On deploy, Encore provisions ElastiCache on AWS or Memorystore on GCP in the environment's private network and connects the service with credentials it manages, so there is no Redis URL or TLS flag to keep in sync per environment. An AI coding agent asked to add a "saved for later" list writes one more NewListKeyspace with its own pattern next to these, and the compile step rejects it if the pattern overlaps one that already exists.

The caching docs list every keyspace type and operation, including sets, floats and MultiGet.

Best fit: Echo APIs that want idempotent POST endpoints and per-user lists backed by Redis that runs under encore run and is provisioned in their own AWS or GCP account.

go-redis and ElastiCache

With go-redis, the lock is a SetNX, the stored response is JSON you encode yourself, and the list is three commands that should run in one pipeline so another request cannot interleave:

var rdb = redis.NewClient(&redis.Options{ Addr: os.Getenv("REDIS_ADDR"), Username: os.Getenv("REDIS_USERNAME"), Password: os.Getenv("REDIS_PASSWORD"), TLSConfig: tlsConfig(), // nil locally, TLS 1.2+ against ElastiCache }) func acquireLock(ctx context.Context, key, token string) (bool, error) { return rdb.SetNX(ctx, "idem:lock:"+key, token, 2*time.Minute).Result() } func releaseLock(ctx context.Context, key, token string) { val, err := rdb.Get(ctx, "idem:lock:"+key).Result() if err == nil && val == token { _ = rdb.Del(ctx, "idem:lock:"+key).Err() } } func trackView(ctx context.Context, user, productID string) error { k := "recent:" + user _, err := rdb.TxPipelined(ctx, func(p redis.Pipeliner) error { p.LRem(ctx, k, 0, productID) p.LPush(ctx, k, productID) p.LTrim(ctx, k, 0, 19) p.Expire(ctx, k, 30*24*time.Hour) return nil }) return err }

The client code is short, and most of the work is in the setup around it. Amazon ElastiCache runs only inside a VPC, so it needs a subnet group, a security group that admits port 6379 from the Echo service, in-transit encryption and an auth token or ACL user stored in Secrets Manager with read access for the task role. A laptop cannot reach it, which means a Redis container with TLS off for local development and a tlsConfig switch that differs between the two, and each staging or preview environment needs its own cluster or a key prefix to avoid sharing state.

Best fit: Echo services whose Redis is already managed in Terraform, or that need streams, Lua scripts or pub/sub channels beyond what typed keyspaces cover.

Where Postgres still belongs

Redis is the fast path for idempotency, and the IETF Idempotency-Key draft leaves storage to the server. For an endpoint that charges money, also store the key in a unique column on the orders table and pass it to the payment provider, so an evicted Redis key cannot lead to a second charge. The recently viewed list has no such requirement, and losing it costs a user nothing more than an empty sidebar.

How to choose

Pick Encore to keep Echo and get typed Redis keyspaces that run locally under encore run and land as ElastiCache or Memorystore in your own cloud account. Pick go-redis with ElastiCache when a platform team already owns Redis in Terraform or you rely on commands outside the keyspace API. In both cases, keep a durable record in Postgres for anything that moves money.

Frequently asked questions

How do I implement idempotency keys in an Echo API with Redis?

Read the Idempotency-Key header, return the stored response if one exists for that key, and otherwise take a lock with SET NX, a unique owner token and a lease longer than the request so a concurrent retry gets a 409. Run the request, store the response under the key with a 24-hour expiry, and delete the lock only if the token still matches. For charges, also use a unique database column and a provider idempotency key.

How do I add Redis to an Echo app with Encore?

Declare a cluster with cache.NewCluster and typed keyspaces such as cache.NewStructKeyspace[string, Receipt] and cache.NewListKeyspace[string, string], mount the Echo instance in a raw endpoint with path=/!fallback, and call the keyspaces from the Echo handlers. encore run provides an in-memory Redis locally, and Encore provisions ElastiCache on AWS or Memorystore on GCP when you deploy.

Is Redis enough to guarantee a payment is only charged once?

Not on its own, since Redis can evict or lose keys and works best as the fast path that catches most retries. The durable guarantee comes from a unique constraint on the idempotency key in Postgres and from passing the same key to the payment provider.

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