How to Implement Server-Side A/B Testing (Step by Step)
How to implement server-side A/B testing: SDK setup, deterministic hashing, cross-service parity, A/A validation, and QA before you trust the pipeline.

📚 This article is part of the guide What Is a Feature Flag? The Complete Guide for Product Teams.
Implementing server-side A/B testing means moving the decision of which variation to show from the visitor’s browser into your backend, before any HTML or API response leaves for the client. We already covered why teams make that choice, and the flicker problem it solves, in our guide to client-side vs server-side A/B testing. This piece is the “how”, the part that guide leaves out: the practical, step-by-step path to shipping a server-side test on top of the same feature flag infrastructure the market already provides, instead of building an experimentation engine from zero.
This is not a concept primer, it is an implementation guide. We cover what to install, where in your code the decision should live, how to make the same user always receive the same variation, how to keep multiple services in agreement, how to test the pipeline before you trust its output, and the pitfalls that most often break a server-side rollout on its first attempt.
Prerequisites before you write any code
Three things need to be decided before the first line of implementation code:
- A server SDK. Feature flag and experimentation vendors such as GrowthBook, Statsig, LaunchDarkly, and Split (now part of Harness FME) ship server SDKs for the most common backend languages. The core difference from a client-side SDK: a server-side SDK key must never run in the browser, because the payload it receives contains the full targeting ruleset, and LaunchDarkly is explicit about this in its own SDK concepts documentation, treating the client-side context as untrusted by definition.
- A clear evaluation layer. Decide, before writing anything, at what exact point in the request lifecycle the flag gets read: an HTTP middleware, an edge function, or a call inside the route handler itself. That choice determines where the test lives in your stack, covered in its own section below.
- A stable hashing attribute. Usually the authenticated user’s ID, or a persistent anonymous ID (cookie or header) for visitors who have not logged in yet. Without a stable identifier there is no stable assignment, and without stable assignment there is no valid A/B test.
None of these three items requires building your own hashing function or a configuration server from scratch: the SDK you choose already handles evaluation, hashing, and configuration delivery. The actual engineering work is deciding where those calls fit and disciplining the team not to re-evaluate the same flag differently at different points in the code.
The request-to-variation pipeline, step by step
The flow is the same regardless of which SDK you pick: the request arrives, the server evaluates the flag using the SDK and the hashing attribute, the response is assembled with the variation already decided, and only then does the exposure event get logged. No step depends on the browser running any additional JavaScript.
In code, the four steps look like this, using a generic server SDK as the reference (the exact syntax changes between GrowthBook, Statsig, LaunchDarkly, or Split, but the logical sequence is identical across all four):
- Initialize the SDK once, at service startup, never per request. GrowthBook’s Node.js SDK, for instance, downloads the features payload over the network during
init()and then answers every evaluation call locally afterward, with no network round trip per request. Statsig’s own server SDK documentation describes the same pattern: “Afterinitializecompletes, virtually all SDK operations are synchronous,” with the SDK refreshing its payload in the background independently of any API call. - Build the user context with the hashing attribute. This is typically an object carrying the user ID (or an anonymous ID) plus targeting attributes such as country, plan, or device. This object is what the SDK uses to decide the variation, so it needs to exist before any evaluation happens, usually right after the request is authenticated.
- Evaluate the flag or experiment and use the result to assemble the response. The call returns the decided variation (for example
"control"or"variant-b"), which your code uses to pick which template to render, which configuration value to apply, or which branch of logic to follow. There is no second “rewrite” step afterward: the response the server sends is already final. - Log the exposure event, usually automatic: most server SDKs already record that a given user “entered” the experiment the first time the flag is evaluated for them, through a tracking callback. That is exactly how GrowthBook’s
trackingCallbackfires automatically once an experiment result is computed. The detail worth getting right, covered in more depth further down, is never firing that log more than once per user.
Deterministic hashing: the piece that makes assignment hold
The piece that everything else rests on is simple to describe and easy to get wrong in practice: the same user must see the same variation on every request, in every session, for as long as the experiment is live. That is not accomplished by rolling dice on every call, it is accomplished with deterministic hashing.
The principle, used with small implementation differences by GrowthBook, Statsig, LaunchDarkly, and Split, is to combine the user identifier with a fixed experiment key (the “seed”) and pass the combined value through a hash function. GrowthBook’s own documentation describes exactly this mechanism, hashing the user ID together with the experiment key to produce a number between 0 and 1, with each variation claiming a slice of that range. Harness FME (Split) documents the same underlying principle: a deterministic hash of the user key and the experiment’s traffic seed, normalized into a fixed set of buckets, so the same user consistently lands in the same bucket for a given experiment.
Two properties matter here:
- Determinism. The same input (user plus experiment) always produces the same output. There is no state to persist and no database lookup on every request: the calculation is reproducible in any process, in any service, as long as the formula and the seed match.
- Uniform distribution. A well-built hash function spreads users evenly across the ranges, so a configured 50/50 split actually lands close to 50/50 in practice, rather than skewing because of some pattern hidden in the user ID.
One detail that trips teams up: deterministic hashing keeps assignment stable as long as the experiment’s parameters do not change. If you change the traffic split mid-experiment (from 50/50 to 90/10, for example), each user’s hash stays the same, but the boundary between ranges moves, and users near the new boundary can flip variations. For this specific case, GrowthBook offers sticky bucketing: an additional layer that persists the already-assigned variation (in a cookie, Redis, or another store) and keeps it fixed even when the experiment configuration changes underneath, including support for a primary hash attribute (the logged-in user ID) with a fallback attribute (an anonymous ID), so a user keeps the same variation even after switching devices post-login.
Where the decision lives in your stack
The hash resolves “which variation”, but not “at what layer of the code is that calculated”. There are three common patterns, each trading off speed, control, and operational effort differently:
| Approach | Where the decision happens | Typical latency | When it makes sense |
|---|---|---|---|
| SDK directly in the service | Inside the same backend that already processes the request (the route handler, the product service) | None extra: a local function call, given the SDK already loaded the payload in memory | When the test affects logic that already lives in that service (pricing, a permission, a business rule) and adding a new layer just for the flag is not worth it |
| Edge function / middleware | A layer before the main handler, typically an edge function (Cloudflare Workers, Vercel Edge, Fastly Compute, Akamai EdgeWorkers) or a framework middleware | Low, and lower the closer the edge node sits to the visitor geographically | When the variation affects server-rendered HTML and you want to decide before assembling the page, without waiting on the origin backend to respond |
| Dedicated proxy | A separate internal service whose only job is to evaluate flags and hand back the decision to whoever calls it | Adds one extra internal network hop, unless co-located | When several different services, sometimes in different languages, need the same decision and you want to centralize evaluation logic in one place instead of replicating the SDK per language |
No row in that table is universally “best”. Statsig documents the edge option (via integrations with Cloudflare Workers, Fastly Compute, AWS CloudFront/Lambda@Edge, and Akamai EdgeWorkers) precisely to shrink the distance between the decision and the visitor when the test affects what gets rendered in the first response. When the flag instead decides a product rule that only one specific service executes (a billing service deciding which price table to apply, for example), putting the SDK directly there, with no proxy, tends to be simpler to build and easier to keep working.
Parity across multiple services
A server-side test rarely lives inside a single process. A checkout, for instance, might touch a catalog service, a pricing service, and a payment service, and all three need to agree on which variation that user is seeing, or the test mixes variations within the same flow and the result becomes worthless.
The good news: because the hash is deterministic, parity does not depend on the services talking to each other in real time. It depends on three things being identical across every service that evaluates that experiment:
- The same experiment key (the unique identifier used in the hash formula).
- The same hashing attribute for that user (the same ID, coming from the same source, always the authenticated user ID, for example, never the session ID in one service and the account ID in another).
- The same experiment configuration (the same traffic split, the same targeting rules), distributed to every service through the same mechanism, whether that is a CDN payload, a versioned configuration file, or a central flag service.
Martin Fowler describes this same requirement in architectural terms, writing that the router for an experiment toggle makes its routing decision “perhaps using some sort of consistent cohorting algorithm based on that user’s id” to guarantee a person experiences the same code path across subsequent requests, and recommending that decision logic get concentrated in a single point (what he calls a FeatureDecisions object) instead of scattering the flag check loosely across each service’s code. In practice, that means: write the flag check once, encapsulated, and reuse that same call in every service, rather than letting each team reimplement its own version of the evaluation logic.
QA: run an A/A test before trusting the pipeline
Before running your first real A/B test, run an A/A test: both groups see the exact same thing, and you measure whether the difference between them stays within what chance alone would explain. If the A/A test reports a statistically significant difference, the defect is in your assignment or your instrumentation, not in the product, and no A/B result produced by that pipeline before the fix should be taken seriously.
An A/A test needs enough sample to avoid a lazy verdict of “found no difference” just because too few visitors passed through it. Size the sample and the duration for your real traffic before declaring the pipeline trustworthy: our guide on how to run an A/B test covers sample size calculation in depth, and if you want the significance side of the A/A readout itself, this same calculator does the two-proportion test for you:
Two-sided two-proportion z-test. "Not significant" almost always means not enough sample, not that the versions are equal.
Beyond the A/A test, two QA tools reduce the risk of shipping a broken pipeline:
- An override endpoint (or parameter) for the internal team. GrowthBook offers a
qa_mode, which disables random assignment and only allows explicitly forced variations, plus a URL-based override to force a specific variation during manual testing (with the caveat that this override skips targeting rules, so it is useful for checking the variation itself, not for validating the targeting). Statsig follows the same principle with per-user-ID overrides: once an ID has an override configured, the forced result is returned before any normal rule is evaluated, letting a team test each variation without touching production data or skewing the experiment. - A debug log explaining why a given variation was assigned. GrowthBook’s own browser DevTools extension, for example, shows in real time which flags and experiments are active for a session and why each value was computed that way. Having something equivalent, even a bare internal endpoint that answers “user X, experiment Y, variation Z, reason W”, saves hours the first time someone reports “I’m seeing the wrong variation” and nobody can say why.
Common pitfalls
| Pitfall | Symptom | Fix |
|---|---|---|
| Services out of sync | One service shows variation A, another shows B, for the same user in the same session | Audit whether every service uses the same experiment key, the same hashing attribute, and the same configuration version; never leave a service caching a stale config |
| CDN cache serving the wrong variation | A user sees another user’s variation, usually in responses cached by URL without accounting for the assignment cookie | Include the hashing attribute (or the already-decided variation) in the cache key, or disable caching on routes with an experiment decision, depending on the CDN |
| Duplicate exposure events | The same user shows up counted twice or more in the same experiment arm, artificially inflating the sample | Log the exposure exactly once per user/experiment, ideally at the single point where the flag is evaluated, never at every place in the code that reads the already-computed result |
| Re-evaluating the flag at multiple points in the code | Inconsistent results within the same request, plus duplicate exposures as a side effect | Evaluate the flag once per request, store the result in the request context, and reuse that stored value everywhere else in the code |
The cache row deserves extra attention because it fails silently: the variation is computed correctly on the server, but if the cached response is served to a second visitor without accounting for the fact that the variation depends on the user, the second visitor gets the response computed for the first one. The fix depends on the CDN, but the principle is always the same: caching and per-user personalization do not coexist safely without a cache key that accounts for that personalization.
Measuring without duplicating events
The practical rule, consistent with what Statsig documents about automatic exposure logging: every server SDK already logs an exposure event on its own, the first time a given user is evaluated for that experiment, through a tracking callback. The bug that causes duplicates is usually not in the SDK, it is in your own code: if you call the flag-evaluation function in three different places within the same request (a middleware, a page component, and a helper API call), and each call fires the tracking callback again, you log three exposures for a single visitor.
The fix is always the same: evaluate the flag once per request (or per user session, depending on your case), store the result, and reuse that computed value at any other point in the code that needs to know the variation, without calling the evaluation again. When the SDK allows disabling the automatic log, Statsig offers this explicitly through methods like manuallyLogGateExposure(), centralizing the event firing at the single decision point is the safest way to guarantee one exposure per user, not one per call.
Client-side vs server-side, briefly
If you got here without having decided between client-side and server-side testing in the first place, the short version: client-side is faster to set up and the common choice for marketing and visual page changes, but it ships flag logic into the browser and carries a flicker risk. Server-side removes flicker by construction and is the right default for pricing, permissions, and any logic that should never be inspectable from a browser’s dev tools, at the cost of the engineering work this guide just walked through. The full trade-off, including the flicker mechanism and SEO implications, lives in our client-side vs server-side A/B testing guide.
Automate this on Donnu
Everything you just read, a server SDK, deterministic hashing, cross-service parity, QA overrides, and event deduplication, is real infrastructure to build, and it costs engineering even when you start from a ready-made tool. To be direct about what Donnu A/B is today: we are a client-side tool, built to install in minutes with a lightweight snippet, without requiring this kind of backend work. If your test is a visual page change (a headline, a CTA, a layout, an offer), that is exactly the use case we solve without you assembling any of what this guide describes.
If that is your next test, start a 14-day free trial and see the snippet in action. If your case genuinely needs server-side robustness (pricing, access permissions, product logic), this guide is the map of what to build, and it is worth reviewing the complete feature flags guide and feature flags vs A/B testing before choosing which server-side tool to build on.
Read next
- Feature flags: the complete guide: the concept from the ground up, for teams that do not have this infrastructure in their stack yet.
- Feature flags vs A/B testing: the boundary between a plain on/off switch and a real, statistically rigorous experiment.
- Client-side vs server-side A/B testing: the full comparison between the two architectures, the flicker problem, and when to choose each.
Leia em português: como implementar teste A/B server-side, passo a passo.
References
- GrowthBook. Node.js SDK. docs.growthbook.io/lib/node.
- GrowthBook. Sticky Bucketing. docs.growthbook.io/app/sticky-bucketing.
- Statsig. Node.js Server SDK and Testing your Gates/Experiments. docs.statsig.com/server/nodejsServerSDK, docs.statsig.com/guides/testing.
- LaunchDarkly. Choosing an SDK type. launchdarkly.com/docs/sdk/concepts/client-side-server-side.
- Harness FME (Split). How does Split ensure a consistent user experience. help.split.io.
- Fowler, M. Feature Toggles (aka Feature Flags). martinfowler.com/articles/feature-toggles.html.
- Statsig. CDN Edge Testing for Cached Resources. docs.statsig.com/guides/cdn-edge-testing.
Frequently asked questions
- Do I need to build my own experimentation engine to run a server-side A/B test?
- No. Feature flag and experimentation platforms such as GrowthBook, Statsig, LaunchDarkly, and Split (now part of Harness FME) already ship server SDKs for Node.js, Python, Go, Java, and other common languages, so the work is integration, not building a hashing or evaluation engine from scratch. The real engineering effort is deciding where in your backend the evaluation happens and keeping the visitor assignment stable across every service that reads the flag.
- Where should the variation decision happen: middleware, an edge function, or inside the backend service?
- It depends on what the variation changes. If it affects the HTML or the layout of a server-rendered page, evaluating in middleware or an edge function before the page is assembled avoids a second rewrite pass and keeps the decision close to the visitor. If the variation is a product rule instead, such as pricing, a permission, or a piece of business logic, evaluating directly inside the backend service that already owns that rule is usually simpler than adding a dedicated layer just for the flag.
- How do I guarantee two microservices assign the same user to the same variation?
- By making sure every service that evaluates that experiment uses the same experiment key, the same hashing attribute (typically the user ID), and the same version of the experiment configuration. Because the hash is deterministic, parity does not depend on the services talking to each other in real time, it depends on all of them reading the exact same experiment definition.
- What is an A/A test and why run one before trusting the pipeline?
- An A/A test shows the exact same experience to both groups and measures whether the observed difference stays within what chance alone would produce. If an A/A test reports a statistically significant difference, the defect lives in your instrumentation or your assignment logic, not in the product, and no A/B result produced by that pipeline should be trusted until the defect is found and fixed.
- How do I avoid counting the same conversion event twice?
- By logging the exposure event (the moment a user enters the experiment) separately from the conversion event, and firing each exactly once per decision unit, usually deduplicated by event ID or by a user-plus-experiment key. Most server SDKs already log exposure automatically on the first evaluation; the common bug is re-evaluating the same flag at multiple points in the request and triggering that automatic exposure log more than once for the same user.