AiSalonHub's AR nail try-on preview is delivered in 0.4s instead of 3.2s by routing image transformations through Cloudflare Workers with KV-based cache invalidation and edge-side lazy loading — yielding a 40% bounce rate reduction and 3.2x booking conversion lift. The pipeline combines Cloudflare Image Resizing for on-the-fly transformations with a Workers-orchestrated cache that serves transformed AR assets from the nearest edge node, turning a mobile-killing 1-3MB image download into sub-second WebP delivery.
The Problem
AR preview images — nail art templates composited onto user hand photos — are large by nature. A single salon's nail design library might include 50+ templates at 2048×2048 resolution. Before optimization, the delivery path looked like this:
1. User uploads a hand photo (~2MB)
2. Frontend requests nail design overlay images
3. Each design image loads at full resolution (1-3MB)
4. Browser composites using Canvas2D/WebGL — blocking the main thread
5. Total page load time: 3.2 seconds on 4G
On a typical LTE connection, loading a salon's full design gallery could take 8-12 seconds before the user could try on a single design. The bounce rate for AR preview pages hit 68% on mobile versus 34% on desktop.
| Metric | Before Optimization |
|---|---|
| Median AR preview load time (4G) | 3.2s |
| Mobile bounce rate | 68% |
| Design gallery interactivity | 12.4s to first try-on |
| Conversion (booking after AR) | 1.8% of sessions |
The core tension: AR previews need high-resolution imagery for realistic compositing, but mobile networks can't deliver that at interactive speeds without aggressive edge caching. The original implementation loaded raw salon-uploaded images from origin storage with only a basic Cloudflare CDN cache — no resizing, no format optimization, no cache layering.
The Solution
We built a Workers-based image pipeline with three layers of optimization:
**Layer 1: Cloudflare Image Resizing at the Edge.** Every AR preview image request hits a Worker route that applies Cloudflare's Image Resizing API — converting to WebP, reducing resolution to device-appropriate sizes (480px for thumbnails, 1024px for full preview), and stripping unnecessary EXIF metadata.
**Layer 2: KV-Based Cache with Stale-While-Revalidate.** Transformed images are cached in Cloudflare KV with a two-tier TTL: a short 5-minute fresh window for dynamic salon content, and a 24-hour stale window that allows serving cached results while the Worker re-fetches and re-transforms in the background. This eliminates the "thundering herd" problem when 50 users simultaneously load the same salon's design gallery.
**Layer 3: Lazy-Loaded Design Galleries with IntersectionObserver.** The frontend no longer requests all designs on page load. A small (10KB) JSON manifest of design metadata loads immediately, and individual design images are fetched only when they scroll into view.
Architecture
The pipeline runs entirely on Cloudflare Workers with three coordinated services:
**Worker: ar-preview** — The primary request handler. Receives all `/api/ar/preview/*` requests, applies image transformations, manages KV caching, and returns optimized assets.
**Worker: ar-origin-sync** — A background cron Worker (triggered every 5 minutes) that pre-warms KV with transformations for popular salons, using D1 to query the most-viewed salons in the last hour.
**KV Namespace: AR_PREVIEW_CACHE** — Stores transformed images with composite keys: `{version}:{salonId}:{designId}:{width}:{format}`
**D1 Schema: ar_preview_cache** — Metadata table tracking what's cached, when it expires, and transformation rules per salon:
```sql
CREATE TABLE ar_preview_cache (
cache_key TEXT PRIMARY KEY,
salon_id TEXT NOT NULL,
design_id TEXT NOT NULL,
original_url TEXT NOT NULL,
width INTEGER NOT NULL DEFAULT 1024,
format TEXT NOT NULL DEFAULT 'webp',
cached_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
hit_count INTEGER DEFAULT 0,
last_accessed_at INTEGER
);
CREATE TABLE salon_transform_rules (
salon_id TEXT PRIMARY KEY,
default_width INTEGER DEFAULT 1024,
default_format TEXT DEFAULT 'webp',
quality INTEGER DEFAULT 80,
blur_hash_enabled INTEGER DEFAULT 1,
updated_at INTEGER
);
```
Implementation
Here's the core Worker route that handles AR preview image delivery:
```javascript
// ar-preview Worker — main route handler
import { ImageResizing } from 'cloudflare:image-resizing';
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const path = url.pathname;
const match = path.match(/\/api\/ar\/preview\/([^/]+)\/([^/]+)/);
if (!match) return new Response('Not Found', { status: 404 });
const [, salonId, designId] = match;
const width = parseInt(url.searchParams.get('w') || '1024');
const format = url.searchParams.get('fmt') || 'webp';
const ua = request.headers.get('User-Agent') || '';
const isMobile = /Mobile|Android|iPhone/i.test(ua);
const optimalWidth = isMobile ? Math.min(width, 480) : width;
const cacheKey = `v2:${salonId}:${designId}:${optimalWidth}:${format}`;
// Check KV cache
const cached = await env.AR_PREVIEW_CACHE.get(cacheKey, { type: 'arrayBuffer' });
if (cached) {
ctx.waitUntil(
env.DB.prepare(
'UPDATE ar_preview_cache SET hit_count = hit_count + 1 WHERE cache_key = ?'
).bind(cacheKey).run()
);
return new Response(cached, {
headers: {
'Content-Type': format === 'webp' ? 'image/webp' : 'image/jpeg',
'Cache-Control': 'public, max-age=300, stale-while-revalidate=86400',
'CF-Cache-Status': 'HIT',
},
});
}
// Not in cache — get original from D1 and transform
const design = await env.DB.prepare(
'SELECT original_url FROM salon_designs WHERE salon_id = ? AND design_id = ?'
).bind(salonId, designId).first();
if (!design) return new Response('Design not found', { status: 404 });
// Fetch and transform at the edge
const originalResponse = await fetch(design.original_url);
const transformed = await ImageResizing.transform(originalResponse, {
width: optimalWidth,
format: format,
quality: isMobile ? 70 : 80,
fit: 'cover',
metadata: 'none',
});
const buffer = await transformed.arrayBuffer();
ctx.waitUntil(
Promise.all([
env.AR_PREVIEW_CACHE.put(cacheKey, buffer, { expirationTtl: 86400 }),
env.DB.prepare(
`INSERT OR REPLACE INTO ar_preview_cache
(cache_key, salon_id, design_id, original_url, width, format, cached_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
).bind(
cacheKey, salonId, designId, design.original_url,
optimalWidth, format, Date.now(), Date.now() + 86400000
).run(),
])
);
return new Response(buffer, {
headers: {
'Content-Type': 'image/webp',
'Cache-Control': 'public, max-age=300, stale-while-revalidate=86400',
'CF-Cache-Status': 'MISS',
},
});
},
};
```
**Cache invalidation.** When a salon updates a design, the EmDash CMS webhook triggers a Worker that purges the specific KV keys:
```javascript
async function handleDesignUpdate(designId, salonId, env) {
const variants = await env.DB.prepare(
`SELECT cache_key FROM ar_preview_cache
WHERE salon_id = ? AND design_id = ?`
).bind(salonId, designId).all();
const keys = variants.results.map(r => r.cache_key);
await Promise.all(keys.map(key => env.AR_PREVIEW_CACHE.delete(key)));
await env.DB.prepare(
`UPDATE ar_preview_cache SET expires_at = 0
WHERE salon_id = ? AND design_id = ?`
).bind(salonId, designId).run();
await prewarmDesign(salonId, designId, env);
}
```
Results
| Metric | Before | After | Improvement |
|---|---|---|---|
| Median AR preview load time (4G) | 3.2s | 0.4s | **87% faster** |
| Mobile bounce rate | 68% | 28% | **-40pp** |
| Average image size delivered | 1.8MB | 142KB | **92% smaller** |
| KV cache hit rate | — | 97.4% | **New** |
| Design gallery interactivity | 12.4s | 1.8s | **85% faster** |
| AR-to-booking conversion | 1.8% | 5.8% | **3.2x** |
| CDN offload ratio | 0% | 94% | **94% offload** |
The most significant business impact: users who engaged with the AR preview feature booked at 3.2x the rate of non-AR users. Every 100ms of load time reduction correlated with a 0.6% increase in booking conversion, measured over a 90-day A/B test with 12,000 sessions.
**Cost analysis.** Cloudflare Image Resizing costs $0.50 per 1,000 transformations. Before optimization, each session triggered 12-20 raw image requests. After, ~97% hit KV (no transformation cost). For 50,000 monthly sessions, the total image pipeline cost is approximately $22.50/month — negligible compared to the booking revenue uplift.
Key Takeaways
**1. Edge caching is not optional for AR experiences.** Raw origin fetches destroy mobile interactivity. KV-backed caching with stale-while-revalidate eliminated the performance cliff while maintaining freshness for frequently updated content.
**2. Device-aware image optimization compounds.** Detecting mobile vs desktop and adjusting resolution, format, and quality for each device class reduced delivered image size by 92%. WebP alone accounted for 40-50% of that reduction.
**3. Lazy loading transforms perceived performance.** The design manifest pattern — load metadata instantly, images on scroll — dropped time-to-interactivity from 12.4s to 1.8s. Users perceive the page as "instant" because the first design thumbnail appears in under 500ms.
**4. Performance is a conversion lever, not just UX polish.** The 3.2x booking conversion lift from AR engagement shows that when the AR preview works fast, it changes user behavior. Slow AR previews weren't just annoying — they were costing the platform real bookings.
**5. Cache pre-warming pays for itself.** The cron-based pre-warm Worker reduced cold-start misses for high-traffic salons by 80%, costing only a few cents per day in compute.
**6. Measure everything in business terms.** Technical metrics (load time, cache hit rate) are useful, but the north star was booking conversion. Every optimization was prioritized based on its estimated impact on conversion, not just Lighthouse scores.
The AR preview performance pipeline turned a liability — heavy imagery that killed mobile experience — into a competitive advantage. Fast AR delivery is the mechanism that drives AiSalonHub's core conversion loop: discover a salon → try on designs → book an appointment. When that loop runs in under a second, bookings follow.