Building an In-Memory TTL Store in Go (Inspired by Elixir's ETS)
Elixir’s ETS (Erlang Term Storage) is a powerful built-in tool for in-process state that multiple processes can read concurrently. When I started working more heavily in Go, I found myself missing the pattern: a fast, in-memory store with atomic reads and lifecycle hooks.
Go doesn’t have ETS, but it has the primitives to build something that covers the same use cases. Here’s the store I built for a hobby project.
What ETS Gives You (and Go Doesn’t)
ETS lets any Elixir process insert, read, and delete entries by key. It’s concurrent by default, supports TTL via match specs, and can be configured as a bag, set, or ordered set. Most importantly, it’s accessible across processes within the same node without serialization.
In Go, you don’t have lightweight processes in the same sense, but you do have goroutines and shared memory with synchronization primitives. A sync.RWMutex over a map gets you most of the way there.
The Data Structure
type Item struct {
Value interface{}
ExpiresAt time.Time
HasExpiry bool
}
type Store struct {
mu sync.RWMutex
items map[string]Item
OnSet func(key string, value interface{})
OnDelete func(key string)
}
The RWMutex is the core concurrency primitive:
RLock()/RUnlock()for reads — allows multiple goroutines to read simultaneously.Lock()/Unlock()for writes — exclusive access.
This is exactly the right tool for a cache: reads are typically much more frequent than writes, so allowing concurrent reads significantly improves throughput.
Set and Get
func (s *Store) Set(key string, value interface{}, ttl time.Duration) {
s.mu.Lock()
defer s.mu.Unlock()
item := Item{Value: value}
if ttl > 0 {
item.ExpiresAt = time.Now().Add(ttl)
item.HasExpiry = true
}
s.items[key] = item
if s.OnSet != nil {
go s.OnSet(key, value)
}
}
func (s *Store) Get(key string) (interface{}, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
item, ok := s.items[key]
if !ok {
return nil, false
}
if item.HasExpiry && time.Now().After(item.ExpiresAt) {
return nil, false
}
return item.Value, true
}
The hook (OnSet) fires in a goroutine so it doesn’t block the write. This is the same pattern ETS uses for match notifications.
Notice that Get does lazy expiry checking: it returns false for expired items but doesn’t delete them. The actual cleanup happens in a background goroutine.
Delete
func (s *Store) Delete(key string) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.items[key]; ok {
delete(s.items, key)
if s.OnDelete != nil {
go s.OnDelete(key)
}
}
}
Background Cleanup
A goroutine runs on a ticker to evict expired entries. Without this, expired items would accumulate in memory even though Get treats them as missing.
func (s *Store) startCleanup(interval time.Duration) {
ticker := time.NewTicker(interval)
go func() {
for range ticker.C {
s.mu.Lock()
for key, item := range s.items {
if item.HasExpiry && time.Now().After(item.ExpiresAt) {
delete(s.items, key)
if s.OnDelete != nil {
go s.OnDelete(key)
}
}
}
s.mu.Unlock()
}
}()
}
A one-minute interval is fine for most use cases. For very short TTLs (sub-second), you’d want a smaller interval or a priority queue ordered by expiry time, similar to how Redis implements expiry internally.
Constructor
func NewStore() *Store {
s := &Store{
items: make(map[string]Item),
}
s.startCleanup(time.Minute)
return s
}
Using It
store := NewStore()
store.OnSet = func(key string, value interface{}) {
fmt.Printf("SET %s\n", key)
}
store.Set("session:abc123", userData, 30*time.Minute)
if val, ok := store.Get("session:abc123"); ok {
// use val
}
store.Delete("session:abc123")
What This Lacks (vs. ETS)
ETS can be accessed by any process in the BEAM VM, not just the one that owns it. This store is scoped to a single Go process. If you need cross-process sharing, you need something like Redis.
ETS also supports efficient match specs for querying by value patterns. This store only supports exact key lookup. Adding a secondary index for value-based queries would require maintaining additional maps with explicit synchronization.
The cleanup goroutine also holds the write lock while walking all entries. For stores with millions of entries, that creates lock contention. A shard-based approach (splitting the map into N buckets, each with its own lock) would reduce contention under high write loads.
For most hobby project use cases — session caches, rate limiter state, deduplication windows — none of these limitations matter. A mutex-guarded map with TTL cleanup is fast, simple, and easy to reason about.