Solving LFU Cache Implementation in Go: Step-by-Step Guide
The Least Frequently Used (LFU) cache eviction policy is one of the most fascinating and challenging data structures to implement correctly. Unlike its simpler cousin LRU (Least Recently Used), which only tracks access recency, LFU tracks how often each item is accessed and evicts the one with the lowest frequency. In this tutorial, we will build a production-quality LFU cache in Go from scratch, exploring the underlying data structures, the algorithm, and the trade-offs along the way.
What Is an LFU Cache?
An LFU cache is a fixed-capacity key-value store that automatically evicts entries when it runs out of space. The eviction candidate is always the item that has been accessed the fewest times. When multiple items share the same lowest frequency, the least recently used among them is evicted — this tie-breaking rule is what makes LFU a hybrid of frequency and recency tracking.
The cache must support two core operations in constant or near-constant time:
Get(key)— Returns the value associated with the key, increments its access frequency, and returns-1(or a not-found indicator) if the key does not exist.Put(key, value)— Inserts or updates a key-value pair. If the cache is at capacity, it evicts the LFU item before inserting the new one.
Why LFU Matters
LFU shines in scenarios where access patterns are skewed — a small subset of items is accessed repeatedly while many items are accessed only once. Common real-world applications include:
- Database query result caching, where popular queries deserve to stay cached longer.
- CDN edge caching, where trending content should remain available.
- Operating system page replacement, where frequently referenced memory pages are preserved.
- API response caching, where hot endpoints benefit from longer retention.
Compared to LRU, LFU is more resistant to one-time scans flushing out genuinely popular items. However, LFU can suffer from "cache pollution" — items that were popular in the past but are no longer accessed can linger. Many production systems use hybrid policies like LRU-K or W-TinyLFU to mitigate this.
Data Structures Required
To achieve O(1) average time complexity for both Get and Put, we need a combination of three data structures:
- A hash map (
map[key]*node) forO(1)key lookups. - A frequency map (
map[freq]*doublyLinkedList) that groups nodes by their current access frequency. Each frequency level holds a doubly linked list ordered by recency. - A doubly linked list per frequency level to maintain insertion order, allowing us to find the least recently used node at the minimum frequency in
O(1).
We also track a minFreq integer so we always know which frequency list contains the eviction candidate.
Step 1: Defining the Node and Doubly Linked List
Each cache entry is represented by a node holding the key, value, and current frequency. The doubly linked list lets us move nodes between frequency buckets efficiently.
package lfu
// node represents a single cache entry.
type node struct {
key, value, freq int
prev, next *node
}
// doublyList is a frequency bucket holding nodes with the same access count.
type doublyList struct {
head, tail *node
size int
}
func newDoublyList() *doublyList {
head := &node{}
tail := &node{}
head.next = tail
tail.prev = head
return &doublyList{head: head, tail: tail}
}
// pushFront inserts a node at the front (most recently used position).
func (dl *doublyList) pushFront(n *node) {
n.prev = dl.head
n.next = dl.head.next
dl.head.next.prev = n
dl.head.next = n
dl.size++
}
// remove detaches a node from the list.
func (dl *doublyList) remove(n *node) {
n.prev.next = n.next
n.next.prev = n.prev
n.prev = nil
n.next = nil
dl.size--
}
// back returns the least recently used node in this frequency bucket.
func (dl *doublyList) back() *node {
if dl.size == 0 {
return nil
}
return dl.tail.prev
}
Step 2: Defining the Cache Structure
Now we wire together the hash map, the frequency map, and the minimum frequency tracker.
// LFUCache is a fixed-capacity Least Frequently Used cache.
type LFUCache struct {
capacity int
minFreq int
nodes map[int]*node
freqs map[int]*doublyList
}
// New creates an LFUCache with the given capacity.
func New(capacity int) *LFUCache {
return &LFUCache{
capacity: capacity,
minFreq: 0,
nodes: make(map[int]*node),
freqs: make(map[int]*doublyList),
}
}
Step 3: Implementing the Frequency Promotion Helper
Whenever a node is accessed, its frequency increases by one. This means removing it from its current frequency bucket and inserting it into the next one. If the old bucket becomes empty and it was the minimum frequency bucket, we bump minFreq.
// promote moves a node from its current frequency bucket to freq+1.
func (c *LFUCache) promote(n *node) {
oldFreq := n.freq
newFreq := oldFreq + 1
// Remove from old bucket.
oldList := c.freqs[oldFreq]
oldList.remove(n)
if oldList.size == 0 {
delete(c.freqs, oldFreq)
if c.minFreq == oldFreq {
c.minFreq = newFreq
}
}
// Insert into new bucket.
n.freq = newFreq
newList, ok := c.freqs[newFreq]
if !ok {
newList = newDoublyList()
c.freqs[newFreq] = newList
}
newList.pushFront(n)
}
Step 4: Implementing Get
The Get operation looks up the key, promotes the node's frequency, and returns the value. If the key is missing, it returns -1.
// Get returns the value for key, or -1 if not present.
func (c *LFUCache) Get(key int) int {
n, ok := c.nodes[key]
if !ok {
return -1
}
c.promote(n)
return n.value
}
Step 5: Implementing Put
The Put operation has two branches. If the key already exists, we update the value and promote the node. If it is new, we evict the LFU item when at capacity, then insert the new node at frequency 1 and reset minFreq to 1.
// Put inserts or updates a key-value pair.
func (c *LFUCache) Put(key, value int) {
if c.capacity <= 0 {
return
}
// Update existing key.
if n, ok := c.nodes[key]; ok {
n.value = value
c.promote(n)
return
}
// Evict if at capacity.
if len(c.nodes) >= c.capacity {
evictList := c.freqs[c.minFreq]
lru := evictList.back()
evictList.remove(lru)
delete(c.nodes, lru.key)
if evictList.size == 0 {
delete(c.freqs, c.minFreq)
}
}
// Insert new node at frequency 1.
n := &node{key: key, value: value, freq: 1}
c.nodes[key] = n
if _, ok := c.freqs[1]; !ok {
c.freqs[1] = newDoublyList()
}
c.freqs[1].pushFront(n)
c.minFreq = 1
}
Step 6: Putting It All Together
Here is the complete implementation in a single file for reference:
package lfu
type node struct {
key, value, freq int
prev, next *node
}
type doublyList struct {
head, tail *node
size int
}
func newDoublyList() *doublyList {
head := &node{}
tail := &node{}
head.next = tail
tail.prev = head
return &doublyList{head: head, tail: tail}
}
func (dl *doublyList) pushFront(n *node) {
n.prev = dl.head
n.next = dl.head.next
dl.head.next.prev = n
dl.head.next = n
dl.size++
}
func (dl *doublyList) remove(n *node) {
n.prev.next = n.next
n.next.prev = n.prev
n.prev = nil
n.next = nil
dl.size--
}
func (dl *doublyList) back() *node {
if dl.size == 0 {
return nil
}
return dl.tail.prev
}
type LFUCache struct {
capacity int
minFreq int
nodes map[int]*node
freqs map[int]*doublyList
}
func New(capacity int) *LFUCache {
return &LFUCache{
capacity: capacity,
nodes: make(map[int]*node),
freqs: make(map[int]*doublyList),
}
}
func (c *LFUCache) promote(n *node) {
oldFreq := n.freq
newFreq := oldFreq + 1
oldList := c.freqs[oldFreq]
oldList.remove(n)
if oldList.size == 0 {
delete(c.freqs, oldFreq)
if c.minFreq == oldFreq {
c.minFreq = newFreq
}
}
n.freq = newFreq
newList, ok := c.freqs[newFreq]
if !ok {
newList = newDoublyList()
c.freqs[newFreq] = newList
}
newList.pushFront(n)
}
func (c *LFUCache) Get(key int) int {
n, ok := c.nodes[key]
if !ok {
return -1
}
c.promote(n)
return n.value
}
func (c *LFUCache) Put(key, value int) {
if c.capacity <= 0 {
return
}
if n, ok := c.nodes[key]; ok {
n.value = value
c.promote(n)
return
}
if len(c.nodes) >= c.capacity {
evictList := c.freqs[c.minFreq]
lru := evictList.back()
evictList.remove(lru)
delete(c.nodes, lru.key)
if evictList.size == 0 {
delete(c.freqs, c.minFreq)
}
}
n := &node{key: key, value: value, freq: 1}
c.nodes[key] = n
if _, ok := c.freqs[1]; !ok {
c.freqs[1] = newDoublyList()
}
c.freqs[1].pushFront(n)
c.minFreq = 1
}
Step 7: Writing a Usage Example
Let us verify the implementation with a small driver program that exercises both Get and Put, including an eviction scenario.
package main
import (
"fmt"
"yourmodule/lfu"
)
func main() {
cache := lfu.New(2)
cache.Put(1, 1) // cache: {1=1(freq1)}
cache.Put(2, 2) // cache: {1=1(freq1), 2=2(freq1)}
fmt.Println(cache.Get(1)) // returns 1, freq of key 1 becomes 2
cache.Put(3, 3) // evicts key 2 (lowest freq, LRU among freq1)
fmt.Println(cache.Get(2)) // returns -1 (evicted)
fmt.Println(cache.Get(3)) // returns 3
fmt.Println(cache.Get(1)) // returns 1
cache.Put(4, 4) // evicts key 3 (freq1, LRU among freq1)
fmt.Println(cache.Get(3)) // returns -1 (evicted)
fmt.Println(cache.Get(4)) // returns 4
fmt.Println(cache.Get(1)) // returns 1
}
Expected output:
1
-1
3
1
-1
4
1
Step 8: Adding Concurrency Safety
The implementation above is not safe for concurrent use. In production, wrap the cache with a sync.RWMutex or use a sharded design for higher throughput under contention.
package lfu
import "sync"
type SafeLFUCache struct {
mu sync.RWMutex
inner *LFUCache
}
func NewSafe(capacity int) *SafeLFUCache {
return &SafeLFUCache{inner: New(capacity)}
}
func (s *SafeLFUCache) Get(key int) int {
s.mu.Lock()
defer s.mu.Unlock()
return s.inner.Get(key)
}
func (s *SafeLFUCache) Put(key, value int) {
s.mu.Lock()
defer s.mu.Unlock()
s.inner.Put(key, value)
}
For read-heavy workloads, you might consider a copy-on-write approach or a sync.Map-style structure, but the mutex wrapper above is sufficient for most applications.
Best Practices
- Choose capacity carefully. Too small and the cache thrashes; too large and you waste memory. Benchmark with realistic workloads before settling on a number.
- Guard against zero or negative capacity. Always check
capacity <= 0inPutto avoid panics or silent corruption. - Consider aging. Pure LFU can retain stale popular items forever. Periodically decay frequencies (for example, halve all frequencies every N operations) to let new hot items rise.
- Use generics for type flexibility. Go 1.18+ supports type parameters, so you can generalize the cache to
LFUCache[K comparable, V any]instead of hardcodingintkeys and values. - Add metrics. Track hit rate, eviction count, and average frequency. These observability signals are invaluable for tuning.
- Test edge cases. Cover capacity 1, capacity 0, repeated access to the same key, and eviction tie-breaking between multiple keys at the same frequency.
- Avoid premature optimization. The
O(1)design here is already optimal for the LFU contract. Only shard or lock-strip if profiling reveals contention.
Generic Version (Go 1.18+)
For modern Go codebases, a generic version is more reusable. Here is the same cache adapted to type parameters:
package lfu
type gnode[K comparable, V any] struct {
key K
value V
freq int
prev *gnode[K, V]
next *gnode[K, V]
}
type glist[K comparable, V any] struct {
head, tail *gnode[K, V]
size int
}
func newGList[K comparable, V any]() *glist[K, V] {
head := &gnode[K, V]{}
tail := &gnode[K, V]{}
head.next = tail
tail.prev = head
return &glist[K, V]{head: head, tail: tail}
}
func (l *glist[K, V]) pushFront(n *gnode[K, V]) {
n.prev = l.head
n.next = l.head.next
l.head.next.prev = n
l.head.next = n
l.size++
}
func (l *glist[K, V]) remove(n *gnode[K, V]) {
n.prev.next = n.next
n.next.prev = n.prev
n.prev = nil
n.next = nil
l.size--
}
func (l *glist[K, V]) back() *gnode[K, V] {
if l.size == 0 {
return nil
}
return l.tail.prev
}
type Cache[K comparable, V any] struct {
capacity int
minFreq int
nodes map[K]*gnode[K, V]
freqs map[int]*glist[K, V]
}
func NewCache[K comparable, V any](capacity int) *Cache[K, V] {
return &Cache[K, V]{
capacity: capacity,
nodes: make(map[K]*gnode[K, V]),
freqs: make(map[int]*glist[K, V]),
}
}
func (c *Cache[K, V]) promote(n *gnode[K, V]) {
oldFreq := n.freq
newFreq := oldFreq + 1
oldList := c.freqs[oldFreq]
oldList.remove(n)
if oldList.size == 0 {
delete(c.freqs, oldFreq)
if c.minFreq == oldFreq {
c.minFreq = newFreq
}
}
n.freq = newFreq
newList, ok := c.freqs[newFreq]
if !ok {
newList = newGList[K, V]()
c.freqs[newFreq] = newList
}
newList.pushFront(n)
}
func (c *Cache[K, V]) Get(key K) (V, bool) {
var zero V
n, ok := c.nodes[key]
if !ok {
return zero, false
}
c.promote(n)
return n.value, true
}
func (c *Cache[K, V]) Put(key K, value V) {
if c.capacity <= 0 {
return
}
if n, ok := c.nodes[key]; ok {
n.value = value
c.promote(n)
return
}
if len(c.nodes) >= c.capacity {
evictList := c.freqs[c.minFreq]
lru := evictList.back()
evictList.remove(lru)
delete(c.nodes, lru.key)
if evictList.size == 0 {
delete(c.freqs, c.minFreq)
}
}
n := &gnode[K, V]{key: key, value: value, freq: 1}
c.nodes[key] = n
if _, ok := c.freqs[1]; !ok {
c.freqs[1] = newGList[K, V]()
}
c.freqs[1].pushFront(n)
c.minFreq = 1
}
Using the generic version is straightforward:
cache := lfu.NewCache[string, []byte](1000)
cache.Put("user:42", []byte("payload"))
val, ok := cache.Get("user:42")
Complexity Analysis
Both Get and Put run in O(1) average time. Here is why each step is constant:
- Hash map lookup and insertion:
O(1)average. - Linked list node removal and insertion:
O(1)since we hold direct pointers. - Frequency map bucket access:
O(1)average. minFrequpdates:O(1)because we only increment it during promotion or reset it to 1 on new insertion.
Space complexity is O(capacity) — each entry occupies one node plus map overhead.
Common Pitfalls
- Forgetting to reset
minFreqon new insertions. A newly inserted node always has frequency 1, sominFreqmust be set to 1. Forgetting this causes evictions to look in the wrong bucket. - Not cleaning up empty frequency buckets. Leaving empty lists in the
freqsmap wastes memory and can causeback()to returnnilunexpectedly. - Breaking the recency tie incorrectly. When evicting, you must pick the least recently used node at
minFreq, not an arbitrary one. The doubly linked list ordering enforces this. - Using a single global lock under heavy load. This serializes all access. Consider sharding by key hash for better concurrency.
Conclusion
Implementing an LFU cache in Go is a rewarding exercise that combines hash maps, doubly linked lists, and careful state tracking into a single elegant data structure. By maintaining a frequency-to-list map and a minFreq pointer, we achieve O(1) operations for both reads and writes while correctly handling the recency tie-break that distinguishes LFU from naive frequency counting. Whether you use the concrete or generic version, remember to wrap it with synchronization for concurrent environments, add observability metrics, and consider frequency aging to prevent stale popular items from dominating the cache. With these pieces in place, you have a robust, production-ready LFU cache that can serve as the backbone for database result caching, API response memoization, or any workload where access frequency is the right eviction signal.