Feature Flags in Go: In-Process Evaluation With No cgo and No Sidecar

Add feature flags to a Go service: a pure-Go SDK with no cgo and no dependencies, in-process evaluation with no per-request network call, goroutine-safe reads, and a clean shutdown.

  • go
  • golang
  • tutorials

Most feature flag tutorials stop at “read a flag, branch on it.” In Go that is the easy 10%. The other 90% is everything the language makes you decide on purpose: whether the SDK drags cgo into a binary you wanted to ship to scratch, what happens to the first requests before flags have loaded, whether a value read concurrently from a thousand goroutines needs a lock you have to hold, and how the background work shuts down without leaking a goroutine. A Go service is long-lived, heavily concurrent, and usually deployed as a single static binary. Those three properties are exactly what a feature flag library has to respect.

This guide adds feature flags to a Go HTTP service with the Go server SDK, which evaluates flags locally in memory so a check is a synchronous read with no network round trip. It is a pure-Go package: standard-library net/http only, no external dependencies and no cgo, so it cross-compiles and ships in a scratch image like the rest of your binary. For a copy-paste first flag, the Go quickstart is the five-minute version. This post is about the operational decisions a real Go service makes around it.

Key Takeaways

  • The Go SDK is pure Go with no cgo and no external dependencies, so CGO_ENABLED=0 go build produces a static binary that runs in a scratch image with nothing else copied in.
  • featureflip.Get() blocks until the first flag fetch finishes and returns an error, so a successful return means flags are already in memory. There is no “boot window serving defaults” to engineer around.
  • Variation methods read from an in-memory store behind a sync.RWMutex, so client.BoolVariation(...) is safe to call concurrently from any handler goroutine with no external locking.
  • One client per process. Create it once in main, pass it down, and a single background goroutine keeps the ruleset current over SSE for the whole service.
  • defer client.Close() stops that goroutine and flushes buffered analytics. Skip it and the goroutine plus its streaming connection leak until the process dies.

1. What makes feature flags in Go different

A flag lives in one of three very different homes, and the Go case sits at the demanding end.

In a one-off script, flags barely register: load config, read a value, do the work, exit. In a browser tab the constraint is the render, which is why a React app cares about avoiding extra re-renders and a Next.js app cares about the hydration flash. A Go service is the third home, and it has its own shape: it boots once, stays up for days, fields highly concurrent traffic across many goroutines, and ships as a self-contained binary you would rather run in a minimal container with no interpreter and no shared libraries.

That shape rewards local evaluation. The SDK fetches your full ruleset once at startup, keeps it current over a streaming connection, and answers every variation call from memory. The long-lived process pays the setup cost once and then gets effectively free flag checks for the rest of its life, and the heavy concurrency is fine because reads are lock-protected and never touch the network. The same local-evaluation model drives the Node.js server guide; the difference here is that Go’s static-binary deployment and explicit concurrency change how you wire it up. The rest of this guide walks those decisions in order.


2. Install a pure-Go SDK that ships in a scratch image

Install the package:

Terminal window
go get github.com/canopy-labs/featureflip-go

The SDK requires Go 1.25+. What it does not require is anything else: it talks to the evaluation API through the standard library’s net/http, pulls in no third-party modules, and uses no cgo. That last point is the one that bites people in Go. A flag library that links a C dependency forces CGO_ENABLED=1, which means a C toolchain in your build image and a binary that is no longer fully static, so it cannot run in scratch and cross-compilation stops being a one-liner.

Because this SDK is pure Go, none of that applies. A standard static build drops straight into an empty base image:

# build stage
FROM golang:1.25 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server
# runtime: the binary and nothing else
FROM scratch
COPY --from=build /app /app
ENTRYPOINT ["/app"]

The flag SDK adds no system packages to that final stage and does not change your CGO_ENABLED=0 setting, so adding feature flags does not cost you the static-binary deployment you build Go services to get. Cross-compiling for another architecture with GOOS/GOARCH keeps working for the same reason.


3. Initialize once, and let Get block until flags are loaded

Create the client a single time in main, check the error, and defer the cleanup:

package main
import (
"log"
"os"
featureflip "github.com/canopy-labs/featureflip-go"
)
func main() {
client, err := featureflip.Get(os.Getenv("FEATUREFLIP_SDK_KEY"))
if err != nil {
log.Fatalf("feature flags failed to load: %v", err)
}
defer client.Close()
// build your server with `client` and start serving
}

featureflip.Get() is a blocking call. It fetches the initial ruleset over HTTPS and only returns once that fetch succeeds, or returns an error if it fails or exceeds the init timeout (10 seconds by default, adjustable with featureflip.WithInitTimeout). This is the part that makes the Go init story simpler than the equivalent in a runtime where the client is created asynchronously. By the time Get returns a non-nil client and a nil error, the flags are in memory, so the first request your server handles already evaluates against real configuration. There is no window where the process is up but flag-blind, and therefore nothing to gate a readiness probe on for correctness: a log.Fatalf on the error from Get turns a failed flag fetch into a failed boot, which is exactly what you want from a hard dependency. (client.Initialized() exists and returns true after a successful Get, so it is mostly useful as a defensive assertion rather than a probe.)

Pass that one client down to your handlers rather than reaching for a fresh one anywhere. The client owns a long-lived streaming connection and a background goroutine, so one per process is the whole contract. Holding the SDK key in the FEATUREFLIP_SDK_KEY environment variable also lets you call featureflip.Get("") and let the SDK read it, which keeps the key out of your source.


4. Evaluate flags from any goroutine, with no network hop

Once Get has returned, evaluation is a synchronous in-memory read. A variation call takes no context.Context for I/O, makes no request, and cannot time out:

func dashboardHandler(client *featureflip.Client) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := featureflip.EvaluationContext{
UserID: userID(r),
Attributes: map[string]any{"plan": userPlan(r)},
}
if client.BoolVariation("new-dashboard-nav", ctx, false) {
renderDashboardV2(w, r)
return
}
renderDashboardV1(w, r)
}
}

The third argument is the default, returned if the flag is missing or evaluation fails, so a flag check has no error path of its own. The same pattern covers the other value types: client.StringVariation("banner-color", ctx, "blue"), client.Float64Variation("rate-limit", ctx, 100.0), and client.JSONVariation("ui-config", ctx, map[string]any{}). (Go decodes every JSON number as float64, so numeric flags use Float64Variation.) When you need to know why a value came back, client.VariationDetail(...) returns an EvaluationDetail carrying the Reason, the matched Variation, and the RuleID.

The property that matters for a Go service is concurrency. Every Go HTTP handler runs in its own goroutine, so a busy endpoint evaluates flags from many goroutines at once. The SDK keeps the ruleset in a store guarded by a sync.RWMutex, which means the variation methods are safe to call concurrently without any locking on your side. You can scatter BoolVariation calls through middleware, handlers, and deep service functions on every goroutine in the pool, and each one is a read-locked map lookup rather than a request waterfall.

Where a flag check goes in a Go service A diagram of a Go service drawn as a single binary. Inside it, HTTP handlers and goroutines read flag values from an in-memory ruleset protected by a read-write mutex, a synchronous call with no network. A background goroutine receives configuration updates from Featureflip over SSE, the only network connection that crosses the process boundary. Where a flag check goes in a Go service Illustrative: the request path, not a benchmark Featureflip config stream SSE · 1 goroutine Go service · one static binary (CGO_ENABLED=0) HTTP handlers & goroutines in-memory ruleset sync.RWMutex BoolVariation() synchronous, no network
Every per-request flag check stays inside the process: handlers and goroutines read from an in-memory ruleset under a read-write lock. The only network connection is the background SSE stream that keeps that ruleset current, so request handling never makes a flag call over the wire.

5. Target users and keep rollouts consistent across replicas

The EvaluationContext you pass on each call carries the data your rules run against: UserID for percentage rollouts, and an Attributes map for everything else.

ctx := featureflip.EvaluationContext{
UserID: "u-8a31",
Attributes: map[string]any{
"plan": "pro",
"country": "US",
},
}
if client.BoolVariation("priority-queue", ctx, false) {
// route this request through the new worker pool
}

A targeting rule on plan or country and a percentage rollout anchored on UserID both resolve locally against that context, because the SDK runs the same evaluation logic in-process that the platform would run anywhere.

Production Go services rarely run as one instance. They run as several replicas behind a load balancer, and a request for a given user can land on any of them. Local evaluation handles this without a shared cache to invalidate. Each instance holds its own in-memory copy of the ruleset and its own streaming connection, and bucketing is deterministic: the same UserID hashes to the same position in a percentage rollout on every replica, so a 10% rollout is the same 10% of users no matter which pod answers. You do not need sticky sessions to keep a user on one side of a rollout. When you flip a flag in the dashboard, the change pushes over each instance’s stream within milliseconds, so a kill switch thrown during an incident reaches the whole fleet at once with nothing to redeploy. If a stream drops, the SDK keeps serving the last known config while it reconnects and falls back to polling every 30 seconds, so a network blip degrades to slightly staler flags rather than to defaults.


6. Shut down cleanly and don’t leak the goroutine

The client runs a background goroutine for streaming and batches analytics events, the exposure data behind a percentage rollout plus any client.Track(...) calls you make. Two things have to happen on shutdown: the events still in the current batch need to flush, and that goroutine plus its streaming connection need to stop. Both are client.Close(), which is why the defer client.Close() next to Get matters. A process that exits without it drops the pending event batch, and a process that recreates clients without closing them leaks a goroutine and an open connection each time.

Wire Close() into a graceful shutdown alongside the HTTP server:

func main() {
client, err := featureflip.Get(os.Getenv("FEATUREFLIP_SDK_KEY"))
if err != nil {
log.Fatalf("feature flags failed to load: %v", err)
}
defer client.Close() // flushes events, stops the streaming goroutine
srv := &http.Server{Addr: ":8080", Handler: routes(client)}
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
}()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("graceful shutdown failed: %v", err)
}
}

On SIGTERM, signal.NotifyContext cancels the context, the server drains in-flight requests, and the deferred client.Close() then flushes the last event batch and stops the goroutine before main returns. Close() is safe to call more than once and returns nil today, so a defensive double-close in a more complex shutdown path does no harm.


7. Test flag-gated code with ForTesting

A flag introduces two branches, and both need tests that do not reach the network or stand up a server. featureflip.ForTesting is a package-level constructor that returns a fully initialized client backed by hardcoded values, with no streaming goroutine and no connections:

func TestCheckout_newFlow(t *testing.T) {
client := featureflip.ForTesting(map[string]any{
"new-checkout-flow": true,
"rate-limit": 500.0,
})
defer client.Close()
ctx := featureflip.EvaluationContext{UserID: "test-user"}
if got := client.BoolVariation("new-checkout-flow", ctx, false); !got {
t.Fatal("expected new checkout flow to be enabled")
}
}

Inject the test client wherever your code expects a *featureflip.Client and assert each branch independently. The override client returns its configured values regardless of the evaluation context and is ready the moment it is created, so there is no init to await and no HTTP to mock. Flip the override map to cover the off-path in a second test.


8. Why a first-party SDK, and what it meters

You could skip the SDK and call a flag endpoint over HTTP, or poll it and cache the result yourself. That holds up until the endpoint is slow or down, at which point every request that checks a flag is either waiting on a network call or handling its failure, and you now maintain the cache, the refresh loop, the SSE reconnection, and the goroutine lifecycle by hand. Local evaluation is that machinery, built once and kept in sync with the dashboard for you, the kind of undifferentiated plumbing a build-versus-buy decision usually settles in favor of buying.

Pricing lands on a Go service in a specific way. A backend evaluates flags on nearly every request, so on a platform that bills by evaluation or by monthly active user, the flag bill tracks traffic, and a launch that triples requests triples the meter. Featureflip uses flat pricing with unlimited evaluations on every plan, so request volume never moves the invoice. The pricing page has the numbers, and the comparison pages show how that lands against per-MAU and usage-billed vendors for a high-throughput service.


The shorter version

What makes feature flags in Go different from a script or a browser is the deployment shape: a long-lived, concurrent, single-binary service. Install github.com/canopy-labs/featureflip-go, a pure-Go SDK with no cgo and no dependencies, so CGO_ENABLED=0 go build still ships to scratch. Create one client in main with featureflip.Get(sdkKey), which blocks until flags are loaded and returns an error, so a successful return means the first request already evaluates against real config and a failed fetch fails the boot. Evaluate with client.BoolVariation("key", ctx, default), a synchronous in-memory read guarded by a sync.RWMutex that is safe to call from every handler goroutine. Across replicas, each instance streams config independently and buckets the same UserID the same way, so a dashboard flip reaches the fleet in milliseconds with no redeploy. Defer client.Close() to flush analytics and stop the background goroutine, and use featureflip.ForTesting(...) to cover both branches of a flag without a network.


Frequently asked questions

How do I add feature flags to a Go application?

Run go get github.com/canopy-labs/featureflip-go, then create one client in main with client, err := featureflip.Get(os.Getenv("FEATUREFLIP_SDK_KEY")), check the error, and defer client.Close(). Read flags anywhere with client.BoolVariation("your-flag", featureflip.EvaluationContext{UserID: id}, false). Evaluation is a synchronous in-memory read, so a flag check adds no latency to a request. The Go quickstart covers the minimal setup and the Go SDK reference documents every option and variation method.

Does the Featureflip Go SDK require cgo?

No. The SDK is pure Go and depends only on the standard library’s net/http, with no external modules and no C bindings. You can build with CGO_ENABLED=0 and get a fully static binary that runs in a scratch or distroless image, and cross-compiling with GOOS/GOARCH works without a C toolchain. Adding feature flags does not change the static-binary deployment Go services are built to ship.

Is flag evaluation in the Go SDK safe to call from multiple goroutines?

Yes. The SDK stores the ruleset behind a sync.RWMutex, and the variation methods take a read lock, so BoolVariation, StringVariation, and the others are safe to call concurrently from any number of goroutines without external synchronization. Each Go HTTP handler runs in its own goroutine, so you can evaluate flags directly in handlers and middleware on a busy endpoint with no added locking.

Do I need a readiness gate so my Go service doesn’t serve default flag values at startup?

Not for correctness. featureflip.Get() blocks until the initial flag fetch completes and returns an error if it fails or times out, so once it returns a usable client the ruleset is already in memory and the first request evaluates against real configuration. Treat a non-nil error from Get as a failed boot (for example with log.Fatalf). That is different from an SDK whose client is created asynchronously, where you would otherwise need to await initialization before serving.

How do feature flags stay consistent across multiple Go instances?

Each instance holds its own in-memory ruleset and its own streaming connection, and the same UserID hashes to the same bucket on every instance, so a percentage rollout serves the same users the same variation regardless of which replica answers a request. You do not need sticky sessions. A flag flipped in the dashboard pushes to every instance over its stream within milliseconds, so the whole fleet changes together without a redeploy or a shared cache to invalidate.


Featureflip is a focused, flat-priced feature flag platform with a pure-Go SDK that evaluates flags locally in memory, so a Go service gets goroutine-safe flag checks on the hot path, fleet-wide updates over streaming, a static binary that still ships to scratch, and no bill that scales with request volume. The Go SDK reference has the full API, the companion guides on feature flags in Node.js and feature flags in Python cover the same local-evaluation pattern for other backends, and you can start on the free Solo plan without a credit card.