# Landing Page Performance Audit -- www.codeflash.ai

**Date:** 2026-04-10
**Site:** https://www.codeflash.ai (Webflow-hosted)
**Dashboard:** https://app.codeflash.ai (Next.js, changes committed in this branch)

---

## Summary of Findings

The landing page has several performance issues causing slow initial load and poor Core Web Vitals:

| Issue                                                                         | Impact                                                        | Severity |
| ----------------------------------------------------------------------------- | ------------------------------------------------------------- | -------- |
| 15 videos with `autoplay` + no `preload` attr (defaults to `preload="auto"`)  | ~16.3 MB total video data downloaded eagerly                  | Critical |
| 4 render-blocking scripts (jQuery 89KB, Swiper 151KB, GSAP 73KB, Webflow 5KB) | Blocks first paint by ~300ms+                                 | High     |
| OTF fonts instead of WOFF2 (2 fonts @ 118KB + 113KB)                          | 2.3x larger than WOFF2 equivalent                             | High     |
| 91 images (14 eager, 59 lazy, 18 with no loading attr)                        | Above-fold logo images marked `loading="lazy"`                | Medium   |
| 143KB HTML document                                                           | Inline CSS (11.5KB) + inline JS (11.4KB) + 1,428 DOM elements | Medium   |
| 135KB main CSS file loaded render-blocking                                    | Single large stylesheet blocks paint                          | Medium   |
| No `fetchpriority="high"` on LCP image/video                                  | Browser cannot prioritize LCP resource                        | Medium   |
| No `decoding="async"` on any images                                           | All 73 images decoded synchronously                           | Low      |
| Missing preconnect for third-party origins                                    | Crisp, PostHog, Swiper CDN, GitHub                            | Low      |

---

## Critical: Video Loading Strategy

**Current state:** All 15 `<video>` tags have `autoplay` and no `preload` attribute. When `preload` is omitted, browsers default to `preload="auto"` which downloads the entire video file. With 15 videos totaling 16.3 MB, the browser attempts to download all of them on page load.

**Recommended fix (in Webflow custom code -- before `</body>`):**

```html
<script>
  // Defer video loading: replace autoplay with intersection-observer-based playback.
  // Videos below the fold will not load until scrolled into view.
  document.addEventListener("DOMContentLoaded", function () {
    var videos = document.querySelectorAll("video[autoplay]")

    // Keep only the first video (hero) autoplaying
    var heroVideo = document.getElementById("e9d39971-d9e1-d67b-fe47-f58be9f34e3e-video")

    videos.forEach(function (video) {
      if (video === heroVideo) return // Skip hero video

      // Pause and set preload to none for off-screen videos
      video.pause()
      video.preload = "none"

      // Remove autoplay to prevent browser from re-triggering
      video.removeAttribute("autoplay")

      // Use IntersectionObserver to play when visible
      var observer = new IntersectionObserver(
        function (entries) {
          entries.forEach(function (entry) {
            if (entry.isIntersecting) {
              entry.target.preload = "auto"
              entry.target.play().catch(function () {})
            } else {
              entry.target.pause()
            }
          })
        },
        { threshold: 0.25 },
      )

      observer.observe(video)
    })
  })
</script>
```

**Estimated savings:** ~14 MB of initial bandwidth (keeping only the hero video loading eagerly).

**Hero video specific fix:** Add `preload="metadata"` to the hero video in Webflow designer. This downloads only the first few KB needed for dimensions/poster, then starts streaming on play.

---

## High: Render-Blocking Scripts

**Current render-blocking chain:**

1. `jquery-3.5.1.min.js` -- 89 KB (CloudFront)
2. `swiper-bundle.min.js` -- 151 KB (jsDelivr)
3. `webflow.*.js` -- 5 KB (Webflow CDN)
4. `gsap.min.js` -- 73 KB (Webflow CDN)

Total: ~318 KB of JavaScript blocking first paint.

**Fix in Webflow custom code (head section):**

Replace the Swiper CSS embed block. Currently:

```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.css" />
```

Change to async loading:

```html
<link
  rel="preload"
  href="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.css"
  as="style"
  onload="this.onload=null;this.rel='stylesheet'"
/>
<noscript
  ><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.css"
/></noscript>
```

For the Swiper JS, add `defer` in the embed:

```html
<script defer src="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.js"></script>
```

**Note:** jQuery and Webflow JS are injected by Webflow itself and cannot be deferred through custom code. The Swiper and GSAP scripts are loaded via custom embed blocks and CAN be deferred.

---

## High: Font Format Optimization

**Current state:** Two PP Neue Montreal fonts are served as OTF (118KB + 113KB = 231KB). The Monaspace Neon font is already WOFF2 (132KB).

**Fix:** Convert the OTF fonts to WOFF2 format and re-upload to Webflow:

1. Download `ppneuemontreal-medium.otf` and `ppneuemontreal-book.otf`
2. Convert using `woff2_compress` or an online tool like CloudConvert
3. Expected size reduction: ~60% (231KB -> ~90KB)
4. Upload WOFF2 versions to Webflow Assets
5. Update the font-face declarations to reference WOFF2 files

In Webflow custom code (head), add `font-display: swap` to prevent FOIT:

```html
<style>
  @font-face {
    font-family: "Ppneuemontreal";
    font-display: swap;
  }
</style>
```

---

## Medium: Image Loading Optimization

**Issue 1:** The logo image in the navbar is marked `loading="lazy"`. Since the navbar is always visible above the fold, this delays LCP.

**Fix in Webflow:** Select the logo `<img>` element and set loading to "Eager" (or remove the lazy attribute).

**Issue 2:** No images use `fetchpriority="high"`. The LCP element (hero image or video) should have this.

**Fix in Webflow custom code (head):**

```html
<script>
  // Set fetchpriority=high on hero elements for faster LCP
  document.addEventListener("DOMContentLoaded", function () {
    var heroImg = document.querySelector(".hero_visual img")
    if (heroImg) heroImg.fetchPriority = "high"
  })
</script>
```

**Issue 3:** No images use `decoding="async"`.

**Fix in Webflow custom code (before `</body>`):**

```html
<script>
  // Set async decoding on all lazy-loaded images
  document.querySelectorAll('img[loading="lazy"]').forEach(function (img) {
    img.decoding = "async"
  })
</script>
```

---

## Medium: Add Missing Resource Hints

**Current preconnect origins:**

- `cdn.prod.website-files.com` (exists)

**Missing preconnects (add to Webflow head custom code):**

```html
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link rel="preconnect" href="https://client.crisp.chat" crossorigin />
<link rel="dns-prefetch" href="https://d3e54v103j8qbb.cloudfront.net" />
<link rel="dns-prefetch" href="https://buttons.github.io" />
```

---

## Low: Consolidated Custom Script

The current page has 11 inline scripts totaling 11.4KB, many with duplicate `DOMContentLoaded` listeners. Here is a consolidated version that combines all custom logic into a single script block:

**Replace ALL custom before-body scripts with this single block:**

```html
<script>
  ;(function () {
    "use strict"

    // --- Nav banner dismiss (runs immediately, no DOMContentLoaded needed) ---
    if (sessionStorage.getItem("hide-nav-banner") === "true") {
      document.documentElement.classList.add("hide-nav-banner")
    }

    document.addEventListener("DOMContentLoaded", function () {
      // --- Nav banner close buttons ---
      document.querySelectorAll(".nav_banner_close_wrap").forEach(function (btn) {
        btn.addEventListener("click", function () {
          sessionStorage.setItem("hide-nav-banner", "true")
          document.documentElement.classList.add("hide-nav-banner")
        })
      })
      document.querySelectorAll(".nav_skip_wrap").forEach(function (btn) {
        btn.addEventListener("click", function () {
          sessionStorage.setItem("hide-nav-banner", "true")
          document.documentElement.classList.add("hide-nav-banner")
        })
      })

      // --- Dynamic year ---
      document.querySelectorAll("[data-dynamic-year]").forEach(function (el) {
        el.textContent = new Date().getFullYear()
      })

      // --- Desktop-only hover interactions ---
      if (window.innerWidth >= 992) {
        var interactions = [
          { container: ".fast-code_grid", card: ".fast-code_card.is-active" },
          { container: ".ways_component", card: ".ways_card_wrap.is-active" },
          { container: ".hiw_grid", card: ".hiw_card_wrap.is-active" },
        ]
        interactions.forEach(function (cfg) {
          var container = document.querySelector(cfg.container)
          var card = container && container.querySelector(cfg.card)
          if (container && card) {
            container.addEventListener("mouseenter", function () {
              card.classList.remove("is-active")
            })
            container.addEventListener("mouseleave", function () {
              card.classList.add("is-active")
            })
          }
        })
      }

      // --- Async image decoding ---
      document.querySelectorAll('img[loading="lazy"]').forEach(function (img) {
        img.decoding = "async"
      })

      // --- Deferred video loading (non-hero videos) ---
      var heroVideoId = "e9d39971-d9e1-d67b-fe47-f58be9f34e3e-video"
      document.querySelectorAll("video[autoplay]").forEach(function (video) {
        if (video.id === heroVideoId) return
        video.pause()
        video.preload = "none"
        video.removeAttribute("autoplay")
        var obs = new IntersectionObserver(
          function (entries) {
            entries.forEach(function (entry) {
              if (entry.isIntersecting) {
                entry.target.preload = "auto"
                entry.target.play().catch(function () {})
              } else {
                entry.target.pause()
              }
            })
          },
          { threshold: 0.25 },
        )
        obs.observe(video)
      })
    })
  })()
</script>
```

This replaces scripts 6, 9, 10, 11, 12, 13, and the video defer logic -- reducing 7 separate `DOMContentLoaded` listeners to 1 and removing ~2KB of duplicated boilerplate.

---

## Dashboard App Fixes (committed in this branch)

These changes are in `js/cf-webapp/`:

### 1. Suspense boundary for PostHogPageView (Critical)

**File:** `src/app/layout.tsx`

`PostHogPageView` uses `useSearchParams()` without a `<Suspense>` boundary. In Next.js App Router, this forces the entire route to opt out of static rendering and become fully dynamic on every request. Wrapping it in `<Suspense>` allows the rest of the page to render statically while the search-params-dependent component streams in.

### 2. Third-party scripts moved to `lazyOnload` (High)

**File:** `src/app/layout.tsx`

Intercom and Crisp chat scripts were using `strategy="afterInteractive"` and placed inside `<head>`. Changed to:

- `strategy="lazyOnload"` -- loads after the page is fully interactive
- Moved from `<head>` to `<body>` -- proper placement per Next.js docs

This defers ~200KB+ of third-party chat widget JavaScript until after the user can interact with the page.

### 3. Font optimization (Medium)

**File:** `src/app/layout.tsx`

- Added `display: "swap"` to Inter font to prevent invisible text during load
- Reduced JetBrains Mono from 5 weights (300,400,500,600,700) to 2 (400,600)
  - Saves ~3 font file network requests
  - Weights 300, 500, 700 were not used anywhere in the codebase

### 4. JetBrains Mono properly mapped to font-mono (Medium)

**File:** `tailwind.config.ts`

The JetBrains Mono font was loaded but never mapped to Tailwind's `font-mono` utility. All `font-mono` usage was falling back to the browser default monospace font. Added the mapping so the loaded font is actually used.

---

## Estimated Total Impact

| Fix                          | Metric Improved         | Estimated Gain                      |
| ---------------------------- | ----------------------- | ----------------------------------- |
| Video lazy-loading           | LCP, bandwidth          | ~14 MB less initial transfer        |
| Render-blocking script defer | FCP, LCP                | ~200-400ms faster first paint       |
| OTF to WOFF2 fonts           | FCP                     | ~140KB less transfer                |
| Suspense for PostHogPageView | TTFB, static generation | Pages can be statically cached      |
| Chat scripts to lazyOnload   | TBT, TTI                | ~200KB+ deferred from critical path |
| Font weight reduction        | Transfer size           | ~3 fewer font requests              |
| Resource hints               | Connection setup        | ~50-100ms for third-party resources |
| Image loading fixes          | LCP                     | Logo renders without lazy delay     |
| Consolidated scripts         | Parse time              | 7 scripts -> 1, ~2KB less code      |
