DEV Community

Cover image for Build a Dynamic Feature Flag System in Go: Real-Time Control, A/B Testing, and Zero Redeployment
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

Build a Dynamic Feature Flag System in Go: Real-Time Control, A/B Testing, and Zero Redeployment

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

I want to talk about a way to change how your software works without the pain of redeploying it. Imagine you could turn features on and off like lights in a room, test different versions of a button on your website with real users, and do it all instantly, while your application is running. That's what a dynamic feature flag system lets you do.

Let me build one with you in Go, step by step. We'll make it fast, capable of updating in real-time, and smart enough to run experiments. This isn't just a simple on/off switch. It's a full control panel for your application's behavior.

Think of a feature flag as a rulebook. Instead of your code deciding what to do, it asks the flag system: "Should I show the new checkout page to user X?" The system checks the rules—like "Is the user in the US?" or "Are we rolling this out to 10% of users?"—and gives a yes or no answer. The code just follows instructions.

The heart of our system is a manager that coordinates everything. I'll call it FeatureFlagManager. It's like a conductor for an orchestra, making sure all the parts work together.

type FeatureFlagManager struct {
    store       *FlagStore       // Where flag rules live
    evaluator   *FlagEvaluator   // Decides yes/no for a user
    notifier    *UpdateNotifier  // Sends live updates
    experiments *ExperimentTracker // Runs A/B tests
    stats       *FlagStats       // Tracks performance
}
Enter fullscreen mode Exit fullscreen mode

First, we need a place to keep all our flag configurations. I'll create a FlagStore. It's a simple in-memory map protected by a mutex for safe concurrent access. When we update a flag, we'll lock the store, change the map, and release the lock.

type FlagStore struct {
    flags    map[string]*FeatureFlag
    versions map[string]int64
    mu       sync.RWMutex
}
Enter fullscreen mode Exit fullscreen mode

What does a flag look like? It's a struct that defines the rules of the game.

type FeatureFlag struct {
    Key         string
    Enabled     bool
    Rules       []TargetingRule  // Specific user targeting
    Rollout     *PercentageRollout // Gradual percentage rollout
    Variants    []Variant        // For A/B testing
    UpdatedAt   time.Time
}
Enter fullscreen mode Exit fullscreen mode

Now, the most asked question: How do we decide if a flag is on for a specific person? That's the job of the FlagEvaluator. It takes a user's context—like their user ID, country, or subscription plan—and runs through the flag's rules.

The process is straightforward. First, check if the flag exists. Then, see if there's a manual override set by an engineer for debugging. After that, go through each targeting rule in order. If the user matches a rule, return that rule's decision. If no rules match, check if they fall into a percentage rollout. Finally, if it's an A/B test, assign them a variant.

func (ffm *FeatureFlagManager) Evaluate(ctx context.Context, flagKey string, context map[string]interface{}) (bool, map[string]interface{}, error) {
    // 1. Get the flag from the store
    ffm.store.mu.RLock()
    flag, exists := ffm.store.flags[flagKey]
    ffm.store.mu.RUnlock()

    if !exists {
        return false, nil, nil // Flag doesn't exist
    }

    // 2. Check for manual override (useful for debugging)
    if override, hasOverride := ffm.evaluator.overrides[flagKey]; hasOverride {
        return override, nil, nil
    }

    // 3. Evaluate targeting rules
    for _, rule := range flag.Rules {
        if ffm.evaluateRule(rule, context) {
            return rule.Enabled, map[string]interface{}{"matched_rule": rule.Name}, nil
        }
    }

    // 4. Check percentage rollout
    if flag.Rollout != nil {
        userID, hasUser := context["user_id"].(string)
        if hasUser {
            bucket := ffm.computeBucket(flagKey, userID, flag.Rollout.Salt)
            if bucket <= flag.Rollout.Percentage {
                return true, map[string]interface{}{"rollout_bucket": bucket}, nil
            }
        }
    }

    // 5. Default to the flag's global setting
    return flag.Enabled, nil, nil
}
Enter fullscreen mode Exit fullscreen mode

Let's talk about that computeBucket function. It's how we do percentage rollouts consistently. We need to ensure the same user always gets the same result for the same flag. I use a hash function.

func (ffm *FeatureFlagManager) computeBucket(flagKey, userID, salt string) float64 {
    // Combine flag key, user ID, and salt into one string
    hashInput := fmt.Sprintf("%s:%s:%s", flagKey, userID, salt)
    // Create a SHA256 hash
    hash := sha256.Sum256([]byte(hashInput))

    // Use the first 4 bytes to create a number between 0 and 1
    bucketNumber := float64(uint32(hash[0])<<24 | uint32(hash[1])<<16 | uint32(hash[2])<<8 | uint32(hash[3]))
    // Convert to a percentage between 0 and 100
    bucket := (bucketNumber / math.MaxUint32) * 100

    return bucket
}
Enter fullscreen mode Exit fullscreen mode

If the user's bucket number is less than or equal to our rollout percentage (say, 10%), they see the feature. This method is deterministic. User "Alice" will always hash to the same bucket for the "new_dashboard" flag.

Now, what if we want to run an A/B test? We need to show 50% of users a blue button (control) and 50% a red button (treatment). For that, we use variants.

type Variant struct {
    Key     string
    Weight  float64 // Percentage of traffic
    Enabled bool
    Payload map[string]interface{} // Extra data, like button color
}
Enter fullscreen mode Exit fullscreen mode

The selection logic is similar to the rollout bucket but uses weights.

func (ffm *FeatureFlagManager) selectVariant(flagKey, userID string, variants []Variant) *Variant {
    hashInput := fmt.Sprintf("%s:%s:variants", flagKey, userID)
    hash := sha256.Sum256([]byte(hashInput))
    selection := float64(uint32(hash[0])<<24|uint32(hash[1])<<16|uint32(hash[2])<<8|uint32(hash[3])) / math.MaxUint32

    cumulativeWeight := 0.0
    for _, variant := range variants {
        cumulativeWeight += variant.Weight
        // If our selection falls into this variant's weight range, pick it
        if selection*100 < cumulativeWeight {
            return &variant
        }
    }
    return &variants[0] // Fallback
}
Enter fullscreen mode Exit fullscreen mode

This is the core evaluation logic. But calling this function directly for every user request would be slow. We need to be smart about performance. Let's add caching.

I'll create an EvaluationCache that stores recent decisions. When a user asks about a flag, we first check the cache. The key is a combination of the flag key and the user's context.

type EvaluationCache struct {
    entries map[string]*EvaluationResult
    lru     *LRUList
    maxSize int
    mu      sync.RWMutex
}

func (ec *EvaluationCache) Get(key string) (*EvaluationResult, bool) {
    ec.mu.RLock()
    result, exists := ec.entries[key]
    ec.mu.RUnlock()

    if exists {
        ec.lru.Touch(key) // Mark as recently used
        return result, true
    }
    return nil, false
}
Enter fullscreen mode Exit fullscreen mode

But there's a problem. What if 1000 requests for the same user and flag arrive at the exact same time before the cache is populated? They would all miss the cache and all start evaluating the flag. That's wasteful.

We can use a pattern called singleflight. It ensures that only one evaluation happens for duplicate simultaneous requests. The others wait for the result.

// Inside the evaluator
type FlagEvaluator struct {
    cache       *EvaluationCache
    flightGroup singleflight.Group // Prevents duplicate work
    overrides   map[string]bool
}

// In the Evaluate method
result, err, _ := ffm.evaluator.flightGroup.Do(cacheKey, func() (interface{}, error) {
    return ffm.evaluateFlag(flagKey, context)
})
Enter fullscreen mode Exit fullscreen mode

Now our system is fast, but what about updates? When an engineer changes a flag from 10% to 20% rollout, we need all servers to know immediately. This is where real-time updates come in.

I'll create an UpdateNotifier that uses WebSockets to push changes to connected clients. Each running instance of our application connects to the notifier.

type UpdateNotifier struct {
    clients   map[string]*ClientConnection
    broadcast chan ConfigUpdate
    mu        sync.RWMutex
}

func (ffm *FeatureFlagManager) UpdateFlag(flag *FeatureFlag) error {
    // ... update the store ...

    // Notify all connected clients
    ffm.notifier.broadcast <- ConfigUpdate{
        Type:    UpdateTypeFlagChanged,
        FlagKey: flag.Key,
        Version: ffm.store.versions[flag.Key],
    }

    // Clear the cache for this flag
    ffm.evaluator.cache.InvalidatePrefix(flag.Key)

    return nil
}
Enter fullscreen mode Exit fullscreen mode

The notifier has a broadcast channel. When a flag updates, we send a message to this channel. A separate goroutine reads from this channel and sends the update to every connected client.

func (n *UpdateNotifier) StartBroadcasting() {
    for update := range n.broadcast {
        n.mu.RLock()
        msg, _ := json.Marshal(update)
        for _, client := range n.clients {
            select {
            case client.Send <- msg:
                // Message sent
            default:
                // Client's buffer is full, maybe disconnect them
            }
        }
        n.mu.RUnlock()
    }
}
Enter fullscreen mode Exit fullscreen mode

Each client connection runs two goroutines: one to read messages from the WebSocket, and one to write messages. The write pump also sends periodic ping messages to keep the connection alive.

Now, let's talk about A/B testing. It's not enough to just show different variants. We need to know which one performs better. We need an ExperimentTracker.

When a user sees a variant, we record an "exposure." When they complete a goal (like making a purchase), we record a "conversion." Later, we can calculate the conversion rate for each variant.

type ExperimentTracker struct {
    exposures    map[string][]*ExposureEvent
    conversions  map[string][]*ConversionEvent
    mu           sync.RWMutex
}

func (et *ExperimentTracker) TrackExposure(experimentID string, context map[string]interface{}, variant string) {
    et.mu.Lock()
    defer et.mu.Unlock()

    userID, _ := context["user_id"].(string)
    exposure := &ExposureEvent{
        ExperimentID: experimentID,
        Variant:      variant,
        UserID:       userID,
        Timestamp:    time.Now(),
    }
    et.exposures[experimentID] = append(et.exposures[experimentID], exposure)
}
Enter fullscreen mode Exit fullscreen mode

We can call TrackExposure right after we evaluate a flag and determine the user is in an experiment. Then, when the user completes a purchase, we call TrackConversion.

At any time, we can ask for the results.

func (et *ExperimentTracker) GetExperimentResults(experimentID string) *ExperimentResults {
    et.mu.RLock()
    defer et.mu.RUnlock()

    exposures := et.exposures[experimentID]
    conversions := et.conversions[experimentID]

    results := &ExperimentResults{
        ExperimentID: experimentID,
        Variants:     make(map[string]*VariantStats),
    }

    // Count exposures and conversions per variant
    for _, exp := range exposures {
        if _, exists := results.Variants[exp.Variant]; !exists {
            results.Variants[exp.Variant] = &VariantStats{}
        }
        results.Variants[exp.Variant].Exposures++
    }

    for _, conv := range conversions {
        if stats, exists := results.Variants[conv.Variant]; exists {
            stats.Conversions++
            stats.TotalValue += conv.Value
        }
    }

    // Calculate conversion rates
    for _, stats := range results.Variants {
        if stats.Exposures > 0 {
            stats.ConversionRate = float64(stats.Conversions) / float64(stats.Exposures)
        }
    }

    return results
}
Enter fullscreen mode Exit fullscreen mode

Let me put all these pieces together in a complete example. Imagine we're testing a new checkout flow.

func main() {
    // Create the manager
    ffm := NewFeatureFlagManager()

    // Define our A/B test flag
    checkoutFlag := &FeatureFlag{
        Key:     "new_checkout_experiment",
        Enabled: true,
        Variants: []Variant{
            {Key: "control", Weight: 50, Enabled: true},
            {Key: "treatment", Weight: 50, Enabled: true},
        },
        Description: "Test new single-page checkout",
    }

    ffm.UpdateFlag(checkoutFlag)

    // Simulate user requests
    for i := 0; i < 1000; i++ {
        userContext := map[string]interface{}{
            "user_id": fmt.Sprintf("user_%d", i),
            "country": "US",
        }

        // Evaluate the flag
        enabled, metadata, _ := ffm.Evaluate(context.Background(), "new_checkout_experiment", userContext)

        if enabled {
            variant := metadata["variant"].(string)

            // Record that this user saw the experiment
            ffm.experiments.TrackExposure("new_checkout_experiment", userContext, variant)

            // Simulate a purchase (conversion) with some probability
            if (variant == "treatment" && i%4 == 0) || (variant == "control" && i%6 == 0) {
                ffm.experiments.TrackConversion("new_checkout_experiment", "purchase", 99.99, userContext)
            }
        }
    }

    // Get results
    results := ffm.experiments.GetExperimentResults("new_checkout_experiment")
    fmt.Printf("Control: %d exposures, %d purchases (%.1f%%)\n",
        results.Variants["control"].Exposures,
        results.Variants["control"].Conversions,
        results.Variants["control"].ConversionRate*100)

    fmt.Printf("Treatment: %d exposures, %d purchases (%.1f%%)\n",
        results.Variants["treatment"].Exposures,
        results.Variants["treatment"].Conversions,
        results.Variants["treatment"].ConversionRate*100)
}
Enter fullscreen mode Exit fullscreen mode

This gives us the basic numbers. In a real system, you'd want statistical significance tests to know if the difference is real or just random chance.

There are several important details to consider for a production system. First, persistence. Our flags are in memory. If the service restarts, we lose them. We need to save them to a database or file.

I can define a FlagPersistence interface.

type FlagPersistence interface {
    Save(flag *FeatureFlag) error
    LoadAll() (map[string]*FeatureFlag, error)
}
Enter fullscreen mode Exit fullscreen mode

Then, in our FlagStore, we can have a persistence field. When we update a flag, we save it. When we start the service, we load all flags.

Second, we need to think about security and validation. Not everyone should be able to change flags. We should validate that flag configurations make sense before accepting them.

func (ffm *FeatureFlagManager) validateFlag(flag *FeatureFlag) error {
    if flag.Key == "" {
        return fmt.Errorf("flag key is required")
    }

    // Check variant weights sum to 100%
    if len(flag.Variants) > 0 {
        totalWeight := 0.0
        for _, v := range flag.Variants {
            totalWeight += v.Weight
        }
        if math.Abs(totalWeight-100.0) > 0.01 {
            return fmt.Errorf("variant weights must sum to 100, got %.2f", totalWeight)
        }
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode

Third, monitoring. We should track how many evaluations we're doing, our cache hit rate, and how long evaluations take.

type FlagStats struct {
    Evaluations   uint64
    CacheHits     uint64
    CacheMisses   uint64
    TotalDuration uint64 // nanoseconds
}

func (fs *FlagStats) CacheHitRate() float64 {
    total := fs.CacheHits + fs.CacheMisses
    if total == 0 {
        return 0
    }
    return float64(fs.CacheHits) / float64(total)
}

func (fs *FlagStats) AverageDuration() time.Duration {
    if fs.Evaluations == 0 {
        return 0
    }
    return time.Duration(fs.TotalDuration / fs.Evaluations)
}
Enter fullscreen mode Exit fullscreen mode

We can update these stats atomically during evaluation.

Fourth, we need to consider what happens when the update notification system fails. A client might miss an update. To handle this, each client can track the version of each flag it has. When it reconnects, or periodically, it can ask for any flags that have changed since a certain version.

The system I've described is a starting point. In practice, you might add more features. For example, you could add rule types that target based on the day of the week, or the user's device type. You could add more complex rollout strategies, like canary releases that start with 1% and increase only if error rates stay low.

You could also add a user interface for non-engineers to manage flags. This UI would talk to an API that uses the same UpdateFlag method we created.

The main goal is to give your team control. Control to release features safely. Control to test ideas with real users. Control to quickly disable something that's causing problems. All without waiting for a full deployment cycle.

Building this in Go gives us performance and simplicity. The concurrency primitives—goroutines, channels, mutexes—make it straightforward to build a system that handles thousands of requests per second while pushing updates in real-time.

Start simple. Implement the basic evaluation first. Add caching when you need performance. Add real-time updates when you have multiple servers. Add A/B testing when you need to make data-driven decisions. Each piece builds on the last, giving you more control over how your software behaves in production.

Remember, the code doesn't decide. The flags decide. Your code just asks questions and follows instructions. This separation is what gives you the power to change your application's behavior without changing its code.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)