Home About Me

When One Request Should Serve Many: Singleflight in Go

Go's singleflight.Group is a small tool with a very specific job: when several goroutines ask for the same key at the same time, only one of them runs the underlying function. The rest wait and receive the same result, along with a shared flag that tells them the value was reused. That makes it useful for cache misses, slow database reads, and calls to external services where duplicate work is wasteful.

Singleflight illustration

The first example keeps the operation deliberately simple. Three goroutines call Do with the same key, and the function sleeps for two seconds to imitate expensive work.

package main

import (
    "fmt"
    "time"

    "golang.org/x/sync/singleflight"
)

func main() {
    var group singleflight.Group

    // A function that simulates an expensive operation
    expensiveOperation := func(key string) (interface{}, error) {
        time.Sleep(2 * time.Second) // Simulate delay
        return fmt.Sprintf("Result for %s", key), nil
    }

    key := "unique-key"

    // Start multiple goroutines that call the expensive operation through singleflight
    for i := 0; i < 3; i++ {
        go func(id int) {
            result, err, shared := group.Do(key, func() (interface{}, error) {
                return expensiveOperation(key)
            })

            if err != nil {
                fmt.Printf("Goroutine %d received error: %v\n", id, err)
            } else {
                fmt.Printf("Goroutine %d received result: %v (shared: %v)\n", id, result, shared)
            }
        }(i)
    }

    // Wait for goroutines to finish
    time.Sleep(3 * time.Second)
}

The output shows the effect clearly: all three callers get Result for unique-key, and shared is true for each of them. The order of goroutine messages can vary, but only one execution does the two-second work.

Goroutine 0 received result: Result for unique-key (shared: true)
Goroutine 2 received result: Result for unique-key (shared: true)
Goroutine 1 received result: Result for unique-key (shared: true)

Cache breakdown, with Redis and PostgreSQL

A cache breakdown happens when a cached value expires or is removed and a burst of requests arrives at the same moment. Each request misses the cache and tries to rebuild it, so the database suddenly receives many identical queries. Caching alone does not solve that spike; the application still needs a way to coordinate the rebuild.

The second example builds a small HTTP service with Redis, PostgreSQL, and Gin. It exposes [GET] /gold/price to read the latest gold price and [POST] /gold to update it. The update endpoint writes a new row to PostgreSQL and deletes the Redis cache entry. The read endpoint fetches from Redis first; on a cache miss, it loads the price from PostgreSQL and writes it back to Redis.

The concurrency simulation is part of the program: GET requests are fired repeatedly, and a separate goroutine posts new prices on a timer. That makes cache invalidation and concurrent reads overlap. The SFL_ENABLE environment variable switches Singleflight on or off, so the same workload can be compared in both modes.

First, create the table and one sample row:

-- Create table
CREATE TABLE gold_realtime_prices (
    id SERIAL PRIMARY KEY,
    price NUMERIC NOT NULL,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Insert a sample row
INSERT INTO gold_realtime_prices (price) VALUES (575.25);

The full program is below. It uses Redis DB 3, counts API calls under visit_times, and counts database queries under query_times. The database read includes a 200 ms delay so that the overlap is easier to observe.

package main

import (
    "bytes"
    "database/sql"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "math/rand"
    "net/http"
    "os"
    "sync"
    "time"

    "github.com/gin-gonic/gin"
    "github.com/go-redis/redis/v8"
    _ "github.com/lib/pq"
    "golang.org/x/net/context"
    "golang.org/x/sync/singleflight"
)

var (
    ctx      = context.Background()
    rdb      *redis.Client
    db       *sql.DB
    sf       singleflight.Group
    mu       sync.Mutex
    cacheKey = "gold_price"
    visitKey = "visit_times"
    queryKey = "query_times"

    enable bool
)

func init() {
    // Read SFL_ENABLE from the environment
    enableEnv := os.Getenv("SFL_ENABLE")
    if enableEnv == "1" {
        enable = true
    } else {
        enable = false
    }
}

func initDB() error {
    var err error
    connStr := "postgresql://<pg-user>:<pg-passwd>@localhost:5432/<dbname>?sslmode=disable"
    db, err = sql.Open("postgres", connStr)
    if err != nil {
        return err
    }
    return db.Ping()
}

func initRedis() {
    rdb = redis.NewClient(&redis.Options{
        Addr: "localhost:6379",
        DB:   3, // Note: using DB 3
    })
}

func getGoldPriceFromDB() (float64, error) {
    time.Sleep(200 * time.Millisecond) // Add 200 ms to simulate database query time
    var price float64
    err := db.QueryRow("SELECT price FROM gold_realtime_prices ORDER BY updated_at DESC LIMIT 1").Scan(&price)
    return price, err
}

func withoutSingleFlight() (string, error) {

    // Count database queries
    rdb.Incr(ctx, queryKey)

    price, err := getGoldPriceFromDB()
    if err != nil {
        return "", err
    }
    priceStr := fmt.Sprintf("%.2f", price)

    // Update cache
    rdb.Set(ctx, cacheKey, priceStr, time.Hour)

    return priceStr, nil
}

func withSingleFlight() (string, error) {
    v, err, _ := sf.Do(cacheKey, func() (interface{}, error) {

        // Count database queries
        rdb.Incr(ctx, queryKey)

        price, err := getGoldPriceFromDB()
        if err != nil {
            return nil, err
        }
        priceStr := fmt.Sprintf("%.2f", price)

        // Update cache
        rdb.Set(ctx, cacheKey, priceStr, time.Hour)

        return priceStr, nil
    })
    if err != nil {
        return "", errors.New("could not get price")
    }
    return fmt.Sprintf("%.2f", v), nil
}

func getGoldPrice(c *gin.Context) {

    // Count API calls
    rdb.Incr(ctx, visitKey)

    price, err := rdb.Get(ctx, cacheKey).Result()
    if err == redis.Nil {

        if enable {
            v, err := withSingleFlight()
            if err != nil {
                c.JSON(http.StatusInternalServerError, gin.H{"error": "could not get price"})
                return
            }
            price = v
        } else {
            v, err := withoutSingleFlight()
            if err != nil {
                c.JSON(http.StatusInternalServerError, gin.H{"error": "could not get price"})
                return
            }
            price = v
        }
    } else if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "could not get price from cache"})
        return
    }

    c.JSON(http.StatusOK, gin.H{"price": price})
}

func updateGoldPrice(c *gin.Context) {
    var request struct {
        Price float64 `json:"price"`
    }
    if err := c.ShouldBindJSON(&request); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
        return
    }

    // Update the price in the database
    _, err := db.Exec("INSERT INTO gold_realtime_prices (price, updated_at) VALUES ($1, NOW())", request.Price)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "could not update price"})
        return
    }

    // Invalidate the cache
    rdb.Del(ctx, cacheKey)

    c.Status(http.StatusNoContent)
}

func main() {
    if err := initDB(); err != nil {
        panic(err)
    }
    initRedis()

    r := gin.Default()
    r.GET("/gold/price", getGoldPrice)
    r.POST("/gold", updateGoldPrice)

    // Start a goroutine that concurrently calls /gold/price
    go func() {
        simulatedAccess()
    }()

    // Start a goroutine that concurrently calls /gold
    go func() {
        simulatedUpdate()
    }()

    if err := r.Run(":8080"); err != nil {
        panic(err)
    }
}

func simulatedAccess() {

    time.Sleep(time.Second)

    startTime := time.Now()
    for time.Since(startTime) < 6*time.Second {
        go func() {
            resp, err := http.Get("http://localhost:8080/gold/price")
            if err != nil {
                fmt.Println("Error making GET request:", err)
            } else {
                body, _ := io.ReadAll(resp.Body)
                fmt.Println("Response from /gold/price:", string(body))
                resp.Body.Close()
            }
        }()
        time.Sleep(1 * time.Millisecond)
    }
}

func simulatedUpdate() {

    time.Sleep(1 * time.Second)

    ticker := time.NewTicker(500 * time.Millisecond)
    defer ticker.Stop()

    startTime := time.Now()
    for time.Since(startTime) < 5*time.Second {
        <-ticker.C // Wait for the next tick

        // Generate a random price
        price := randomPrice(560.0, 580.0)

        // Prepare POST request data
        data := map[string]float64{"price": price}
        jsonData, err := json.Marshal(data)
        if err != nil {
            fmt.Println("Error marshalling JSON:", err)
            continue
        }

        resp, err := http.Post("http://localhost:8080/gold", "application/json", bytes.NewBuffer(jsonData))
        if err != nil {
            fmt.Println("Error making POST request:", err)
            continue
        }
        fmt.Printf("Posted price: %.2f, response status: %s\n", price, resp.Status)
        resp.Body.Close()
    }
}

func randomPrice(min, max float64) float64 {
    return min + rand.Float64()*(max-min)
}

Run it once with Singleflight disabled and once with it enabled:

# SingleFlight disabled
$ SFL_ENABLE=0 go run main.go

# SingleFlight enabled
$ SFL_ENABLE=1 go run main.go

Without Singleflight, the database query counter keeps climbing during cache invalidation. Many concurrent GET requests miss the cache and each one goes to PostgreSQL:

Database query count without Singleflight

With Singleflight enabled, the database query count matches the number of update calls. When the cache is empty, concurrent readers still arrive, but sf.Do lets only one of them run the database read; the others wait for that result. The cache is then rebuilt once, and the database is not hit by every request in the burst:

Database query count with Singleflight

That difference is the point of the pattern. Singleflight is not tied to PostgreSQL, Redis, or any particular storage system. It only needs a stable key for the resource being requested. As long as concurrent callers use the same key, their work can be merged and the result returned to all of them. The same idea can sit in front of Redis under heavy read traffic, reducing the number of duplicate Redis calls even when the database is not involved.

The key detail is that the cache miss path is where the coordination belongs. If every request independently checks the cache, misses, and then queries the database, Singleflight does nothing until those requests are actually inside the protected function. In this example, the check stays outside, and the rebuild—database read plus cache write—is wrapped by Do. That keeps the cache fast for hits while collapsing the expensive part when the cache is missing.