Google Optimize server side experiments are a great way to run A/B tests for free.
The problem?
They’re really slow. Really really slow.
Cloudflare Workers are a way to make them faster with essentially zero code changes to your website.
With Google Optimize (the normal version), there are two round trips:
The result, your user is waiting around.
The alternative is Cloudflare workers. This Cloudflare worker randomly assigns users for their A/B experience.
The name doesn’t matter, but be sure to select “A/B” test.
Add a variant
Your dashboard should look something like this:
To let Google Optimize know that your experiment is run serverside, click on the pencil
And then update the URL to SERVER_SIDE
You can ignore this warning.
Copy your experiment ID and then start the experiment.
For UA, you need to update the implmentation of your gtag.
For GA4, you’ll send a separate event.
Before:
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag("js", new Date());
gtag("config", "GA_MEASUREMENT_ID");
Universal Analytics
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag("js", new Date());
if(window.experimentID && window.experimentVariation){
gtag("config", "GA_MEASUREMENT_ID",
{ experiments:
[{id: window.experimentID, variant: window.experimentVariation}]
});
}) else {
gtag("config", "GA_MEASUREMENT_ID");
}
GA4
In GA4, you need to send an event like so:
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag("js", new Date());
gtag("config", "GA_MEASUREMENT_ID");
if(window.experimentID && window.experimentVariation){
gtag('event', 'experiment_impression', {
'experiment_id': window.experimentID,
'variant_id': experimentVariation,
'send_to': 'GA_MEASUREMENT_ID',
});
})
Get started with Cloudflare workers here
// This is the entrypoint to a Cloudflare worker.
// It just tells Cloudflare to apply a function to someone's request.
addEventListener("fetch", (e) => {
e.respondWith(abExperiment(e.request));
});
// Here is where you configure your experiments
// you can have multiple experiments running simultaneously
const experiments = [
{
expPath: "/path-a", // Run the experiment if a user comes to this path
experimentID: "spt_aLgkQjeC0iHlUe-0WA",
// Each variation is given a path and some details
variants: [
{
path: "/path-a",
// this key is set as a cookie, so future visits are consistent
key: "control",
// user's are bucketed to different experiences using a random number
// generation between 0 and 1.
// In this example, if that number is between .5 and 1, the user will
// get this variation.
//
// You can control the likelihood of a user getting a variation with
// these variables
likelihoodStart: 0.5,
likelihoodEnd: 1,
// The experiment variation is just the variant's index in this variant array
// I chose to be explicit (rather than just grabbing the index)
// for flexibility of changing the variant array
experimentVariation: 0,
},
{
path: "/path-b",
key: "full-page-variant",
likelihoodStart: 0,
likelihoodEnd: 0.5,
experimentVariation: 1,
},
],
},
] as experiments[];
export async function abExperiment(request: Request) {
let requestURL = new URL(request.url);
// To start, not every request should run an experiment. This
// filters out tests
if (!shouldRunExperiment(requestURL.pathname)) {
// this caches your content so subsequent requests are very
// vast
return fetch(requestURL.toString(), {
cf: {
cacheTtl: 6000000,
cacheEverything: true,
},
});
}
// This gets the experiment ID and variation
// It's wrapped in a try/catch block just in case
// something goes wrong with your configuration
let experimentID = "";
let experimentVariation = {} as variant;
try {
const experimentVariables = getExperimentVariables(requestURL.pathname);
experimentVariation = getExactExperimentData(experimentVariables);
experimentID = getExperimentID(requestURL.pathname);
} catch (e) {
console.log(e);
return new Response(e as string);
}
// Update the URL to include the experiment subdirectory, if applicable.
const resourceURL = new URL(
[requestURL.origin, experimentVariation.path, requestURL.search].join("")
);
// This fetches the page of your experiment
const res = await fetch(resourceURL.toString(), {
cf: {
cacheTtl: 6000000,
cacheEverything: true,
},
});
const contentType = res.headers.get("Content-Type");
// If the response is HTML, it can be transformed with
// HTMLRewriter -- otherwise, it should pass through
if (contentType?.startsWith("text/html")) {
return new HTMLRewriter()
.on(
"head",
new ElementHandler(
experimentVariation.experimentVariation,
experimentID
)
)
.transform(res);
} else {
return res;
}
}
class ElementHandler {
private experimentVariation: number;
private experimentID: string;
constructor(experimentVariation: number, experimentID: string) {
this.experimentVariation = experimentVariation;
this.experimentID = experimentID;
}
// this simple handler just sets the global variables for the experiment ID and
// experiment variation
element(element: any) {
element.append(
`
<script>
window.experimentID = '${this.experimentID}'
window.experimentVariation = '${this.experimentVariation}'
</script>
`,
{ html: true }
);
}
}
type experiments = {
expPath: string;
variants: variant[];
experimentID: string;
};
type variant = {
path: string;
key: string;
likelihoodStart: number;
likelihoodEnd: number;
experimentVariation: number;
};
// shouldRunExperiment matches paths in the experiment object
export const shouldRunExperiment = (path: string): boolean => {
let shouldRun = false;
experiments.forEach((exp) => {
if (path == exp.expPath) {
shouldRun = true;
}
});
return shouldRun;
};
// getExperimentVariables returns the first matching variable
// the error condition technically should never be reached, but
// who know's how you're going to change this ;)
export const getExperimentVariables = (path: string): variant[] => {
for (let i = 0; i < experiments.length; i++) {
if (experiments[i].expPath == path) {
return experiments[i].variants;
}
}
throw new Error("no matching variables");
};
// getExperimentID loops and gets the experimentID from the given path
export const getExperimentID = (path: string): string => {
for (let i = 0; i < experiments.length; i++) {
if (experiments[i].expPath == path) {
return experiments[i].experimentID;
}
}
throw new Error("no experiment ID");
};
// getExactExperimentData generates a random number to get a random
// experience based on the odds you set.
export const getExactExperimentData = (expValue: variant[]) => {
let randNumber = Math.random();
for (let i = 0; i < expValue.length; i++) {
if (
randNumber > expValue[i].likelihoodStart &&
randNumber < expValue[i].likelihoodEnd
) {
return expValue[i];
}
}
throw new Error("no matching experiment");
};