I recently wanted to build a high-performance caching mechanism. There are multiple caching strategies you can design, but it really depends on your business requirements. In this blog, I’ll share my thought process on how I decided to implement a workerpool to handle caching.

The Requirement

My requirement was straightforward, get data from the cache if it exists, otherwise fetch the missing data from the database and return the result. While returning the result, the missing data should be stored in the cache to avoid hitting the database again on subsequent requests.

This is essentially a combination of Lazy Loading + Async Cache Population.

The Development Challenge

As a backend engineer, my main challenge was building an async cache population mechanism inside a high-load server. Each request needs to be handled concurrently, so without a second thought, I started designing a workerpool.

The Design Challenge

Initially, I built a workerpool to handle cache population, and it satisfied my immediate requirement. But then I thought what if I need to design a similar workerpool for another concurrent caching operation?

I took a step back. Instead of jumping straight into a raw implementation, I wanted to build a Workerpool Design Pattern. That way, next time I need a workerpool, I wouldn’t have to design it from scratch,by “scratch” I mean setting up workers, configuring channels for specific operations, and managing wait groups.

The Design

After implementing this design, I only need to follow three simple steps:

  • Initialize the pool
  • Start the workers
  • Create a job and pass it to the workerpool

Here’s how the workerpool behaves at a high level.

Implementation Approach

The code approach looks like this:

1. Initialize the Workerpool

type Pool struct {
	workersCount int
	jobChanel    chan Job
	wg           *sync.WaitGroup
	quit         chan struct{}
}

func InitPool(workersCount int, chanelSize int) *Pool {
	return &Pool{
		workersCount: workersCount,
		jobChanel:    make(chan Job, chanelSize),
		quit:         make(chan struct{}),
		wg:           &sync.WaitGroup{},
	}
}

func (p *Pool) InitWorkers(ctx context.Context) {
	for i := 0; i < p.workersCount; i++ {
		p.wg.Add(1)
		go p.worker(ctx)
	}
}

Then you can initialize the workerpool like this:

// Build Worker Pool (Workers + Pool)
redisSettingPool := workerpool.InitPool(10, 100)
redisSettingPool.InitWorkers(ctx)

2. Making the Workerpool Flexible for Different Tasks

Here’s where I used a neat trick: I defined the channel input type as an interface. This means any job I design can implement that interface, and the worker can execute the interface method.

type Job interface {
	Execute(ctx context.Context) error
}

This simple interface allows you to create any job type that implements this method. Here’s an example:

type foodSaveJob struct {
	foodRecommendedCache cache.FoodRecommendedCache
	locationIndex, passenderId, foodIdsStr string
}

func NewFoodRecommendedJob(
	foodRecommendedCache cache.FoodRecommendedCache,
	passenderId, foodIdsStr string,
) *foodSaveJob {
	return &foodSaveJob{
		foodRecommendedCache: foodRecommendedCache,
		passenderId: passenderId,
		foodIdsStr: foodIdsStr,
	}
}

func (fsj *foodSaveJob) Execute(ctx context.Context) error {
	keySuffix := fmt.Sprintf("%s",fsj.passenderId)
	err := fsj.foodRecommendedCache.SetFood(ctx, keySuffix, fsj.foodIdsStr)
	if err != nil {
		return err
	}
	return nil
} 

3. Submitting Jobs to the Pool

Using this simple technique, you can create your own workerpool to perform dedicated tasks with just a few lines of code. Here’s how to pass created jobs to the channel:

foodMap := make(map[string]string)
foodMap["1234"] = "3423,3242,4564,3423"
foodMap["4567"] = "0808,3657,4957,0089"

for passengerId, foodList := range foodMap {
    job := workerpool.NewFoodRecommendedJob(
        foodRecommendedCache,
        passengerId,
        foodList,
    )
    redisSettingPool.AddJob(job)
}

Impact

After implementing this pattern Now? I just spin up a workerpool, define a job struct, and I’m done. What I love most is that this approach scales naturally.

Source Code

The complete implementation of the async cache population worker pool is available here:

👉 GitHub Repo

Conclusion

When you need to design highly available, concurrent-handling operations, setting up a workerpool is essential. Building it as a reusable blueprint like this makes your codebase much more maintainable and scalable. More importantly, it saves you and your team from reinventing the wheel every time you need concurrent processing.