↑↓ mover↵ abrir⌘K abrir/cerrar

    ← Todas las notas

    Pruebas A/B sin parpadeo en Cloudflare Workers

    Cómo la prueba en vivo del home de este sitio asigna variantes en el servidor, cuenta exposiciones solo al verse y guarda una fila por visitante en D1.

    5 de noviembre de 2026 · 5 min de lectura

    • Pruebas A/B
    • Cloudflare Workers
    • D1
    • Rendimiento

    El home de este sitio corre una prueba A/B ahora mismo: la mitad de los visitantes ve un hero que enumera habilidades, la otra mitad uno que arranca con resultados, y la métrica es si escriben. Los resultados son públicos en experimentos. Aquí cuento cómo está construida y qué partes llevaría a una tienda.

    Por qué parpadean las pruebas del lado del cliente

    Casi todas las herramientas de pruebas A/B corren en el navegador: la página renderiza el control y luego un script reescribe el DOM con la variante. Si el script tarda, el visitante ve A y luego B. El arreglo habitual, un snippet anti-flicker que oculta la página hasta que la herramienta carga, cambia el parpadeo por una pantalla en blanco y peor LCP para todos.

    El parpadeo también puede sesgar el resultado: la variante es la versión que se mueve, y la gente reacciona al movimiento. Renderizar la variante en el HTML elimina el problema en vez de taparlo.

    Asignar la variante en el servidor

    El home no se prerenderiza. El Worker asigna al visitante y renderiza el texto elegido:

    export function assign(request: Request, url: URL, cookies: AstroCookies): Assignment {
      const secure = url.protocol === 'https:';
      const forced = url.searchParams.get('variant');
      if (isVariant(forced)) return { variant: forced, track: false, forced: true };
      if (isBot(request)) return { variant: VARIANT_KEYS[0], track: false, forced: false };
    
      visitorId(cookies, secure);
      const name = cookieVariant(heroTest.id);
      const saved = cookies.get(name)?.value;
      if (isVariant(saved)) return { variant: saved, track: true, forced: false };
    
      const variant = VARIANT_KEYS[Math.floor(Math.random() * VARIANT_KEYS.length)];
      cookies.set(name, variant, { path: '/', maxAge: YEAR, httpOnly: true, sameSite: 'lax', secure });
      return { variant, track: true, forced: false };
    }

    Todo va en dos cookies: un ID aleatorio de visitante y la variante, ambas HttpOnly, SameSite=Lax y por un año. Ningún script puede leer ni cambiar la variante, así que el endpoint no depende del navegador: lee las cookies.

    La página también envía cache-control: private, no-store; si no, un CDN puede cachear una variante y servírsela a todos.

    Bots y vistas previas con ?variant=b vuelven con track: false: ven una variante (así hago QA de B), pero nada las cuenta; el endpoint además revisa el user agent. El home en español comparte la asignación pero nunca se cuenta, porque la prueba es sobre el texto en inglés: si lees esto en español, tu visita no suma.

    Contar la exposición cuando se ve

    Asignar no es exponer: alguien puede recibir la cookie e irse antes de ver el hero, y contarlo diluye el efecto. La exposición se envía desde el navegador cuando el 60% del hero está en pantalla:

    const hero = document.querySelector('.hero__lede');
    if (hero && 'IntersectionObserver' in window) {
      const io = new IntersectionObserver(([en]) => {
        if (en.isIntersecting) { beacon('exposure'); io.disconnect(); }
      }, { threshold: 0.6 });
      io.observe(hero);
    } else beacon('exposure');

    beacon() usa navigator.sendBeacon, con fetch y keepalive como respaldo, y el evento sobrevive al cierre de la pestaña. El body es solo { kind }; visitante y variante salen de las cookies, en el servidor.

    Las conversiones son intención de contacto desde cualquier página: copiar el email, abrir Contacto o enviar el formulario. Un flag en sessionStorage evita beacons repetidos, pero la garantía es la tabla.

    Una fila por visitante

    D1 es SQLite, y la primary key vuelve un no-op la segunda exposición o conversión del mismo visitante:

    CREATE TABLE IF NOT EXISTS xp_events (
      experiment TEXT NOT NULL,
      visitor    TEXT NOT NULL,
      kind       TEXT NOT NULL CHECK (kind IN ('exposure', 'conversion')),
      variant    TEXT NOT NULL,
      created_at INTEGER NOT NULL,
      PRIMARY KEY (experiment, visitor, kind)
    );
    
    -- A conversion only counts for visitors who were exposed, attributed to the variant they saw
    INSERT OR IGNORE INTO xp_events (experiment, visitor, kind, variant, created_at)
    SELECT ?1, ?2, 'conversion', variant, ?3 FROM xp_events
    WHERE experiment = ?1 AND visitor = ?2 AND kind = 'exposure';

    El insert de conversión copia la variante de la fila de exposición, no de la cookie actual; sin exposición, el SELECT vuelve vacío y no se escribe nada. Quien llega directo a contacto no vio ningún hero y no cuenta. Reintentos y beacons duplicados mueren en INSERT OR IGNORE. Los resultados son un GROUP BY variant, kind, y una prueba nueva es un id nuevo, con conteos desde cero.

    Leer los resultados

    GET /api/xp devuelve los conteos y tres números, con la misma CDF normal de src/lib/stats.ts que usan las calculadoras:

    • el p-value de un z-test de dos proporciones, bilateral y con varianza combinada,
    • la probabilidad de que la tasa real de B supere a la de A, por aproximación normal,
    • un estado que sigue en collecting hasta tener 100 exposiciones por variante.

    Ese piso de 100 solo evita que el panel celebre ruido; no es un tamaño de muestra. Con una conversión de 2%, detectar un lift relativo de 20% con 80% de poder requiere unos 21.000 visitantes por variante, y un lift de 50%, unos 3.800. Un portafolio no llega ahí: la lectura es direccional. Y un panel que se refresca cada 15 segundos es una ventana, no una regla de parada: parar la primera vez que p baja de 0,05 infla los falsos positivos.

    Antes del lift, reviso el reparto:

    export function srm(visitorsA: number, visitorsB: number, shareA = 0.5) {
      const total = visitorsA + visitorsB;
      const eA = total * shareA, eB = total * (1 - shareA);
      const chi = (visitorsA - eA) ** 2 / eA + (visitorsB - eB) ** 2 / eB;
      const p = 2 * (1 - phi(Math.sqrt(chi)));
      return { chi, p, mismatch: p < 0.01, share: visitorsA / total };
    }

    Un sample ratio mismatch (SRM) indica que la asignación o el conteo están rotos, y el lift deja de valer. 10.000 contra 10.400 parece inofensivo y da p ≈ 0,005. Causas usuales: caché, redirecciones que pierden cookies y bots que se cuelan. Contar al ver agrega una propia: si el hero de B es más alto o queda más abajo, el umbral del 60% se alcanza a otro ritmo. Mantén las variantes comparables en layout u observa un elemento común.

    Conclusiones

    • Asigna en el servidor con cookies HttpOnly, renderiza la variante en el HTML y marca la página private, no-store.
    • Cuenta exposiciones cuando el elemento se ve y deja que la primary key garantice una fila por visitante; atribuye conversiones con INSERT OR IGNORE … SELECT.
    • Revisa el SRM antes de leer el lift y dimensiona la prueba con un cálculo de poder, no con un mínimo de visitas.

    Más notas

    sections/contact.liquid

    Construyamosel siguiente.

    ¿Buscas a alguien senior en Shopify, necesitas rehacer una tienda, construir una app o automatizar un proceso? Escríbeme.