DEV Community

Cover image for Implementing Thread-Safe In-Memory Cache in Go Part 2: Introducing Expiration to cache (TTL)
Ganesh Kumar
Ganesh Kumar

Posted on

Implementing Thread-Safe In-Memory Cache in Go Part 2: Introducing Expiration to cache (TTL)

Hello, I'm Ganesh. I'm working on FreeDevTools online, currently building a single platform for all development tools, cheat codes, and TL; DRs — a free, open-source hub where developers can quickly find and use tools without the hassle of searching the internet.

In the previous article, we learnt how cache will help in improving performance and efficiency, and how to implement a simple in-memory cache. If you have not read it, you can check it out here link.

The previous implementation did not handle expiration, which is crucial for any application. Now, let's modify the existing code to store the TTL (Time To Live).

Core Logic Behind Cache with Expiration (TTL)

Since each cache entry can have a defined TTL, we create a CacheItem struct that contains interface{} for storing value and an expiry time field.

1. The Structure

Instead of storing just raw data, we now wrap every value in a CacheItem struct:

type CacheItem struct {
    value  interface{} // The actual data (e.g., "mohit", 75)
    expiry time.Time   // The exact timestamp when this data dies (e.g., 10:05:00 PM)
}
Enter fullscreen mode Exit fullscreen mode

The Cache map now stores string -> CacheItem.

2. How it Works (Logic)

  • Adding Data
    When you add data, you must specify how long it should live.
    Expiry Time = Current Time + TTL

  • Retrieving Data
    This is the smartest part.
    The cache does not run a background timer to delete old keys (which consumes CPU). Instead, it checks at the moment you ask for the data.

Lookup: It finds the item in the map.
Check: Is Current Time > Expiry Time?
If Yes (Expired): It explicitly deletes the item right now and tells you it wasn't found (false).
If No (Valid): It returns the value.

Implementing Thread-Safe In-Memory Cache with Expiration (TTL)

We just need to update the cache structure, set function, and get function.

package main

import (
    "fmt"
    "sync"
    "time"
)

// CacheItem represents an item stored in the cache with its associated TTL.
type CacheItem struct {
    value interface{}
    expiry time.Time // TTL for a key
}

// Cache represents an in-memory key-value store with expiry support.
type Cache struct {
    data map[string]CacheItem
    mu   sync.RWMutex
}

// NewCache creates and initializes a new Cache instance.
func NewCache() *Cache {
    return &Cache{
        data: make(map[string]CacheItem),
    }
}

// Set adds or updates a key-value pair in the cache with the given TTL.
func (c *Cache) Set(key string, value interface{}, ttl time.Duration) {
    c.mu.Lock()
    defer c.mu.Unlock()

    c.data[key] = CacheItem{
        value:  value,
        expiry: time.Now().Add(ttl),
    }
}

// Get retrieves the value associated with the given key from the cache.
// It also checks for expiry and removes expired items.
func (c *Cache) Get(key string) (interface{}, bool) {
    c.mu.Lock()
    defer c.mu.Unlock()

    item, ok := c.data[key]
    if !ok {
        return nil, false
    }
    // item found - check for expiry
    if item.expiry.Before(time.Now()) {
        // remove entry from cache if time is beyond the expiry
        delete(c.data, key)
        return nil, false
    }
    return item.value, true
}

// Delete removes a key-value pair from the cache.
func (c *Cache) Delete(key string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    delete(c.data, key)
}

// Clear removes all key-value pairs from the cache.
func (c *Cache) Clear() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.data = make(map[string]CacheItem)
}

func main() {
    cache := NewCache()

    // Adding data to the cache with a TTL of 2 seconds
    cache.Set("name", "mohit", 2*time.Second)
    cache.Set("weight", 75, 5*time.Second)

    // Retrieving data from the cache
    if val, ok := cache.Get("name"); ok {
        fmt.Println("Value for name:", val)
    }

    // Wait for some time to see expiry in action
    time.Sleep(3 * time.Second)

    // Retrieving expired data from the cache
    if _, ok := cache.Get("name"); !ok {
        fmt.Println("Name key has expired")
    }

    // Retrieving data before expiry
    if val, ok := cache.Get("weight"); ok {
        fmt.Println("Value for weight before expiry:", val)
    }

    // Wait for some time to see expiry in action
    time.Sleep(3 * time.Second)

    // Retrieving expired data from the cache
    if _, ok := cache.Get("weight"); !ok {
        fmt.Println("Weight key has expired")
    }

    // Deleting data from the cache
    cache.Set("key", "val", 2*time.Second)
    cache.Delete("key")

    // Clearing the cache
    cache.Clear()

    time.Sleep(time.Second) // Sleep to allow cache operations to complete
}
Enter fullscreen mode Exit fullscreen mode

With this expiry support, our cache implementation becomes more versatile and suitable for a wider range of caching scenarios.

On running the code.

gk@jarvis:~/exp/code/cache$ go run main.go 
Value for name: mohit
Name key has expired
Value for weight before expiry: 75
Weight key has expired
Enter fullscreen mode Exit fullscreen mode

By this we can verify it correctly storing and handling TTL.

Conclusion

Now we designed an In-Memory Cache to handle expiry check.

So, that user won't see old data, and our DB won't suffer from multiple requests.


FreeDevTools

I’ve been building for FreeDevTools.

A collection of UI/UX-focused tools crafted to simplify workflows, save time, and reduce friction when searching for tools and materials.

Any feedback or contributions are welcome!

It’s online, open-source, and ready for anyone to use.

👉 Check it out: FreeDevTools

⭐ Star it on GitHub: freedevtools

Sources

  1. https://medium.com/on-building-software/why-cache-invalidation-is-actually-hard-e8b5e9a83e45

  2. https://www.mohitkhare.com/blog/go-in-memory-cache/

Top comments (0)