[CODE]

Attribution Before Aesthetics: Wiring Measurement and LCP for a B&B's Direct Booking Channel

A B&B running Google Ads with zero conversion events is buying anonymous clicks. Wiring attribution first, then finding a three-layer LCP problem in my own architecture — and how Cloudflare failed at one fix and delivered an 11× TTFB gain instead.

7 min read AI-generated
astro web-performance analytics cloudflare core-web-vitals

The argument for breaking away from OTAs is tight, and it only holds under one condition: you can demonstrate, per booking, that the direct channel is working. OTAs win on three compounding dimensions simultaneously — ad budgets that dwarf anything a single property can sustain, brand trust built over years of transaction volume, and professional marketing teams operating full-time. A direct channel doesn’t need to win on all three. But it must generate a feedback loop that tells the owner which campaigns earn the spend and which don’t. Without that, the site is not a direct booking channel. It’s a cost center.

When I came into this project, Google Ads had been running for some time. The dashboard showed clicks and cost-per-click. Conversion count was zero — not because nobody had contacted the owner, but because the tracking events were never wired.

The Attribution Layer

This site has no checkout flow. A visitor either calls, messages on LINE, or leaves. That constraint changes the measurement model entirely. Contact clicks are not proxies for conversions — they are the conversions. Every tel: link, every LINE deep-link, every email anchor needed to fire both a GA4 event and a Google Ads conversion beacon. The first meaningful commits on this project had no visual component.

Three channels, each firing two destinations: GA4 for analytics — phone, LINE, email, booking-page click, scroll-depth milestones at 25/50/75% — and Google Ads for conversion attribution — phone, LINE, email only. Booking-page clicks go to GA4 but not to Ads. Navigating to the contact page is a funnel step; making contact is the conversion. Misclassifying funnel steps as conversions inflates the conversion rate and corrupts the bid optimization signals Google’s algorithm relies on. The distinction is load-bearing.

One event nearly got lost. On mobile, tapping a tel: link hands OS control to the dialer before the network stack has finished its work. The gtag() calls are synchronous enqueue operations — they push to dataLayer — but the actual beacon fires asynchronously. On iOS Safari in certain conditions, the page context can be interrupted between tap and dialer activation, before the beacon goes out. A phone_click event that evaporates silently means the dashboard reads zero on a campaign that was working. That is the highest-intent signal in the entire funnel — an actual call attempt, not a scroll or a click to a disambiguation page — and it disappears without a trace. The fix was deliberate: explicitly fire the Ads conversion beacon for contact events, and treat any untracked CTA as a bug in the system, not a gap to close later.

The scroll-depth implementation surfaced a second edge case. This site sets overflow-x: hidden on the body, which makes body the scroll container — window.scrollY stays 0. Reading scroll position from window.scrollY alone produces no milestones ever. The handler reads from window.scrollY, window.pageYOffset, document.documentElement.scrollTop, and document.body.scrollTop, takes the first non-zero value, and listens on both window and document so it catches the event regardless of which target it fires on.

This work was out of reach for a small property five years ago. Analytics build, Ads setup, and ongoing attribution tuning each required dedicated headcount. AI tooling didn’t replace a team here. The team was never affordable to begin with.

The Performance Audit

With attribution in place, the performance audit. The site is a pure-static Astro build served from Railway, with Cloudflare as a CDN proxy layer in front. Mobile Lighthouse was at 76. Edge caching makes static HTML arrive faster — it doesn’t fix the page you’re serving.

I went looking for third-party bloat and render-blocking external resources. Every problem was in my own architecture.

The hero AVIF was invisible to the preload scanner. The hero image used a <picture> element with AVIF and WebP <source> blocks. The browser’s preload scanner — the speculative fetch engine that runs before the DOM is built — cannot resolve <source> srcset candidates. It sees <picture>, but source selection doesn’t run until CSS is parsed and the element renders. On Lighthouse Mobile Slow 4G simulation, the hero AVIF wasn’t discovered until 4.6 seconds into the load. The fix: a <link rel="preload" as="image" type="image/avif" imagesrcset="...480w, ...960w, ...1600w, ...2400w" fetchpriority="high"> emitted from BaseLayout.astro. The preload scanner finds this tag immediately during HTML parsing and fetches the right size in parallel with everything else. When the <picture> element later resolves source selection, the AVIF is already cached.

The stylesheet hit the render-blocking threshold at 12 KB. Astro’s build.inlineStylesheets: 'auto' inlines stylesheets under roughly 4 KB and emits larger ones as external requests. This site’s shared CSS bundle was 12 KB — just over the threshold — so the browser had to fetch and parse it before painting anything. Lighthouse measured 1,756 ms of LCP “element render delay” from this alone, even with the hero AVIF cached and ready. Setting inlineStylesheets: 'always' in astro.config.mjs moves every stylesheet into a <style> tag in the <head>. No external round-trip, no render-blocking gap. The cost: each HTML file grows by roughly 12 KB of inline CSS. For a 48-page static site, that’s around 576 KB of additional dist size — acceptable after gzip compression, and worth far more than 1.7 seconds of LCP recovery.

Google Fonts was costing 250 ms for text that is 95% CJK. Cormorant Garamond and Nunito loaded via four <link> tags. Even with the non-blocking preload + font-display: swap pattern, Lighthouse Mobile measured roughly 250 ms of render delay from the font CSS request. The Latin display face rendered on a handful of headlines. That wire cost wasn’t earning its keep. Both fonts were replaced with system stacks: Iowan Old Style → Apple Garamond → Cambria → Georgia → CJK fallback for display, -apple-system → BlinkMacSystemFont → Segoe UI → Roboto → CJK fallback for body. The visual trade-off is real — Cormorant’s italic is gone — and it’s the right trade-off.

The hero AVIF was overweight for its job. The 960-wide variant — the size mobile gets at typical display density — was 137 KB at quality 60. Recompressed to quality 38, it dropped to 57 KB, a 58% reduction. On Slow 4G simulation that’s roughly 400 ms of transfer-time savings on the LCP image specifically.

Cloudflare: Arrived for One Purpose, Delivered a Different One

Cloudflare entered the stack as a diagnostic step — the hypothesis was that Meta’s Facebook scraper block was keyed on the origin IP and SSL chain, not the domain string itself. Migrating nameservers to Cloudflare would present a fresh IP and cert chain to Meta’s pipeline, and might lift the block. That hypothesis was wrong. A fresh path that Meta had never queried returned 403 through Cloudflare within minutes, confirming the flag is keyed on the domain string. The actual fix came from a different direction: DNS TXT record verification through Meta Business Manager, which bypasses the flagged HTTP surface entirely and queries DNS directly.

Cloudflare stayed in the stack because of what it did to performance after cache rules were configured. Hero AVIF TTFB dropped from 2,028 ms cold to 180 ms — an 11× improvement — once the edge cached the image with a 30-day TTL. The Railway origin’s default cache-control: max-age=14400 had been forcing Cloudflare to revalidate every 4 hours, producing near-cold-start latency on every cycle. Extending the edge TTL to 30 days for static images and 1 year for hash-versioned Astro bundles turned Cloudflare into a genuine performance layer rather than a pass-through.

Mobile 94, Desktop 98

The combined result: Mobile Performance 94, Desktop 98. Every point came from real reductions in resource discovery latency, render-blocking delay, and transfer weight. The competing properties in the same destination were at 64 mobile at the time of measurement.

The owner opens the analytics dashboard and knows whether the campaign is working. That’s the deliverable — not the score.