Coding interview patterns in Go: rate limiter, worker pool, LRU, pub/sub
The last lesson is the live-coding round. The interviewer slides a laptop across and says "build me a rate limiter" or "build me an LRU cache". You have thirty minutes and a blank file. This lesson is the four answers you should have in your head.
Each one is small enough to write from memory. Together they cover most of what you need to design a production cache layer, a job queue, a notification bus, or an API gateway. The same patterns recur over and over in real Go services. Internalise them once, reuse for years.
A note on style before we start. The interviewer is also watching how you reason, not just what you produce. Talk through the ownership of every channel, the cancellation story, and the failure modes as you write. A correct solution explained badly scores lower than a slightly buggy solution explained well, because the explanation reveals whether you actually understand the trade-offs.
Pattern 1: Token bucket rate limiter
A token bucket limits operations per second while allowing bursts up to a bucket size. The implementation is a buffered channel plus a ticker that refills it.
type Limiter struct {
tokens chan struct{}
stop chan struct{}
}
func NewLimiter(rate time.Duration, burst int) *Limiter {
l := &Limiter{
tokens: make(chan struct{}, burst),
stop: make(chan struct{}),
}
for i := 0; i < burst; i++ {
l.tokens <- struct{}{} // prefill
}
go func() {
t := time.NewTicker(rate)
defer t.Stop()
for {
select {
case <-t.C:
select {
case l.tokens <- struct{}{}:
default: // full, drop
}
case <-l.stop:
return
}
}
}()
return l
}
func (l *Limiter) Allow() bool {
select {
case <-l.tokens:
return true
default:
return false
}
}
func (l *Limiter) Wait(ctx context.Context) error {
select {
case <-l.tokens:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (l *Limiter) Close() { close(l.stop) }Two API methods, two behaviours. Allow is non-blocking, returns false when there is no token. Wait blocks until a token is available or the context is cancelled. Both share the same bucket.
The default clause in the refill goroutine prevents the ticker from blocking when the bucket is full. The stop channel makes the limiter cleanly shutdownable.
Pattern 2: Worker pool
A worker pool dispatches N goroutines that read jobs off one channel and write results onto another. The harness coordinates with a WaitGroup.
type Job struct {
ID int
Input string
}
type Result struct {
JobID int
Output string
Err error
}
func RunPool(ctx context.Context, jobs []Job, workers int) []Result {
jobCh := make(chan Job)
resCh := make(chan Result, len(jobs))
var wg sync.WaitGroup
// Start N workers
for i := 0; i < workers; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case j, ok := <-jobCh:
if !ok {
return
}
resCh <- process(j)
}
}
}(i)
}
// Feed jobs
go func() {
defer close(jobCh)
for _, j := range jobs {
select {
case <-ctx.Done():
return
case jobCh <- j:
}
}
}()
// Close results when workers done
go func() {
wg.Wait()
close(resCh)
}()
var results []Result
for r := range resCh {
results = append(results, r)
}
return results
}
func process(j Job) Result {
return Result{JobID: j.ID, Output: strings.ToUpper(j.Input)}
}The pieces. One job channel, N worker goroutines that range over it. A feeder goroutine that closes the job channel when the input is exhausted. A coordinator goroutine that waits for all workers and then closes the result channel. The main goroutine ranges over results until the channel is closed.
Every send and receive checks ctx.Done(), so cancellation propagates. The feeder respects cancellation too, which means a cancelled call returns quickly even with a long job list.
Senior rule: implement first with channels because the structure documents itself. Profile second. Switch to
sync.Mutexorsync/atomiconly when pprof says the channel itself is the bottleneck. It usually is not.
Pattern 3: LRU cache
An LRU cache evicts the least-recently-used entry when full. The canonical implementation is a hash map for O(1) lookup plus a doubly-linked list for O(1) reordering on access. Go's container/list gives you the list for free.
type entry struct {
key string
val any
}
type LRU struct {
cap int
ll *list.List
items map[string]*list.Element
mu sync.Mutex
}
func NewLRU(capacity int) *LRU {
return &LRU{
cap: capacity,
ll: list.New(),
items: make(map[string]*list.Element, capacity),
}
}
func (c *LRU) Get(key string) (any, bool) {
c.mu.Lock()
defer c.mu.Unlock()
if el, ok := c.items[key]; ok {
c.ll.MoveToFront(el)
return el.Value.(*entry).val, true
}
return nil, false
}
func (c *LRU) Put(key string, val any) {
c.mu.Lock()
defer c.mu.Unlock()
if el, ok := c.items[key]; ok {
el.Value.(*entry).val = val
c.ll.MoveToFront(el)
return
}
if c.ll.Len() == c.cap {
oldest := c.ll.Back()
if oldest != nil {
c.ll.Remove(oldest)
delete(c.items, oldest.Value.(*entry).key)
}
}
el := c.ll.PushFront(&entry{key: key, val: val})
c.items[key] = el
}Front of the list is most-recently-used, back is least-recently-used. Every Get and Put moves the touched element to the front. When inserting into a full cache, we pop the back, delete its key from the map, and push the new entry to the front.
The mutex is necessary because container/list is not safe for concurrent use. For a hotter cache, you can shard by key hash and have N smaller LRUs, each with its own mutex, to reduce contention.
Interview trap: candidates implement the linked list themselves and waste 10 minutes on prev/next bookkeeping.
container/listexists, use it. The interesting part is the LRU policy, not the list mechanics.
Pattern 4: In-process pub/sub
A pub/sub bus lets producers publish to a topic and subscribers receive everything for topics they care about. The implementation is a topic-keyed map of subscriber channels, guarded by a sync.RWMutex.
type Bus struct {
mu sync.RWMutex
subs map[string][]chan any
}
func NewBus() *Bus {
return &Bus{subs: make(map[string][]chan any)}
}
func (b *Bus) Subscribe(topic string, buf int) <-chan any {
ch := make(chan any, buf)
b.mu.Lock()
b.subs[topic] = append(b.subs[topic], ch)
b.mu.Unlock()
return ch
}
func (b *Bus) Publish(topic string, msg any) {
b.mu.RLock()
subs := b.subs[topic]
b.mu.RUnlock()
for _, ch := range subs {
select {
case ch <- msg:
default: // slow subscriber, drop
}
}
}
func (b *Bus) Close() {
b.mu.Lock()
defer b.mu.Unlock()
for _, subs := range b.subs {
for _, ch := range subs {
close(ch)
}
}
b.subs = nil
}Three design choices worth narrating. Each subscriber gets its OWN channel, so a slow consumer cannot block other consumers. The default in Publish drops messages rather than blocking the publisher (a backpressure policy you would replace with "block" or "log and drop" depending on requirements). The RWMutex lets concurrent publishes proceed in parallel because they only need a read lock; only Subscribe and Close take the write lock.
For real production use, you would add per-subscriber unsubscribe, message metadata, and probably swap the slice-per-topic for a sync.Map keyed by subscriber ID. But the 30-minute version above is what the interviewer wants to see.
Bonus: the semaphore one-liner
The semaphore from the channel-patterns lesson recapped in one line:
sem := make(chan struct{}, N)
// acquire: sem <- struct{}{}
// release: <-semUse it when you want bounded concurrency without spinning up a worker pool. It is the simplest of all the patterns and the one you should reach for first when N goroutines need a cap.
Interview patterns
- token bucket
- Rate-limit algorithm: bucket refills at a fixed rate up to a cap; each operation takes a token. Implemented in Go as buffered chan + ticker.
- worker pool
- N goroutines reading jobs off a shared channel and writing results to another. Coordinated by WaitGroup.
- LRU cache
- Eviction policy that discards the least-recently-used entry when full. Map + doubly-linked list gives O(1) get and put.
- pub/sub bus
- Producers publish to a topic, subscribers receive on per-subscriber channels. Decouples sender from receivers.
- container/list
- Stdlib doubly-linked list. Not safe for concurrent use, but ideal as the inner structure of an LRU under a mutex.
- backpressure
- Mechanism by which a slow consumer signals the producer to slow down. In Go, expressed as a blocking send, a default-clause drop, or a context cancellation.
You have crossed into senior territory
If you can write the rate limiter, worker pool, LRU, and pub/sub from memory in 30 minutes each, you have crossed into senior territory. The same building blocks (channel, select, WaitGroup, RWMutex, context) compose into every concurrent system you will be asked to design.
Senior rule: implement first with channels for the simplicity. Profile second. Switch to atomic or mutex only when pprof says so. The biggest mistake in senior Go interviews is over-engineering the first draft. Write the obvious channel-based version, name the trade-offs out loud, and let the interviewer drive you toward the optimisation only if they care.
Common questions
Q.In your LRU implementation, why use container/list?›
Q.What is the simplest rate limiter in Go?›
Watching quietly. Tap me if you want a tip.
Go cannot run natively in a browser. Run copies your code and opens go.dev/play ; paste and click Run there.
Try this (0 of 1 done)
- 1
Predict the output. (Did "b" get evicted?)
hint
Capacity is 2. After Put(c,3), the LRU evicts b because a was used most recently.
show answer
// already in the editor