You can run production-grade A/B experiments entirely on Cloudflare Workers with KV for config and D1 for results — no third-party tools, no client-side bloat, and no privacy compromises. Here's exactly how we built it for AiSalonHub, a nail salon directory running on EmDash CMS (Astro + Cloudflare Workers).
The Problem
AiSalonHub connects users with nail salons across the US. Every page in the funnel — search, comparison, booking — is a conversion opportunity. But without data, we were guessing.
Before building our framework, we couldn't answer basic questions:
- Does a "Book Now" button above the fold outperform one below the service menu?
- Do users prefer grid or list views for comparison results?
- Which homepage headline drives more searches?
Third-party tools like Optimizely cost $50k+/yr, add 60-120KB of JS per page, and raise privacy flags with cross-site tracking. Google Optimize is being sunset. We needed something lightweight, server-side, and integrated into our EmDash stack.
| Approach | Cost | Latency Impact | Privacy | Control |
|---|---|---|---|---|
| Optimizely | $50k+/yr | High (client JS) | Third-party cookies | Limited |
| Google Optimize | Free (sunset) | Medium | Data sharing | Limited |
| Our Framework | ~$0.30/mo | ~5ms (KV read) | Full control | Complete |
The Solution
We built a server-side A/B testing framework using three Cloudflare primitives already in our stack:
- **Workers KV** — stores experiment configurations with sub-10ms reads at the edge
- **D1** — logs every impression and conversion with full SQL queryability
- **Workers runtime** — intercepts requests, assigns variants deterministically, and injects experiment data into component props
Zero new infrastructure. Zero new dependencies. Roughly $0.30/month at our traffic levels. Every experiment runs entirely on the edge — no client-side JavaScript required.
Architecture
Data flow when a user hits AiSalonHub:
```
User Request → Cloudflare Worker
→ KV lookup: active experiments & split ratios
→ Cookie check: already assigned?
→ Yes: serve assigned variant
→ No: deterministic assignment (consistent hash)
→ Write impression to D1
→ Inject experiment data into Astro island props
→ Return modified HTML
User Action (click, booking)
→ sendBeacon to /api/track-conversion
→ D1 write: {experiment_id, variant, user_id, type}
```
Experiment configs live in KV under keys like `exp:homepage-hero-v1`:
```json
{
"id": "homepage-hero-v1",
"name": "Homepage Hero Headline Test",
"status": "running",
"variants": [
{ "id": "control", "name": "Find Your Perfect Salon", "weight": 0.5 },
{ "id": "variant_a", "name": "Book a Nail Appointment in Seconds", "weight": 0.5 }
],
"target_metric": "search_ctr",
"min_sample_size": 1000
}
```
D1 schema for results:
```sql
CREATE TABLE experiment_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
experiment_id TEXT NOT NULL,
variant_id TEXT NOT NULL,
user_id TEXT NOT NULL,
event_type TEXT NOT NULL, -- 'impression' | 'conversion'
page TEXT NOT NULL,
metadata TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_exp ON experiment_events(experiment_id, event_type, created_at);
```
This gives us everything we need: conversion rates per variant, statistical significance via SQL, and segmentation by device or geography via the metadata field.
Step-by-Step Implementation
1. Configure KV and D1 Bindings
In `wrangler.toml`:
```toml
[[kv_namespaces]]
binding = "EXPERIMENTS"
id = "your-kv-namespace-id"
[[d1_databases]]
binding = "DB"
database_name = "aisalonhub-db"
database_id = "your-database-id"
```
2. The Experiment Middleware
Core logic that runs on every request:
```typescript
export async function handleExperiment(request: Request, env: Env, ctx: ExecutionContext) {
const url = new URL(request.url);
const userId = getOrCreateUserId(request);
const activeKeys = await env.EXPERIMENTS.list({ prefix: "exp:" });
const assignments: Record<string, string> = {};
for (const key of activeKeys.keys) {
const config: ExperimentConfig = await env.EXPERIMENTS.get(key.name, "json");
if (config.status !== "running") continue;
const variant = assignVariant(userId, config);
assignments[config.id] = variant;
ctx.waitUntil(logImpression(env.DB, config.id, variant, userId, url.pathname));
}
return assignments;
}
function assignVariant(userId: string, config: ExperimentConfig): string {
const hash = simpleHash(userId + config.id);
let cumulative = 0;
const totalWeight = config.variants.reduce((s, v) => s + v.weight, 0);
const normalized = hash / Number.MAX_SAFE_INTEGER;
for (const v of config.variants) {
cumulative += v.weight / totalWeight;
if (normalized <= cumulative) return v.id;
}
return config.variants[config.variants.length - 1].id;
}
```
Deterministic assignment via consistent hashing means users see the same variant across sessions without server-side state — critical for accurate conversion attribution.
3. Inject Into Astro Islands
Experiments flow to the client through Astro component props, not JavaScript:
```astro
---
import HomepageHero from "../components/HomepageHero.astro";
const exps = Astro.locals.experiments || {};
const hero = exps["homepage-hero-v1"];
---
<HomepageHero
client:load
headline={hero === "variant_a"
? "Book a Nail Appointment in Seconds"
: "Find Your Perfect Salon"}
experimentId="homepage-hero-v1"
variant={hero}
/>
```
4. Conversion Tracking Endpoint
```typescript
export async function trackConversion(request: Request, env: Env) {
const { experiment_id, variant_id, user_id, conversion_type, page } = await request.json();
await env.DB.prepare(`
INSERT INTO experiment_events
(experiment_id, variant_id, user_id, event_type, page, metadata)
VALUES (?, ?, ?, 'conversion', ?, ?)
`).bind(
experiment_id, variant_id, user_id, page,
JSON.stringify({ conversion_type, timestamp: new Date().toISOString() })
).run();
return new Response(JSON.stringify({ ok: true }), {
headers: { "Content-Type": "application/json" }
});
}
```
On the client, `navigator.sendBeacon` fires on key actions — booking clicks, search submissions, phone number taps. It works even if the user navigates away mid-request, ensuring no conversions are lost.
5. Analyze Results
Simple SQL to evaluate any experiment:
```sql
SELECT
variant_id,
COUNT(DISTINCT CASE WHEN event_type = 'impression' THEN user_id END) as impressions,
COUNT(DISTINCT CASE WHEN event_type = 'conversion' THEN user_id END) as conversions,
ROUND(
100.0 * COUNT(DISTINCT CASE WHEN event_type = 'conversion' THEN user_id END) /
NULLIF(COUNT(DISTINCT CASE WHEN event_type = 'impression' THEN user_id END), 0), 2
) as conversion_rate
FROM experiment_events
WHERE experiment_id = 'homepage-hero-v1'
GROUP BY variant_id;
```
We wrapped this in an admin dashboard endpoint with chi-squared significance calculations, viewable at `/admin/experiments`.
Results
Here are the actual improvements since deploying the framework:
| Experiment | Control | Variant | Lift | Significance |
|---|---|---|---|---|
| Homepage Headline | 3.2% CTR | 4.7% CTR | +46.9% | p < 0.01 |
| Booking Button Position | 8.1% | 11.3% | +39.5% | p < 0.05 |
| List vs Grid View | 5.4% | 6.8% | +25.9% | p < 0.05 |
| Price-First vs Rating-First | 12.1% | 11.4% | -5.8% | Not sig. |
**Homepage Headline** was our biggest win. The original "Find Your Perfect Salon" was too generic. "Book a Nail Appointment in Seconds" drove urgency and clarified value — a 47% lift in search CTR after 2,800 impressions (p < 0.01).
**Booking Button Position** taught us that placing the CTA immediately after the service menu captures users at peak intent. Without the experiment, the button would have stayed at the bottom of the page.
**List vs Grid View** surprised our designers — users clicked through significantly more in list view despite the design team's preference for grids. Data overruled opinion.
**Price vs Rating** was our null result, equally valuable. We confirmed the order doesn't matter, letting us prioritize design consistency.
Cost Breakdown
Over 30 days with ~15,000 daily visitors running 3 concurrent experiments:
- KV reads: ~1.3M ops → $0.09
- D1 writes: ~60K rows → $0.15
- D1 reads (analytics): ~500 ops → $0.01
- Workers CPU: negligible (< $0.05)
- **Total: ~$0.30/month**
Compare that to Optimizely at $50,000/year, and the savings are obvious — not just financial. We also eliminated 60-120KB of third-party JavaScript per page load.
Key Takeaways
- **Server-side A/B testing on Workers is better than client-side alternatives.** No JS bloat, no third-party cookies, full data control, and minimal latency impact.
- **KV + D1 is the ideal experiment stack.** KV handles the hot path (sub-10ms config lookups at the edge), while D1 provides durable, SQL-queryable storage for results.
- **Start with simple experiments.** A single headline change lifted conversions by 47%. Test copy and layout before attempting multivariate designs.
- **Null results save you from shipping changes that don't matter.** They focus effort on experiments that actually move the needle.
- **Consistent hashing for assignment** means users see the same variant across sessions without server-side state — critical for accurate conversion attribution.
- **This pattern is portable across any Cloudflare Workers application.** Swap in your KV namespace and D1 IDs, and you'll be running your first experiment in under an hour. The full source lives in the AiSalonHub repo at `~/Projects/AIKitLLC/AiSalonHub/src/middleware/experiment.ts`.