Insights

Engineering Notes

Technical notes, updates, and essays from the team.

A Deep Dive into Modern Web Performance

1 February 2025 — Francis Agbey

Why Performance Is a Product Problem

Performance isn't a metric to chase after launch — it's a product decision made at every layer of the stack. A 100 ms delay in server response correlates directly with drop-off, especially on mobile networks across sub-Saharan Africa where Agbey Tech serves a significant user base.

"The best performance optimization is the one you don't have to undo." — Internal design principle


The Stack We Optimise

Here's what a typical high-traffic project at Agbey Tech looks like:

  • Next.js 15 with App Router and React Server Components
  • PostgreSQL via Prisma, co-located with compute on Fly.io
  • Redis for query-level caching
  • Cloudflare as the global edge layer
  • Vercel for preview deployments

Server Components vs Client Components

The golden rule: render on the server unless you need interactivity.

// ✅ Server Component — no JS shipped to the client
export default async function BlogList() {
  const posts = await db.post.findMany({ orderBy: { date: "desc" } });
  return (
    <ul>
      {posts.map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}

// ⚠️ Only go client when you need state / events
("use client");
export function LikeButton({ postId }: { postId: string }) {
  const [liked, setLiked] = useState(false);
  return (
    <button onClick={() => setLiked((l) => !l)}>{liked ? "♥" : "♡"}</button>
  );
}

Caching Strategy

We apply caching in three layers:

  1. In-memory — React's built-in cache() for request deduplication
  2. Redis — 60-second TTL on expensive aggregation queries
  3. CDN edgeCache-Control: s-maxage=300, stale-while-revalidate=600 on static routes
Layer Scope TTL Invalidation
cache() Per request Auto N/A
Redis Cross-request 60–300 s On mutation
CDN edge Global 5 min Purge API

Route Segment Config

Next.js gives us fine-grained control at the file level:

// app/dashboard/page.tsx
export const revalidate = 60; // ISR — rebuild at most every 60 s
export const dynamic = "force-dynamic"; // opt out when data is always fresh

Image Optimisation

The <Image> component from next/image does a lot of heavy lifting:

  • Converts PNGs and JPEGs to WebP / AVIF automatically
  • Generates srcset for responsive loading
  • Lazy-loads below-the-fold images by default
  • Prevents Cumulative Layout Shift with explicit width / height
import Image from "next/image";

<Image
  src="/hero.jpg"
  alt="Hero image"
  width={1280}
  height={720}
  priority
  sizes="(max-width: 768px) 100vw, 50vw"
/>;

Note: Always set priority on your LCP image, and never on below-the-fold images.


Measuring What Matters

Core Web Vitals are the production target:

  • LCP (Largest Contentful Paint) — target < 2.5 s
  • INP (Interaction to Next Paint) — target < 200 ms
  • CLS (Cumulative Layout Shift) — target < 0.1

We instrument every deploy with Vercel Speed Insights and compare against a rolling P75 baseline.


Takeaways

  • Reach for Server Components first
  • Cache aggressively, invalidate precisely
  • Optimise images at the framework level, not post-hoc
  • Measure real-user data, not just Lighthouse synthetic audits

Have questions? Reach out