Client side A/B testing refers to the method of performing experimentation related changes to a web application in the browser — sometimes without integrating code changes to the actual application source code. This is popular in the industry as the method usually helps to cut down resources required for experimentation. Our objective is to explore ways of conducting the same outcome, without the performance penalties it comes with today.
The key to the approach being outlined here is considering control as the base document, and expressing each variant as a series of transformations being applied onto control.
This is analogous to how client side A/B testing is conducted today — except we want to improve it further and make it more performant. How do we do that?
-
Standardize the serialization of changes to a common schema.
-
Whenever possible, optimize for applying a transformation where it makes the best sense (pre-UA, or on-UA). This helps to:
a. reduce the bytes transferred, b. potentially improve cache hit rates at serving c. reduce computation needs on each client.
-
Apply transformations in a performant way, without blocking rendering of the page — ideally without any performance metric degradation.
We could represent the transformations required for reaching a variant as a serialization of an ordered set of operations as follows:
[
[flags, selector, operation, ...payload],
[flags, selector, operation, ...payload],
...
]
where each field can be defined as follows
flags
|
A bit field that indicates the type of transformation.
For an initial version, we can support the following flags:
|
|||||||||
selector |
A CSS selector that targets the `HTMLElement` for transformation. Depending on the target platform and capability, only a subset of CSS selectors might be applicable here (and that needs to be documented and revisioned as the support changes). | |||||||||
operation |
A numeric value indicating the operation to be performed.
For a list of operations and their supported parameters, see the section
"Operations" below.
The operations are expected to be idempotent, i.e., repeated applications of the operations should be possible and not have unintended side effects. |
|||||||||
payload |
A variable number of arguments to support the operation. Should follow the specification of the operation used. |
Operation | Code | Description | Arguments |
OP_CUSTOM_JS | 0 | Executes a custom Javascript block of code against the element. |
|
OP_INSERT_BEFORE | 1 | Inserts content right before the element. |
|
OP_INSERT_AFTER | 2 | Inserts content right after the element. |
|
OP_PREPEND | 3 | Inserts content right after the start tag of the element. |
|
OP_APPEND | 4 | Inserts content right before the end tag of the element. |
|
OP_REPLACE | 5 | Replaces the element with the provided content. |
|
OP_SET_INNERHTML | 6 | Replaces the content of the element with provided content. |
|
OP_REMOVE | 7 | Removes the element and its children | none |
OP_SET_ATTRIBUTE | 8 | Sets an attribute’s value on the element. |
|
OP_REDIRECT | 9 | Redirect the user to a different page or URL. During `PRE_UA` phase, this will perform an HTTP redirect and during `ON_UA`, this will result in client side redirection. Additional arguments supplied can select the response code during PRE_UA phase. |
|
- A
PRE_UA
component — responsible for applying static markup transformations, and injecting the necessaryON_UA
components into the document. - An
ON_UA
component — responsible for client-side transformations. - The set serialized transformations.
A PRE_UA
component is responsible for applying the transformations flagged as PRE_UA
, i.e., the transforms that make best sense to be applied before User-Agent. This could be done at:
- the Origin itself, or
- an Edge component at the Origin (like a web server plugin, or a proxy), or
- a CDN compute node that fronts the Origin as a proxy, or
- an implementation inside the UA/Browser prior to parsing the document (this has a latency impact).
The important aspect is that the PRE_UA
transformations are best done at a step prior to UA’s parsing.
PRE_UA
component has the following responsibilities:
- Apply all of the
PRE_UA
transformations for the selected experiment, onto the response. - Collect the remaining
ON_UA
transformations if any, and serialize them along with the client-side transform applicator code. Inject this into theHEAD
of the document.
The client-side/ON_UA
components are responsible for applying ON_UA
transformations as necessary, and consists of two parts:
- The remaining
ON_UA
transformations from the experiment configuration. - The client side transformation applicator code.
The applicator component injected into the HEAD
of the document has the following parts to it:
- A
MutationObserver
client code that listens for DOM changes. - The listening code looks for DOM changes that match the
ON_UA
selectors, as (1) the browser is parsing the document, or (2) the client side Javascript is making changes to the DOM. - One or more identified DOM mutations that match the selectors are queued and deferred for processing until the next repaint, via a
requestAnimationFrame
callback. - The queue is processed, applying each
ON_UA
transformation as needed — via running the matchingDOMElement
through the supplied transformation function.
A simple implementation could be as follows:
(function applyTransformations(transformations) {
const transform = (target) => {
if (target.nodeType !== 1) return;
for (const [flags, selector, transform] of transformations) {
if (!(flags & ON_UA) || !transform) continue;
const node = target.querySelector(selector);
if (!node) continue;
try {
transform(node);
} catch (e) { /* report back transform error */ }
}
}
const queue = new Set();
const processMutationsOnPaint = () => {
for (const target of queue) {
transform(target);
queue.delete(target);
}
}
var observer = new MutationObserver(function(mutations){
for (const {target} of mutations) {
if (!queue.size) requestAnimationFrame(processMutationsOnPaint);
queue.add(target);
}
});
return observer.observe(document.documentElement, {
childList: true,
subtree: true
});
})([ /* Array of transformations */ ]);
The code example above doesn’t account for any selector performance optimizations, or error reporting.
This block results in approximately 365 bytes of minified code when processed via terser, and forms the Client-side Transform Applicator.