Run A/B Tests on WordPress with Optimizely Web Experimentation

Loading...·7 min read

WordPress is the most common place a first A/B test gets installed and one of the most common places it quietly fails. The reason is structural: nothing on a WordPress page has a fixed position in the <head>. Themes, plugins, caching layers and consent managers all enqueue scripts into the same hook, ordered by a priority number that most people never look at. A snippet that works perfectly on a staging site can end up three plugins deep on production and flicker on every page load.

This guide covers the two halves of the integration that actually matter: getting the Optimizely Web Experimentation snippet to run before anything paints, and forwarding the events WordPress sites already emit — WooCommerce purchases, WPForms submissions — into Optimizely as custom events. It assumes a working knowledge of what the Optimizely Results page reports, and it is worth sizing the test on the sample size calculator first, because most WordPress sites running this integration are content sites where the conversion is a form fill at one or two percent.

How Optimizely Web Experimentation Runs on WordPress

The snippet is a synchronous script that decides the visitor's variation before the browser paints, applies the DOM changes, and exposes a global command queue. Everything else on the page can push custom events onto that queue. WordPress's job in this integration is only to put the script tag in the right place and to expose its own events somewhere a small bridge can read them.

flowchart TD
    A[Request hits WordPress] --> B{Served from page cache?}
    B -->|yes| C[Cached HTML with snippet inline]
    B -->|no| D[wp_head runs, plugins enqueue in priority order]
    D --> C
    C --> E[Optimizely snippet decides variation before paint]
    E --> F[WooCommerce / WPForms push to dataLayer]
    F --> G{Event in the allowlist?}
    G -->|yes| H[Push onto the Optimizely queue]
    G -->|no| I[Dropped, silently]

Where the Snippet Actually Ends Up

Every script in the head arrives through wp_head, and every callback registered on that hook carries a priority. The default is 10. Consent managers commonly register at 1, analytics plugins at 5 to 20, and theme functions wherever the theme author felt like. If your snippet is registered at the default priority, it will sit below anything registered lower, and below anything a plugin printed directly.

That ordering is the whole flicker story on WordPress. The fix is not to make the script asynchronous — that guarantees the problem — but to register it at a priority low enough that nothing meaningful precedes it.

Plugins, Caching and the Order of the Head

Full-page caching plugins serve a stored copy of the finished HTML. This is good for Optimizely: the snippet is inside the cached HTML and runs on every request regardless. It becomes a problem only when a caching plugin's optimisation features get involved — script combining, deferring, or moving scripts to the footer will all break a synchronous snippet, and most of them are on by default in the "recommended" preset.

Prerequisites

  • The Optimizely Web Experimentation snippet URL for your project.

  • A child theme, or another way to edit functions.php that survives a theme update.

  • Custom events created in Optimizely for each WordPress event you intend to forward, with their API names noted. An event pushed under a name that does not exist in Optimizely is discarded without an error.

  • Event properties created in Optimizely for any field you want to filter on — fifteen per event, five predefined and ten custom.

  • A dataLayer, from either the WooCommerce Google Analytics integration, a GA4 plugin, or Google Tag Manager. All of them push the same shapes.

Step 1: Place the Snippet Above Everything Else

Using a Child Theme

Add this to the child theme's functions.php. Priority 0 puts it ahead of every default-priority callback, and printing the tag directly avoids the enqueue system entirely — which matters because optimisation plugins rewrite enqueued scripts and leave printed ones alone.

<?php
add_action( 'wp_head', 'optipilot_optimizely_snippet', 0 );

function optipilot_optimizely_snippet() {
    ?>
    <script src="https://cdn.optimizely.com/js/YOUR_PROJECT_ID.js"></script>
    <?php
}

Then exclude that file from any script optimisation. In most caching plugins this is an "exclude from JS minify/combine/defer" field, and the value to add is cdn.optimizely.com. Skipping this step is the single most common cause of a WordPress site that flickers after the caching plugin is configured; the flicker guide covers what the visitor actually sees.

Why Not a Header-Scripts Plugin

Plugins that let you paste code into the head are convenient and almost always register at the default priority or later, which puts your snippet below the consent manager and below other analytics. They are fine for a tag that can run late. A variation decision cannot run late.

Step 2: Forward WooCommerce and WPForms Events

WordPress sites rarely need a bespoke event layer, because the plugins already push a dataLayer array. The bridge reads from it and republishes selected events onto the Optimizely queue.

The Event Map

Forward the events a metric will be built on and nothing else. view_item and page_view fire on nearly every request and would multiply your event volume without improving a single decision.

{
  "events": {
    "purchase": "purchase_completed",
    "add_to_cart": "add_to_cart",
    "wpforms_form_submit": "lead_form_submitted"
  },
  "currencyProperty": "value",
  "numericProperty": "quantity",
  "properties": [
    { "from": "item_category", "to": "Category" },
    { "from": "item_id", "to": "SKU" },
    { "from": "form_title", "to": "Form Name" }
  ]
}

revenue is a reserved tag and must be an integer number of cents. Optimizely discards a non-integer revenue value, so an order total of 54.99 pushed unconverted produces a revenue metric that reports zero rather than an obviously wrong number. value is a plain number and is where an item quantity belongs.

The dataLayer Bridge

WooCommerce nests its fields under ecommerce; WPForms pushes them flat. The bridge merges both so the map does not have to know which plugin produced the event.

const FORWARDED_EVENTS = {
  purchase: 'purchase_completed',
  add_to_cart: 'add_to_cart',
  wpforms_form_submit: 'lead_form_submitted',
};

function normalize(entry) {
  const { event, ecommerce, ...rest } = entry;
  return {
    name: event,
    properties: Object.assign({}, rest, ecommerce || {}),
  };
}

function forwardToOptimizely(entry) {
  const source = normalize(entry);
  const eventName = FORWARDED_EVENTS[source.name];
  if (!eventName) {
    return;
  }

  const properties = source.properties;
  const tags = {};

  if (typeof properties.value === 'number') {
    tags.revenue = Math.round(properties.value * 100);
  }
  if (typeof properties.quantity === 'number') {
    tags.value = properties.quantity;
  }

  window['optimizely'] = window['optimizely'] || [];
  window['optimizely'].push({
    type: 'event',
    eventName: eventName,
    tags: tags,
    properties: {
      Category: properties.item_category,
      SKU: properties.item_id,
      'Form Name': properties.form_title,
    },
  });
}

window.dataLayer = window.dataLayer || [];
window.dataLayer.forEach(forwardToOptimizely);

const nativePush = window.dataLayer.push;
window.dataLayer.push = function (entry) {
  forwardToOptimizely(entry);
  return nativePush.apply(this, arguments);
};

Replaying the existing array before wrapping push is not optional. WooCommerce pushes the purchase event during page render on the order-received page, which is often before your bridge has loaded, and a bridge that only wraps future pushes will miss exactly the event that matters most.

What the Payload Looks Like

A completed WooCommerce order for £54.99 and two items produces this:

{
  "type": "event",
  "eventName": "purchase_completed",
  "tags": { "revenue": 5499, "value": 2 },
  "properties": { "Category": "Footwear", "SKU": "RUN-114-BLK" }
}

You can run the same mapping event by event in the WordPress integration demo, including a view_item that is deliberately outside the allowlist and is dropped.

Step 3: Keep Page Caching From Breaking Bucketing

Full-page caching does not interfere with bucketing, because the decision happens in the browser after the cached HTML arrives. What does interfere is caching plugins that vary the cache by cookie and then serve a different HTML variant to bucketed visitors, and CDN-level "optimisation" that strips or defers scripts.

If your host offers an edge cache with HTML minification, test it explicitly: minifiers occasionally reorder inline scripts, and reordering the snippet below a consent manager reintroduces flicker on production only.

Gotchas

Ad blockers. WordPress audiences skew towards blockers more than commerce audiences do, and a blocked snippet means an unbucketed visitor rather than a wrong one. It is worth understanding how ad blockers affect experiment traffic before reading a low sample count as a bug.

Consent managers that block by default. If the consent tool holds scripts until acceptance, the snippet does not run for anyone who has not accepted, and your sample becomes consented visitors only. That is a defensible choice, but the results generalise to that group and not to all traffic.

Theme updates. Editing the parent theme's functions.php works until the next update wipes it. Use a child theme.

Two forwarding paths at once. If you also install a Google Tag Manager tag that pushes to Optimizely, every event fires twice. Pick one path per event.

Verifying the Integration

Load a page with the browser console open and confirm window.optimizely is defined before any plugin script has run — in practice, that it appears at the very top of the network waterfall. Then submit a WPForms form and check that a push with eventName: "lead_form_submitted" reaches the queue.

For WooCommerce, place a test order and confirm the purchase push carries an integer revenue tag. Then leave the experiment in draft for a day and compare Optimizely's conversion count against WooCommerce's own order report for the same window. The two will not be identical — Optimizely counts only bucketed visitors — but a ratio that moves around from day to day usually means the bridge is missing the replay step above, not that the store is broken.