Behavioral Created: 2026-01-28 Updated: 2026-01-28

Hot Config Reload

Watch configuration files for changes and reload application settings without restarting.

Contributors

When to Use

  • In long-running services where restart downtime is unacceptable
  • When tuning parameters like rate limits or feature flags in production
  • For microservices that need dynamic configuration updates
  • When configuration changes are frequent during development

When NOT to Use

  • For static configuration that never changes after startup
  • When config changes require restarting dependencies anyway
  • In short-lived processes like CLI tools or batch jobs
  • When atomic configuration updates aren't critical

Hot Config Reload allows applications to detect and apply configuration changes without restarting. This pattern uses file system watchers to monitor config files and atomically swaps configuration when changes are detected, enabling zero-downtime updates.

Implementation

package config

import (
    "context"
    "encoding/json"
    "fmt"
    "log"
    "os"
    "sync"
    "time"

    "github.com/fsnotify/fsnotify"
)

// Config holds application configuration
type Config struct {
    Server ServerConfig `json:"server"`
    Redis  RedisConfig  `json:"redis"`
    Features FeatureFlags `json:"features"`
}

type ServerConfig struct {
    Port         int           `json:"port"`
    ReadTimeout  time.Duration `json:"read_timeout"`
    WriteTimeout time.Duration `json:"write_timeout"`
}

type RedisConfig struct {
    Host     string `json:"host"`
    Port     int    `json:"port"`
    PoolSize int    `json:"pool_size"`
}

type FeatureFlags struct {
    EnableNewUI      bool `json:"enable_new_ui"`
    EnableBetaAPI    bool `json:"enable_beta_api"`
    MaxUploadSizeMB  int  `json:"max_upload_size_mb"`
}

// ConfigManager manages hot reloading of configuration
type ConfigManager struct {
    configPath string
    mu         sync.RWMutex
    config     *Config
    watcher    *fsnotify.Watcher
    onChange   []func(*Config)
}

func NewConfigManager(configPath string) (*ConfigManager, error) {
    cm := &ConfigManager{
        configPath: configPath,
        onChange:   make([]func(*Config), 0),
    }

    // Load initial config
    if err := cm.reload(); err != nil {
        return nil, fmt.Errorf("load initial config: %w", err)
    }

    // Setup file watcher
    watcher, err := fsnotify.NewWatcher()
    if err != nil {
        return nil, fmt.Errorf("create watcher: %w", err)
    }
    cm.watcher = watcher

    if err := watcher.Add(configPath); err != nil {
        return nil, fmt.Errorf("watch config file: %w", err)
    }

    return cm, nil
}

// Get returns current config (thread-safe)
func (cm *ConfigManager) Get() *Config {
    cm.mu.RLock()
    defer cm.mu.RUnlock()
    // Return a copy to prevent external modifications
    cfg := *cm.config
    return &cfg
}

// OnChange registers a callback for config changes
func (cm *ConfigManager) OnChange(callback func(*Config)) {
    cm.mu.Lock()
    defer cm.mu.Unlock()
    cm.onChange = append(cm.onChange, callback)
}

// Watch starts watching for config changes
func (cm *ConfigManager) Watch(ctx context.Context) error {
    log.Printf("Watching config file: %s", cm.configPath)
    
    // Debounce timer to handle rapid file changes
    var debounceTimer *time.Timer
    const debounceDelay = 500 * time.Millisecond

    for {
        select {
        case <-ctx.Done():
            return cm.watcher.Close()

        case event, ok := <-cm.watcher.Events:
            if !ok {
                return fmt.Errorf("watcher closed")
            }

            // Only care about write and create events
            if event.Op&fsnotify.Write == fsnotify.Write ||
               event.Op&fsnotify.Create == fsnotify.Create {
                
                // Reset debounce timer
                if debounceTimer != nil {
                    debounceTimer.Stop()
                }
                
                debounceTimer = time.AfterFunc(debounceDelay, func() {
                    log.Println("Config file changed, reloading...")
                    if err := cm.reload(); err != nil {
                        log.Printf("Failed to reload config: %v", err)
                    } else {
                        log.Println("Config reloaded successfully")
                        cm.notifyCallbacks()
                    }
                })
            }

        case err, ok := <-cm.watcher.Errors:
            if !ok {
                return fmt.Errorf("watcher error channel closed")
            }
            log.Printf("Watcher error: %v", err)
        }
    }
}

// reload reads and parses the config file
func (cm *ConfigManager) reload() error {
    data, err := os.ReadFile(cm.configPath)
    if err != nil {
        return fmt.Errorf("read config file: %w", err)
    }

    var newConfig Config
    if err := json.Unmarshal(data, &newConfig); err != nil {
        return fmt.Errorf("parse config: %w", err)
    }

    // Validate config before applying
    if err := cm.validate(&newConfig); err != nil {
        return fmt.Errorf("validate config: %w", err)
    }

    // Atomic swap
    cm.mu.Lock()
    cm.config = &newConfig
    cm.mu.Unlock()

    return nil
}

// validate checks if config is valid
func (cm *ConfigManager) validate(cfg *Config) error {
    if cfg.Server.Port < 1 || cfg.Server.Port > 65535 {
        return fmt.Errorf("invalid port: %d", cfg.Server.Port)
    }
    if cfg.Redis.PoolSize < 1 {
        return fmt.Errorf("pool size must be positive")
    }
    return nil
}

// notifyCallbacks calls all registered change callbacks
func (cm *ConfigManager) notifyCallbacks() {
    cm.mu.RLock()
    callbacks := cm.onChange
    config := cm.config
    cm.mu.RUnlock()

    for _, callback := range callbacks {
        go callback(config)
    }
}

Usage

package main

import (
    "context"
    "fmt"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    // Create sample config file
    createSampleConfig("config.json")

    // Initialize config manager
    cfgMgr, err := NewConfigManager("config.json")
    if err != nil {
        log.Fatalf("Failed to create config manager: %v", err)
    }

    // Register callbacks for config changes
    cfgMgr.OnChange(func(cfg *Config) {
        log.Printf("Config updated! New port: %d", cfg.Server.Port)
        log.Printf("New UI enabled: %v", cfg.Features.EnableNewUI)
    })

    // Start watching for changes
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    go func() {
        if err := cfgMgr.Watch(ctx); err != nil {
            log.Printf("Watcher stopped: %v", err)
        }
    }()

    // Start HTTP server using config
    mux := http.NewServeMux()
    mux.HandleFunc("/config", func(w http.ResponseWriter, r *http.Request) {
        cfg := cfgMgr.Get()
        fmt.Fprintf(w, "Current Config:\n")
        fmt.Fprintf(w, "Port: %d\n", cfg.Server.Port)
        fmt.Fprintf(w, "Redis Pool: %d\n", cfg.Redis.PoolSize)
        fmt.Fprintf(w, "New UI: %v\n", cfg.Features.EnableNewUI)
    })

    mux.HandleFunc("/feature/new-ui", func(w http.ResponseWriter, r *http.Request) {
        cfg := cfgMgr.Get()
        if cfg.Features.EnableNewUI {
            fmt.Fprintf(w, "New UI is ENABLED\n")
        } else {
            fmt.Fprintf(w, "New UI is DISABLED\n")
        }
    })

    server := &http.Server{
        Addr:    ":8080",
        Handler: mux,
    }

    go func() {
        log.Printf("Server starting on :8080")
        if err := server.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatalf("Server error: %v", err)
        }
    }()

    // Wait for interrupt
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    log.Println("Shutting down...")
    cancel()
    
    shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer shutdownCancel()
    _ = server.Shutdown(shutdownCtx)
}

func createSampleConfig(path string) {
    config := Config{
        Server: ServerConfig{
            Port:         8080,
            ReadTimeout:  5 * time.Second,
            WriteTimeout: 10 * time.Second,
        },
        Redis: RedisConfig{
            Host:     "localhost",
            Port:     6379,
            PoolSize: 10,
        },
        Features: FeatureFlags{
            EnableNewUI:     false,
            EnableBetaAPI:   false,
            MaxUploadSizeMB: 10,
        },
    }

    data, _ := json.MarshalIndent(config, "", "  ")
    os.WriteFile(path, data, 0644)
}

// Test it:
// 1. Run: go run main.go
// 2. Check config: curl http://localhost:8080/config
// 3. Edit config.json - change "enable_new_ui": true
// 4. Watch logs for "Config reloaded successfully"
// 5. Check again: curl http://localhost:8080/feature/new-ui

Benefits

  • Zero downtime: Update configuration without restarting service
  • Fast iteration: Change feature flags or limits in production instantly
  • Safe updates: Validation prevents invalid config from being applied
  • Atomic changes: RWMutex ensures no partial config is ever read
  • Debuggable: Callbacks allow logging and monitoring of config changes

Production Enhancements

// Add version tracking
type ConfigManager struct {
    // ... existing fields
    version    int64
    lastReload time.Time
}

// Add metrics
func (cm *ConfigManager) reload() error {
    start := time.Now()
    err := cm.doReload()
    
    metrics.RecordConfigReload(time.Since(start), err == nil)
    
    if err == nil {
        cm.version++
        cm.lastReload = time.Now()
    }
    return err
}

// Add config source abstraction (file, etcd, consul, etc.)
type ConfigSource interface {
    Read() ([]byte, error)
    Watch(ctx context.Context) (<-chan []byte, error)
}

Common Gotchas

  • File editors: Some editors create temp files and rename, causing multiple events. Debouncing helps.
  • Validation: Always validate before applying to prevent bad config
  • Callbacks: Keep them fast and non-blocking (use goroutines)
  • Container volumes: Docker bind mounts may need special watch configuration
  • Credentials: Don’t log sensitive config values in callbacks