The diagnosis
Google PageSpeed Insights gave us a 55 on mobile. Accessibility, best practices, and SEO were all perfect 100s — but performance was dragging. The numbers:
| Metric | Value | Verdict |
|---|---|---|
| First Contentful Paint | 3.3 s | Poor |
| Largest Contentful Paint | 8.3 s | Poor |
| Total Blocking Time | 20 ms | Good |
| Cumulative Layout Shift | 0.01 | Good |
| Speed Index | 3.5 s | Moderate |
The site was fast once loaded — TBT and CLS were excellent — but it took far too long to show the first meaningful content. On a 3G connection in Lahore or Karachi, users were staring at a blank screen for over eight seconds.
We needed to understand why.
The seven root causes
1. The hero was invisible until JavaScript loaded
Our homepage hero — a large italic tagline in Cormorant Garamond — was the Largest Contentful Paint element. But it was wrapped in framer-motion's motion.div with initial={{ opacity: 0 }}:
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1.2, ease: [0.22, 1, 0.36, 1], delay: 0.2 }}
>
<p className="font-serif text-[clamp(32px,5.5vw,56px)] ...">
{tagline}
</p>
</motion.div>
This meant: the server sends the HTML, the browser receives it, but the text is opacity: 0. It stays invisible until the React runtime hydrates, framer-motion's ~50KB JavaScript bundle parses and executes, and the animation triggers. On a fast connection, this adds a second or two. On a slow mobile connection, it can add five or more.
The text was there in the HTML. It was just hidden by JavaScript that hadn't loaded yet.
2. Five Google Fonts, fifteen weight files
The root layout loaded five font families:
const cormorant = Cormorant_Garamond({
weight: ["300", "400", "500", "600", "700"],
style: ["normal", "italic"], // 10 files
});
const dmSans = DM_Sans({
weight: ["300", "400", "500", "600", "700"],
style: ["normal", "italic"], // 10 files
});
const notoNastaliq = Noto_Nastaliq_Urdu({ weight: ["400", "500", "600", "700"] });
const notoNaskh = Noto_Naskh_Arabic({ weight: ["400", "500", "600", "700"] });
const gulzar = Gulzar({ weight: ["400"] });
That's potentially 25+ font files. Even with display: "swap" and Next.js's automatic font optimisation, the browser still needs to discover and begin downloading these before FCP can happen. The Noto Arabic fonts are especially large — complex script shaping tables make them 3–5x heavier than Latin equivalents.
A grep across the entire codebase revealed: Cormorant was only ever used at weights 400 and 500. DM Sans never used 300 or italic. The Arabic fonts never used 600 or 700.
3. Client-side Islamic geometry computation
The Shah Jahan Mosque pattern — a twelve-pointed star rosette tessellation that appears as a background on the homepage hero, section dividers, and several other pages — was computed entirely in the browser:
"use client";
function buildTile(S: number): string {
// Generate {12/5} star polygon chords from 5×5 neighbourhood
for (let tx = -2; tx <= 2; tx++) {
for (let ty = -2; ty <= 2; ty++) {
for (let i = 0; i < 12; i++) {
// ... polar coordinates, line clipping, pairwise intersections
}
}
}
// Find all intersection points, split into segments, filter star voids
// ... hundreds of floating-point operations
}
The algorithm is beautiful — it follows the classical Islamic geometric construction method, generating chords of a {12/5} star polygon and computing their intersections to produce the interlocking pattern. But it's pure mathematics. The output for a given tile size is always the same 9KB SVG path string. Computing it on every page load on a mobile phone in Peshawar is unnecessary work.
The same was true of the Ibn Tulun pattern (a {6/2} hexagram tessellation from Cairo's 879 CE mosque), which appeared on the Reconstruction section.
4. Unoptimised images
Four portrait images on the key figures pages were served as full-resolution JPG and PNG:
| Image | Original | Format |
|---|---|---|
| iqbal-1935 | 577 KB | PNG |
| whitehead | 487 KB | JPG |
| bergson | 221 KB | JPG |
| kant | 151 KB | JPG |
These are greyscale portraits. There is no reason for them to be this large.
5. framer-motion on every page
The FadeIn component — used to animate sections into view as the user scrolls — imported all of framer-motion:
import { motion, useInView } from "framer-motion";
This component wrapped nearly every section on every page. That meant framer-motion's ~50KB (gzipped) was in the critical bundle for the entire site, even though all it was doing was a simple opacity + translateY animation triggered by an intersection observer.
6. A 234KB Arabic font blocking the critical rendering path
After the first round of fixes brought us from 55 to 73, PageSpeed revealed a deeper problem in the LCP breakdown:
Network dependency chain — 3,151 ms:
HTML
└─ chunks/0g-s22uunzvxr.css (15.8 KiB, 2,378 ms)
└─ media/7431e9586bb7ba3a.woff2 (234 KB, 3,151 ms)
The browser couldn't start downloading the font until the CSS finished loading, and the font it was waiting for was Noto Nastaliq Urdu at 234KB — an Arabic script font with complex glyph shaping tables. This single file was the final link in a 3.1-second waterfall chain.
Next.js's next/font was preloading all five font families by default, including the three Arabic fonts that aren't needed for initial render on English pages. The browser was dutifully downloading 234KB of Urdu ligature tables before it could paint the hero text.
7. The wrong LCP element
PageSpeed identified the LCP element as a small 0.78rem italic serif span inside a "Featured" badge:
<span className="font-serif text-[0.78rem] italic text-cream/85">
Iqbal's Reconstruction, annotated
</span>
This tiny span was the last thing to paint because it depended on Cormorant Garamond italic — a font variant that had to wait for the CSS to parse before the browser even knew to request it. The actual hero tagline (the large text below) painted even later because of its 0.5-second animation delay.
The LCP wasn't slow because the content was heavy. It was slow because we'd accidentally created a dependency chain: HTML → CSS → font discovery → font download → text render → animation delay → visible.
The fixes
Round 1: from 55 to 73
Fix 1: CSS animations for the hero
We replaced framer-motion in the hero with three CSS keyframe animations:
@keyframes hero-fade-up {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
.hero-fade-up {
animation: hero-fade-up 1s cubic-bezier(0.22, 1, 0.36, 1) both;
}
<div className="hero-fade-up" style={{ animationDelay: "0.5s" }}>
<p className="font-serif ...">{tagline}</p>
</div>
The critical difference: CSS animations work without JavaScript. The browser applies them as soon as it parses the stylesheet — no hydration needed. The text is visible in the server-rendered HTML (no opacity: 0 initial state in the markup), and the animation runs the moment the CSS is parsed.
We used the same cubic-bezier curve as before (0.22, 1, 0.36, 1) so the animation feels identical.
Fix 2: trim the fonts to what we actually use
A codebase-wide audit of every font-serif, font-sans, font-urdu, and font-naskh class showed the exact weights in use. We cut to only what's needed:
// Before: 10 files After: 4 files
weight: ["300","400","500","600","700"] → weight: ["400", "500"]
style: ["normal", "italic"] → style: ["normal", "italic"]
// Before: 10 files After: 4 files
weight: ["300","400","500","600","700"] → weight: ["400", "500", "600", "700"]
style: ["normal", "italic"] → (no italic)
// Arabic fonts: 4 weights each → 2 each
weight: ["400","500","600","700"] → weight: ["400", "500"]
From ~25 font files down to ~12. Each Arabic font file saved is especially impactful — they're 2–5x larger than Latin equivalents due to complex glyph shaping tables.
Fix 3: pre-compute the geometric patterns
The insight: buildTile(tileSize) is a pure function. Same input, same output. We ran the computation once at build time, captured the 9KB SVG path string, and embedded it as a static constant:
// Before: "use client" + useMemo + 160 lines of geometry
const tilePath = useMemo(() => buildTile(tileSize), [tileSize]);
// After: static string, scaled via SVG patternTransform
const TILE_PATH = "M81.7,100.0L79.9,98.2 M79.9,98.2L77.8,96.1 ...";
<pattern
width={100} height={100}
patternTransform={`scale(${tileSize / 100})`}
>
<path d={TILE_PATH} />
</pattern>
The key trick: we compute the tile at a unit size of 100x100 and use SVG's patternTransform="scale(N)" to render it at any size. One pre-computed path serves all instances — the hero at 220px tiles, the section borders at 100px, the sidebar accents at 120px.
The Ibn Tulun pattern got the same treatment. Its tile was only 1.5KB (simpler geometry — hexagrams vs dodecagrams).
Both components went from "use client" with heavy computation to lightweight components with zero runtime math.
Fix 4: WebP conversion
iqbal-1935: 577 KB → 95 KB (−83%)
whitehead: 487 KB → 35 KB (−93%)
bergson: 221 KB → 130 KB (−41%)
kant: 151 KB → 71 KB (−53%)
Total: 1,436 KB → 331 KB. We also enabled AVIF/WebP format negotiation in the Next.js config for any images served through next/image.
Fix 5: native IntersectionObserver for FadeIn
We replaced framer-motion's useInView with a direct IntersectionObserver:
// Before
import { motion, useInView } from "framer-motion";
const isInView = useInView(ref, { once: true, margin: "0px 0px -40px 0px" });
const Component = motion.create(as);
// After
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ rootMargin: "0px 0px -40px 0px" },
);
Same visual result. The animation triggers when the element scrolls into view, using the same CSS keyframe as the hero. But the bundle cost goes from ~50KB to zero — IntersectionObserver is a browser API, not a library.
framer-motion still ships for the 28 interactive philosophy widgets (sliders, spectrum pickers, drag interactions) where CSS alone can't replicate the behaviour. But those are code-split to their individual routes. The homepage and all standard content pages no longer load it.
Round 2: from 73 to 90
The first round of fixes exposed the real bottleneck. PageSpeed's LCP breakdown showed 3,180ms of "element render delay" — almost all of it spent waiting for a font that wasn't needed for initial paint.
Fix 6: defer Arabic font preloading
We set preload: false on the three Arabic fonts:
const notoNastaliq = Noto_Nastaliq_Urdu({
weight: ["400", "500"],
display: "swap",
preload: false, // 234KB — don't block initial render
});
const notoNaskh = Noto_Naskh_Arabic({
weight: ["400", "500"],
display: "swap",
preload: false, // 187KB
});
const gulzar = Gulzar({
weight: ["400"],
display: "swap",
preload: false, // 92KB
});
This removed over 500KB of font files from the critical rendering path. The browser still downloads them when it encounters Arabic text — display: "swap" ensures the system font shows immediately and swaps to the custom font once loaded — but it no longer blocks painting the English homepage.
The critical network chain collapsed from:
HTML → CSS (2,378ms) → Nastaliq font (3,151ms)
to:
HTML → CSS → Cormorant Latin (36KB, preloaded in parallel)
Fix 7: fix the LCP element
The "Iqbal's Reconstruction, annotated" span was identified by PageSpeed as the LCP element because it was the last visible text to render — it used font-serif italic, forcing the browser to wait for Cormorant Garamond italic.
We changed it to font-sans:
// Before: waits for Cormorant italic to download
<span className="font-serif text-[0.78rem] italic text-cream/85">
// After: renders with DM Sans, already preloaded
<span className="font-sans text-[0.78rem] text-cream/85">
We also tightened the hero animation delays — the logo block from 0.2s to 0.15s, the tagline from 0.5s to 0.35s — so content paints sooner without losing the staggered feel.
The results
Mobile: 55 → 90
| Metric | Before | After | Change |
|---|---|---|---|
| Performance score | 55 | 90 | +35 |
| First Contentful Paint | 3.3 s | 1.7 s | −48% |
| Largest Contentful Paint | 8.3 s | 3.2 s | −61% |
| Total Blocking Time | 20 ms | 10 ms | −50% |
| Cumulative Layout Shift | 0.01 | 0 | perfect |
| Speed Index | 3.5 s | 4.5 s | +29% |

Desktop: 95 → 96
| Metric | Value |
|---|---|
| Performance score | 96 |
| First Contentful Paint | 0.3 s |
| Largest Contentful Paint | 0.8 s |
| Total Blocking Time | 0 ms |
| Cumulative Layout Shift | 0 |
| Speed Index | 1.9 s |

Desktop was already fast — more bandwidth and CPU headroom meant the original issues were less visible. But the same fixes pushed it from the mid-90s to 96.
What we didn't change
- The visual design. Every animation looks the same. The Shah Jahan pattern renders identically. The fonts are the same — we just stopped loading weights nobody was using.
- The architecture. Still Next.js with Convex, still using next-intl for i18n, still the same component structure.
- The interactive features. framer-motion still powers the philosophy widgets where CSS isn't sufficient. We just stopped loading it on pages that don't need it.
The principle
Performance work is not about choosing faster tools. It's about not doing unnecessary work:
- Don't hide content behind JavaScript that could be visible in HTML.
- Don't download font weights you never use.
- Don't compute geometry at runtime when the input is constant.
- Don't ship 50KB of animation library for an opacity transition.
- Don't serve 577KB PNGs of greyscale photographs.
- Don't preload 234KB of Arabic ligature tables to render an English page.
- Don't make the LCP element depend on a font that hasn't been downloaded yet.
Each of these was individually rational when written. Cormorant might be used at weight 700 someday. The pattern could need dynamic sizing. framer-motion does provide a nice API. But when seven individually reasonable decisions compound on a mobile connection, you get an 8.3-second LCP.
The audit took two rounds and a few hours. The fixes were mechanical, not creative. The hardest part was reading the PageSpeed waterfall carefully enough to see that the bottleneck wasn't the obvious thing (JavaScript bundle size) but the non-obvious thing (a 234KB Urdu font preloaded on an English page, sitting at the end of a three-link dependency chain).
Built with patience. Optimised with attention.