← All posts
technical architecture server-side flags

DOM-injected vs flag-based experimentation: what changes when you go server-side

A practical comparison of client-side DOM manipulation and server-side flag-based testing — the trade-offs, the migration path, and what actually changes for the developer.

5 min read

Most A/B testing starts the same way: inject some JavaScript, select an element, change it. It is fast to ship, requires no back-end access, and works across any stack where you can drop a script tag. For many programmes, it stays that way indefinitely.

But at some point — usually triggered by flicker problems, performance concerns, or a desire to test server-rendered content — the conversation turns to server-side, flag-based experimentation. The two approaches are more different than they first appear.

What “DOM-injected” actually means

Client-side, DOM-injected tests run after the browser has received and begun parsing the page. The testing tool (Optimizely Web, Convert, VWO, or a bespoke setup) loads a JavaScript bundle containing your experiment logic. This bundle:

  1. Evaluates targeting conditions (URL match, audience segment, cookie value)
  2. Assigns the user to a variant (or reads an existing assignment from storage)
  3. Applies changes to the DOM — typically document.querySelector and property manipulation
// A simplified DOM-injected activation
var el = document.querySelector('.product-price');
if (el) {
  el.textContent = formatPrice(el.dataset.price, 'prominent');
  el.classList.add('exp-123-variant');
}

The entire cycle happens in the user’s browser, in real time, every page load. The original HTML is always sent; the test modifies it after delivery.

What “flag-based” means

Flag-based experimentation (Optimizely Feature Experimentation, LaunchDarkly, Statsig, GrowthBook, or a home-built system) moves the assignment decision upstream — to the server, or to an edge worker — before the response is sent.

A typical server-side integration looks like this:

// Next.js server component example
import { getOptimizelyClient } from '@/lib/optimizely';

export default async function ProductPage({ params }) {
  const client = await getOptimizelyClient();
  const userId = await getUserId();
  const decision = client.decide(userId, 'price_display_experiment');

  return (
    <ProductHero priceVariant={decision.variationKey} />
  );
}

The component receives priceVariant as a prop and renders accordingly. No post-load DOM mutation. No flicker. The HTML that arrives in the browser already contains the variant content.

What changes for the developer

The test is now real code

With DOM injection, the experiment often lives in a testing tool’s WYSIWYG editor, or in a separate JavaScript snippet hosted on the vendor’s CDN. It is adjacent to your codebase, not part of it.

Flag-based tests live inside your application code. The feature flag decision is a branch in your component or route handler. This has consequences:

  • Deployment coupling. Shipping a new test variant requires a deployment. You cannot push a new experiment without going through your release pipeline.
  • Code ownership. The experiment is subject to code review, linting, type checking. This is a feature, not a drawback.
  • Cleanup discipline becomes critical. The flag evaluation code stays in your codebase until someone removes it. Without a deliberate cleanup process, flags accumulate and create dead branches that nobody is confident to delete.

QA changes entirely

Validating a DOM-injected test often means loading the page with a force-variation URL parameter and checking the visual output. A five-minute task.

Validating a flag-based test requires either:

  • A local development override (most SDKs support forcedVariations in config)
  • A staging environment with flag assignment controlled per-user
  • A URL parameter that your middleware reads to force a variant
// Forcing a variant in development
const decision = process.env.NODE_ENV === 'development' && context.query.variant
  ? { variationKey: context.query.variant as string }
  : client.decide(userId, 'price_display_experiment');

Building this into your development workflow from the start saves substantial time.

Tracking is different

DOM-injected tests typically use the testing tool’s built-in event tracking — you call window.optimizely.push({ type: 'event', eventName: 'purchase' }) and the tool handles the rest.

Server-side experiments usually need explicit instrumentation. The assignment must be recorded somewhere (your analytics platform, your data warehouse) independently of the tool that made it. This creates a join problem: experiment assignments in Tool A, conversions in Tool B. Getting this right is where most server-side migrations stumble.

// Every decision must be logged explicitly
optimizely.onDecision(({ type, userId, flagKey, decision }) => {
  analytics.track('Experiment Viewed', {
    userId,
    experimentId: flagKey,
    variationId: decision.variationKey,
    timestamp: Date.now(),
  });
});

When to stay client-side

DOM injection is the right choice when:

  • You are an agency working across multiple client stacks without server access
  • The element being tested is below the fold (flicker is less impactful)
  • Test cycle time matters more than architectural cleanliness
  • The engineering team is not involved and you need to move fast

When to move server-side

Flag-based is worth the investment when:

  • You are building an internal, durable testing programme
  • Above-the-fold content is being tested and flicker affects perceived quality
  • You want to test server-rendered data (prices from a database, personalised recommendations)
  • You are already using feature flags for release management and want unified tooling

The migration is not a big-bang event. I have done it incrementally: new experiments go flag-based from day one, existing DOM tests run to conclusion and are retired. Within six months, the codebase has naturally transitioned.

The practical bottom line

Client-side testing is not “wrong” — it is a tool with a specific set of trade-offs. Flag-based experimentation solves the problems that emerge when a testing programme matures: flicker, performance, server-rendered content, and the desire for tests that behave like production code.

The jump is larger than it looks from the outside, mostly because of tracking and deployment coupling. But if your programme is growing and you are repeatedly fighting the same DOM-injection problems, the architecture is telling you something.