ToolPopToolPop
Go · Lesson 22 of 22

Coding interview patterns in Go: rate limiter, worker pool, LRU, pub/sub

10 min readUpdated 25 Jun 2026

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.

go
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.

go
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.Mutex or sync/atomic only when pprof says the channel itself is the bottleneck. It usually is not.

Diagram
rendering diagram...
Worker pool: feeder, N workers, results, coordinator closes when WaitGroup signals

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.

go
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/list exists, 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.

go
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:

go
sem := make(chan struct{}, N)
// acquire: sem <- struct{}{}
// release: <-sem

Use 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?
A.It is the standard library's doubly-linked list with O(1) MoveToFront, PushFront, and Remove operations. Combined with a map for O(1) lookup, you get the canonical LRU: O(1) Get and Put.
Q.What is the simplest rate limiter in Go?
A.A buffered channel filled by a time.Ticker. Each request consumes one token. If the channel is empty, the request waits. This is the token bucket pattern in ~10 lines. For production, use golang.org/x/time/rate which is the standard.
Chai0/1 done

Watching quietly. Tap me if you want a tip.

Go Playground

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. 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