Open-source project
jellydator/ttlcache avatar
jellydator/ttlcache

ttlcache gives Go a typed in-memory cache with per-item expiry

An in-memory cache with item expiration and generics

1,300 stars152 forksGoMIT

At a glance

What is it?
ttlcache is a MIT licensed Go cache built on type parameters, with per-item or cache-wide TTL, automatic cleanup, a Loader interface for lazy loading, cost-based capacity limits and event handlers. It suits Go services that want a small in-process cache, and it is a v3 module with its own import path.
Who is it for?
Adopt ttlcache if you want a small typed in-process cache in Go with per-item expiry, a loader for lazy population, and eviction events, without writing a background janitor yourself. Do not adopt it if you need a cache that survives a restart or is shared across processes, since it is entirely in memory.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 13 days ago.
What is it written in?
Mainly Go, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 21, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Two type parameters, and the whole API is on the Cache type

ttlcache is an in-memory cache with item expiration and generics, and the API is built around two type parameters. The cache is created with ttlcache.New, and the key and value types are given at that point:

go
func main() {
	cache := ttlcache.New[string, string]()
}

The type parameters are repeated in every option, which looks verbose but is the price of Go's generics: an option is a function over a concrete instantiation of the cache, so ttlcache.WithTTL[string, string](30 * time.Minute) has to be told which instantiation it applies to. Once a cache exists, the operations are untyped in the sense that they take the key type and return the value type, and the compiler checks it.

The installation is one command:

bash
go get github.com/jellydator/ttlcache/v3

Note the major version in the import path. The default branch is v3, the go.mod declares the module as github.com/jellydator/ttlcache/v3, and the go.mod requires Go 1.26.0. The major version in the import path is the standard Go mechanism for signalling a breaking change.

The repository is laid out as a flat package at the root rather than a directory per file, with cache.go, item.go, metrics.go, options.go and expiration_queue.go alongside their test files, plus a bench/ directory, an examples/ directory and a go.mod. The dependencies are minimal: stretchr/testify for the tests, go.uber.org/goleak for goroutine leak detection, and golang.org/x/sync.

That goleak dependency is worth noticing, because it tells you what the maintainers consider the risky part of this library. A cache with a background cleanup goroutine and a set of event handler goroutines is exactly the shape of code that leaks goroutines, and the test suite checks for that on every run.

Items never expire until you set a TTL and start the cleanup goroutine

The default behaviour of a new cache is worth stating plainly, because it is the opposite of what the name suggests.

By default, items never expire and are never removed automatically. Expiration is enabled by setting a default TTL with the ttlcache.WithTTL() option and starting the automatic cleanup process with the cache.Start() method. And since cache.Start() blocks until cache.Stop() is called, it is usually launched on a separate goroutine:

go
func main() {
	cache := ttlcache.New[string, string](
		ttlcache.WithTTL[string, string](30 * time.Minute),
	)

	go cache.Start() // starts automatic expired item deletion
	defer cache.Stop()
}

So there are two switches to turn expiry on, not one. Set the TTL, and start the goroutine. Forget the second and items carry an expiry timestamp but are never reclaimed.

That is a deliberate design choice rather than an oversight, and the README explains when the automatic cleanup is the wrong answer. Some applications need to control the exact timing of expired item deletion, for example a system that wants to delete such items only when its resource load is at its lowest, such as after midnight when the number of users or HTTP requests drops. In those cases the cache.DeleteExpired() method can be called periodically instead of starting the cleanup process:

go
func main() {
	cache := ttlcache.New[string, string](
		ttlcache.WithTTL[string, string](30 * time.Minute),
	)

	for {
		time.Sleep(4 * time.Hour)
		cache.DeleteExpired()
	}
}

A janitor thread that wakes periodically is a predictable background cost, and for a service with a large cache and a low-traffic window, deleting in a batch once a night is cheaper than a timer firing constantly. The cost of that choice is that you have to remember to call it, and an application that switches from automatic to manual cleanup and forgets the loop will accumulate expired items indefinitely.

Per-item TTL, and the sliding window that a Get can create

Each item gets its own expiry, and there are three ways to specify it. The first is a specific duration, the second is ttlcache.DefaultTTL to use the cache's default, and the third is ttlcache.NoTTL to never expire:

go
cache.Set("first", "value1", ttlcache.DefaultTTL)
cache.Set("second", "value2", ttlcache.NoTTL)
cache.Set("third", "value3", time.Minute)

The item returned by Get is not just a value. It carries the expiry time, which the README demonstrates by printing both:

go
item := cache.Get("first")
fmt.Println(item.Value(), item.ExpiresAt())

The full set of operations is Set, Get, Has, Delete and related methods, plus DeleteExpired and DeleteAll for bulk removal, GetOrSet for retrieve-or-insert, and GetAndDelete for retrieve and remove in one step. GetOrSet takes an option rather than a plain duration, so the call reads cache.GetOrSet("fourth", "value4", ttlcache.WithTTL[string, string](time.Minute)).

There is one behaviour in the feature list that changes the meaning of a TTL and is easy to miss. Automatic expiration time extension on each Get call, and it can be disabled. A sliding TTL means a hot key never expires, because something keeps reading it, while a cold key expires on schedule. That is the right behaviour for a session cache and the wrong behaviour for a cache where you want data to go stale regardless of popularity, which is why the switch to turn it off is there.

The features list also names thread safety, which for a cache used across request handlers is not a feature so much as a precondition. The library is safe for concurrent use, and the goroutine leak tests in the test suite exist to keep that property honest.

The Loader interface turns a cache miss into a fetch, with optional suppression

The Loader interface is the feature that lets a cache sit in front of something expensive, and the README describes it as an interface that may be used to load or lazily initialize missing cache items, with optional duplicate call suppression.

The Get method calls the loader whenever the requested item is not found and returns whatever the loader returns. A custom or existing implementation of the ttlcache.Loader interface can be used, and the function form is the easiest way in:

go
loader := ttlcache.LoaderFunc[string, string](
	func(c *ttlcache.Cache[string, string], key string) *ttlcache.Item[string, string] {
		// load from file/make an HTTP request
		item := c.Set(key, "value from file", ttlcache.DefaultTTL)
		return item
	}
)
cache := ttlcache.New[string, string](
	ttlcache.WithLoader[string, string](loader),
)

Note the shape of the loader function. It receives the cache and the key, and it is expected to insert the value and return the item. That means the loader is not a pure fetch, it is a fetch plus a store, and the cache stores what the loader already stored.

The suppression option addresses a real problem. When multiple goroutines request the same missing item at once, the loader normally runs once for each of them. Wrapping it with ttlcache.NewSuppressedLoader() ensures that only one load operation is in-flight for a given key at a time, with all callers receiving its result:

go
cache := ttlcache.New[string, string](
	ttlcache.WithLoader[string, string](ttlcache.NewSuppressedLoader(loader, nil)),
)

That is single-flight behaviour, and it matters whenever the loader hits a database or an upstream service. Without it, a cache miss on a hot key becomes a thundering herd against the thing you are caching. The cost is that a caller can wait on another goroutine's load, so a slow or hanging loader becomes a slow or hanging read, and the caller has no way to time out of it.

The examples directory has two complete applications, dbcache and httpcache, which are the natural place to see the loader wired to a real backend.

Eviction events, cost-based capacity, and metrics

The remaining features are about what happens when the cache fills up, and they are the part that distinguishes this from a map with a timestamp on each entry.

Capacity limits are based on the number of items or their custom-calculated cost. The count-based version is a constructor option, ttlcache.WithCapacity, and the cost-based version is ttlcache.WithMaxCost, which assigns each item a cost calculated by a custom function and evicts the least recently used items whenever the total cost exceeds the given limit. The README's example limits the memory used by cached entries to about 5KiB:

go
cache := ttlcache.New[string, string](
	ttlcache.WithMaxCost[string, string](5120, func(item ttlcache.CostItem[string, string]) uint64 {
		// Note: the calculation below does not include the memory
		// used by the internal structures or the string metadata of
		// the key and the value.
		return uint64(len(item.Key) + len(item.Value))
	})
)

The comment in that example is the important part. The cost function measures the key and the value, not the memory the entry actually occupies, because the internal structures and the string metadata are not counted. So a WithMaxCost of 5120 is a rough bound, not a memory guarantee, and an application that is trying to stay under a hard memory limit needs to account for the overhead itself.

The eviction is least recently used, which means a cost limit and a TTL are two different eviction policies in the same cache, and they can disagree about which entry goes first.

The event handlers are the observability side. The cache.OnInsertion(), cache.OnUpdate(), and cache.OnEviction() methods subscribe to the cache's events, and the subscribed functions are executed on separate goroutines, so they never block the cache's operations. Each subscription method returns a function that can be called to unsubscribe:

go
unsubscribe := cache.OnEviction(func(ctx context.Context, reason ttlcache.EvictionReason, item *ttlcache.Item[string, string]) {
	if reason == ttlcache.EvictionReasonCapacityReached {
		fmt.Println(item.Key(), item.Value())
	}
})

The eviction reason is what makes this useful, since the EvictionReason value tells you whether an entry went because the capacity was reached or because it expired, and those are very different signals. The handlers run on their own goroutines, which means they cannot slow the cache down, but it also means a handler that panics takes down its own goroutine rather than the cache, and the ordering between handlers and the operations that triggered them is not guaranteed.

The features list also includes metrics, and there is a metrics.go file at the root of the repository, so the cache can report its own hit rates and sizes without an external agent.

Editorial conclusion

Adopt ttlcache if you want a small typed in-process cache in Go with per-item expiry, a loader for lazy population, and eviction events, without writing a background janitor yourself. Do not adopt it if you need a cache that survives a restart or is shared across processes, since it is entirely in memory. Before you import it, note that the v3 line lives under its own module path, github.com/jellydator/ttlcache/v3, that the default behaviour is that items never expire until you both set a TTL and start the cleanup goroutine, and that the Go version required by the current module is 1.26.

Frequently asked questions

How do I install and create a ttlcache cache?

Run go get github.com/jellydator/ttlcache/v3, then create a cache with ttlcache.New[string, string](). The two type parameters are the key and value types, and they are repeated on every option call because each option applies to a concrete instantiation.

Why are my cached items never expiring?

By default items never expire and are never removed automatically. Expiration needs both a default TTL set with ttlcache.WithTTL and the cleanup process started, usually as go cache.Start() with a defer cache.Stop(), or periodic calls to cache.DeleteExpired() instead.

How do I load a value on a cache miss?

Implement the ttlcache.Loader interface or use ttlcache.LoaderFunc, pass it with ttlcache.WithLoader, and the Get method calls the loader whenever the item is not found. Wrapping it with ttlcache.NewSuppressedLoader() makes only one load run at a time per key, with all callers receiving its result.

Can I limit the cache by memory rather than by number of items?

Yes. ttlcache.WithMaxCost takes a limit and a function that calculates each item's cost, and the least recently used items are evicted when the total exceeds the limit. The README notes the calculation does not include the internal structures or the string metadata of the key and value.

How do I know when entries are evicted?

Subscribe with cache.OnInsertion(), cache.OnUpdate() or cache.OnEviction(). The handlers receive a context and an item, and the eviction handler also receives an ttlcache.EvictionReason so you can tell a capacity eviction from an expiry. Handlers run on separate goroutines so they never block cache operations, and each subscription returns a function to unsubscribe.

Official sources

  1. Issues
  2. jellydator/ttlcache on GitHub
  3. License: MIT
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/jellydator-ttlcache.svg)](https://hysenlabs.com/projects/jellydator-ttlcache)

Was this useful?