A/B Testing Without Third-Party Scripts: Lambda@Edge and Client Fingerprinting

January 5, 20266 min read
awslambda-edgeab-testingperformancearchitecture

At Publishing Factory, we ran A/B tests across a portfolio of high-traffic web properties. The standard approach would have been to drop in a third-party A/B testing tool — Google Optimize, Optimizely, VWO — and start experimenting. We tried that route first. The results weren't great.

Third-party A/B testing scripts add weight to your pages. They execute JavaScript on the client, which means they compete with your application code for the main thread. They introduce a flash of unstyled content (FOUC) while the script decides which variant to show. They set cookies and send data to external servers, raising privacy concerns under GDPR. And they add a dependency on a service you don't control — if their CDN is slow or their script has a bug, your page performance suffers.

I wanted something different: an A/B testing system that made decisions before the page even reached the browser, added zero client-side weight, respected user privacy, and gave us full control over the testing and reporting pipeline.

So I built one using AWS Lambda@Edge and client fingerprinting.

The Core Insight: Deterministic Assignment Without Cookies

The fundamental challenge of A/B testing is consistency. When a user visits your site and sees variant B, they need to see variant B every time they return. Otherwise your test data is noise.

Traditional tools solve this with cookies. First visit: assign a variant, set a cookie. Subsequent visits: read the cookie, serve the same variant. Simple and effective — but cookies have problems. Users clear them. Privacy regulations restrict them. Some browsers block third-party cookies by default.

The alternative I explored was client fingerprinting combined with a deterministic random seed. The idea: extract enough information from the incoming HTTP request to create a stable fingerprint for that client, then use that fingerprint as the seed for a pseudorandom number generator that deterministically assigns a variant.

The fingerprint doesn't need to uniquely identify a person — it just needs to be stable enough that the same person on the same device gets the same variant consistently. We used a combination of request headers: User-Agent, Accept-Language, Accept-Encoding, and the client IP address. Hashed together, these produce a fingerprint that's stable across sessions for the same user on the same device, without storing anything on the client.

Feed that hash into a seeded random number generator, and you get a deterministic variant assignment. Same fingerprint always produces the same random number, which always maps to the same variant. No cookies. No client-side state.

Why Lambda@Edge

The variant decision needs to happen before the page is served. If you make the decision on the client side, you get FOUC. If you make it on your origin server, you add latency and complexity to your application code.

Lambda@Edge runs at AWS CloudFront's edge locations — the CDN layer. When a request arrives at the nearest CloudFront edge, the Lambda function executes, determines the variant, and modifies the request before it reaches your origin. The user gets the correct variant on the first byte, with no client-side JavaScript involved.

The flow looks like this:

User request → CloudFront edge → Lambda@Edge (fingerprint → seed → variant) → Origin (serves variant) → User

The Lambda function is lightweight. It reads the request headers, computes the fingerprint hash, runs the seeded random generator, and either modifies the request path or adds a header indicating which variant to serve. Execution time is typically under 5 milliseconds.

This architecture has a side benefit: your origin server doesn't need to know about A/B testing at all. It just serves whatever path or variant it's asked for. The testing logic lives entirely at the edge, cleanly separated from your application.

The Implementation

The Lambda@Edge function itself is surprisingly compact. On each viewer request event, it extracts the relevant headers, concatenates them into a string, and hashes the result with SHA-256. The first 8 characters of the hash are converted to an integer, and a modulo operation assigns the user to a variant bucket.

fingerprint = SHA256(User-Agent + Accept-Language + Accept-Encoding + IP)
bucket = parseInt(fingerprint.substring(0, 8), 16) % 100
variant = bucket < 50 ? "A" : "B"

The % 100 gives you percentage-based traffic splitting. Want a 70/30 split? Change the threshold. Want three variants? Divide the range into thirds. The deterministic seed means the same user always lands in the same bucket, regardless of when they visit.

We stored the test configuration — which tests are active, what the split ratios are, which URLs are under test — in a lightweight JSON config that the Lambda function reads. Updating a test meant deploying a new config, not changing code.

Building the Reporting and Champion Election

Running the test is half the problem. The other half is measuring results and acting on them.

We built a reporting pipeline that tracked variant performance through CloudFront access logs. Each request logged which variant was served, and downstream analytics tracked conversion events. The reporting system aggregated this data and calculated statistical significance for each test.

The interesting part was what we called the "champion election" system. When a test reached statistical significance — when we were confident that one variant outperformed the other — the system automatically promoted the winning variant. The champion became the default, the losing variant was retired, and the Lambda function updated its routing.

This closed the loop without manual intervention. A product manager could set up a test, define the success metric, and the system would run the test, collect data, determine the winner, and promote it. The engineering team only got involved if something unusual happened.

Validating With Artillery.js

Before deploying to production, I needed confidence that the system would hold under real traffic. The fingerprinting logic adds computation at the edge, and I needed to verify that it didn't degrade response times at scale.

I built a load testing suite using Artillery.js that simulated diverse traffic patterns. The tests varied the request headers to produce different fingerprints, verified that the same fingerprint consistently received the same variant, and measured response times under increasing load.

The results were reassuring. The Lambda@Edge execution added roughly 3-5 milliseconds to request handling — imperceptible to users. Variant assignment was 100% consistent across repeated requests with the same headers. And the system scaled linearly because each edge location handles its own computation independently.

Artillery also helped catch a subtle bug: under very high concurrency, the config loading in the Lambda function could occasionally hit a cold start penalty. We fixed this by keeping the config small enough to bundle directly into the function rather than fetching it from S3 at runtime.

When to Build vs When to Buy

I want to be honest about the tradeoffs. Building your own A/B testing system is not always the right choice. Third-party tools offer visual editors, sophisticated statistical analysis, integration with analytics platforms, and support teams. If your team doesn't have the engineering capacity to maintain a custom solution, a managed tool is the pragmatic choice.

We built our own because our specific constraints demanded it: performance-sensitive pages where every millisecond of client-side JavaScript mattered, strict privacy requirements that ruled out third-party cookies and data sharing, a high volume of tests across many properties that made per-seat licensing expensive, and an engineering team that could maintain the system.

The deciding factor was control. When A/B testing is a core part of your product optimization workflow — not a one-off experiment but a continuous process — owning the infrastructure pays for itself. You can customize the statistical models, integrate directly with your data pipeline, and evolve the system as your needs change.

If you run a handful of tests per quarter, use Optimizely. If A/B testing is part of your daily workflow and you have the engineering bandwidth, consider building something that fits your exact needs. The Lambda@Edge approach isn't the only way, but the principle — make decisions at the edge, keep clients lightweight, own your data — applies broadly.

The system ran in production for years, handling millions of requests and dozens of concurrent tests. The pages it served were faster than any third-party-scripted alternative. And the best part: users never knew it was there. Which, for infrastructure, is the highest compliment.