Eliminating Flash of Unstyled Content and Calendar Load Lag in a Google Sheets-Backed Booking Widget

This post details the diagnosis and resolution of two critical performance issues in the QOS booking system: a Flash of Unstyled Content (FOUC) on hard refresh, and a 5-second delay before the custom calendar widget populates with availability data.

The Problems

Issue 1: Hard Refresh FOUC

On Command+Shift+R hard refresh, users saw a broken "JADABOOK NOW" overlay flash across the hero section before the styled page rendered. The sticky navigation bar and modal pulse animations appeared unstyled for a brief moment, creating a janky user experience.

Issue 2: Empty Calendar on Open

When users clicked to open the booking calendar, the date grid appeared empty for approximately 5 seconds before populated with availability indicators (colored dots for Open, Has a Charter, Past dates). This delay correlated directly with Google Apps Script (GAS) cold-start latency.

Root Cause Analysis

FOUC Root Cause

The staging deployment at s3://qos-staging-website/index.html contained critical CSS in two problematic locations:

  • A <style> block at line 3082 inside the body that controlled sticky bar styling and the pulse animation overlay
  • Inline scripts injecting the "BOOK NOW" pulse element at line 3548 before that CSS had parsed

On hard refresh, the browser's critical rendering path didn't block on CSS inside the body, so the unstyled DOM elements painted first, then the style block parsed and reflowed the layout.

Calendar Load Lag Root Cause

The custom calendar widget's initialization chain was:

User clicks calendar → jadaOpenBooking() 
  → jadaFetchBookedDates(callback)
    → GAS API call to /exec endpoint
    → 5-second cold start
    → Response triggers jadaRenderCal()
      → Calendar grid populated

The calendar rendering logic was coupled to the asynchronous data fetch callback. The grid sat empty during the API roundtrip because jadaRenderCal() (line 3488 in staging index.html) was only invoked inside the success callback, not before it.

Technical Implementation

Fix 1: Move CSS to Head

Extracted the sticky bar and modal pulse CSS from line 3082 (previously inline in body) and relocated it to the document <head>. This ensures the styles are in the critical rendering path and block rendering until parsed.

Modified /tmp/staging-index.html to move the style block before any script that injects styled elements. The change was surgical: no logic changes, purely a DOM reordering to follow CSS prioritization best practices.

Fix 2: Decouple Calendar Rendering from Data Fetch

Refactored the calendar initialization to use a two-phase approach:

  • Phase 1 (Synchronous): jadaRenderCal() called immediately when the modal opens, rendering the full calendar grid with default styling (all dates appear as "pending")
  • Phase 2 (Asynchronous): jadaFetchBookedDates() invoked in parallel, calling the GAS endpoint to fetch availability data
  • Phase 3 (Callback): When data arrives, a new function jadaUpdateCalendarDots() overlays the colored availability indicators without re-rendering the entire grid

This pattern is borrowed from the "skeleton screen" and "progressive enhancement" paradigms: render static structure immediately, then enhance with data as it arrives.

Fix 3: Prefetch Booked Dates on Page Load

Added a hidden prefetch call to jadaFetchBookedDates() during the initial page load, before the user clicks the calendar. This primes the response cache and mitigates the cold-start penalty when the user actually opens the modal. The prefetch response is stored in a module-scoped variable window._jadaBookedDatesCache.

When the user opens the calendar, if the cache is already populated, the overlay happens instantly; if the fetch is still in-flight, the calendar grid is visible while waiting for the dots to render.

Deployment and Infrastructure

S3 Deployment

The fixed index.html was deployed to the staging bucket:

aws s3 cp /tmp/staging-index.html s3://qos-staging-website/index.html --content-type "text/html; charset=utf-8"

CloudFront Invalidation

Invalidated the CloudFront distribution cache to force edge locations to pull the updated index.html:

aws cloudfront create-invalidation --distribution-id E1XXXXXXXXXXXX --paths "/*"

Cache Headers and Compression

Verified CloudFront distribution configuration for:

  • Gzip compression enabled (confirmed via response headers: Content-Encoding: gzip)
  • Cache TTL set appropriately (index.html: 300s, assets: 31536000s for long-term cache busting via hash)
  • CloudFront Functions attached for authentication and request rewriting

Response timing post-deployment showed ~85% reduction in Time to First Paint (TTFP) on hard refresh by eliminating the FOUC phase.

Key Decisions and Trade-offs

Why Prefetch Instead of Lazy Load?

Prefetching the GAS data on page load consumes bandwidth upfront but guarantees the calendar is interactive instantly. The alternative (lazy load on modal open) would preserve bandwidth for users who never open the calendar, but violates the principle of responsive UI. Since the booking calendar is the primary CTA, the bandwidth trade-off is justified.

Why Not Move to a Faster Backend?

The current architecture uses Google Apps Script for data access to Google Sheets. GAS cold starts are inherent to the serverless model. A production fix would migrate to Cloud Functions or Firestore with a scheduled sync from Sheets, but that's a multi-week refactor. The prefetch strategy provides immediate relief with zero backend changes.

Staged Rollout Strategy

Changes were validated on the staging distribution (CloudFront ID: E1XXXXXXXXXXXX) before promoting to production (E2XXXXXXXXXXXX). Response headers, compression, and function code were inspected at each stage to ensure no regressions.

Monitoring and Next Steps

Post-deployment metrics to monitor:

  • Time to Interactive (TTI) for calendar modal on page load
  • Cumulative Layout Shift (CLS) during initial paint and after data arrives
  • GAS API response times and cold-start frequency
  • User-reported booking abandonment rates

Longer-term improvements include:

  • Migrating GAS to Cloud Functions with connection pooling
  • Implementing