← All writing
Performance

Why Your Website Is Slow (It Is Probably Not the Framework)

Before you rewrite in the framework of the week, open the network tab. Nine times out of ten the culprit is images, blocking scripts, or a chatty API — in that order.

Clients rarely ask for performance work directly. They say the site "feels heavy," and they wonder aloud whether it should be rebuilt in something newer. Almost every time, the framework is innocent and the network tab holds a confession.

This post expands the three usual suspects — images, blocking scripts, and chatty APIs — into full fixes with code, plus the measurement habit that keeps you from optimizing the wrong thing.

Suspect #1: images

The single most common finding on audits: a 4 MB hero photograph, exported straight from a phone, scaled down with CSS. The browser still downloads all 4 MB, decodes a 4000-pixel-wide image, and then throws most of it away to paint 800 pixels. The fix is boring and spectacular — resize to the displayed size, serve WebP or AVIF, lazy-load everything below the fold.

The plain HTML fix

<!-- Before: one giant JPEG for every device -->
<img src="/hero.jpg" alt="Storefront" />

<!-- After: modern formats, sized per viewport, decoded off the main thread -->
<picture>
  <source
    type="image/avif"
    srcset="/hero-480.avif 480w, /hero-800.avif 800w, /hero-1600.avif 1600w"
    sizes="(max-width: 600px) 100vw, 800px"
  />
  <source
    type="image/webp"
    srcset="/hero-480.webp 480w, /hero-800.webp 800w, /hero-1600.webp 1600w"
    sizes="(max-width: 600px) 100vw, 800px"
  />
  <img
    src="/hero-800.jpg"
    alt="Storefront"
    width="800"
    height="450"
    decoding="async"
    fetchpriority="high"
  />
</picture>

Three details doing quiet work there: explicit width and height let the browser reserve space before the image arrives (killing layout shift, which is a Core Web Vital), fetchpriority="high" tells the browser this one image is worth jumping the queue for, and the sizes attribute stops a phone from downloading the desktop rendition.

For everything below the fold, invert the priority:

<img src="/product-3.webp" alt="…" width="400" height="400" loading="lazy" decoding="async" />

One rule: never put loading="lazy" on the hero or anything above the fold — you'd be telling the browser to deprioritize the most important paint on the page.

The Next.js fix

If you're on Next.js, next/image does the resizing, format negotiation, and lazy-loading for you:

import Image from 'next/image';

<Image
  src="/hero.jpg"
  alt="Storefront"
  width={800}
  height={450}
  priority        // above the fold: preload it, don't lazy-load it
  sizes="(max-width: 600px) 100vw, 800px"
/>

The batch fix for existing assets

When a client hands you a folder of phone exports, don't resize by hand. A ten-line script with sharp converts the lot:

// scripts/optimize-images.mjs
import sharp from 'sharp';
import { readdir } from 'fs/promises';

const files = await readdir('./raw-images');

for (const file of files.filter((f) => /\.(jpe?g|png)$/i.test(f))) {
  const name = file.replace(/\.\w+$/, '');
  for (const width of [480, 800, 1600]) {
    await sharp(`./raw-images/${file}`)
      .resize({ width, withoutEnlargement: true })
      .webp({ quality: 78 })
      .toFile(`./public/${name}-${width}.webp`);
  }
}

I have cut page weight by 80% on a client store with exactly this — without touching a line of application JavaScript.

Suspect #2: render-blocking scripts

Every synchronous <script> in the head is a toll gate between your user and the first paint. The browser stops building the page, downloads the script, executes it, and only then resumes. Analytics, chat widgets, and font loaders are the usual offenders — none of them need to run before the user sees anything.

Know your three loading modes

<!-- Blocking: HTML parsing STOPS here until this downloads and runs -->
<script src="/analytics.js"></script>

<!-- defer: downloads in parallel, runs after the document is parsed,
     in order. The right default for almost everything. -->
<script src="/analytics.js" defer></script>

<!-- async: downloads in parallel, runs the moment it arrives —
     fine for scripts with no dependencies, like most analytics snippets -->
<script src="/analytics.js" async></script>

The chat widget nobody clicks

A support chat bundle routinely weighs 300–500 KB — more than the rest of the page — and the median visitor never opens it. Don't load it until there's a signal of intent:

// Load the chat widget on first interaction, or after 5 idle seconds
let loaded = false;

function loadChatWidget() {
  if (loaded) return;
  loaded = true;
  const s = document.createElement('script');
  s.src = 'https://widget.example-chat.com/loader.js';
  s.defer = true;
  document.body.appendChild(s);
}

['scroll', 'pointerdown', 'keydown'].forEach((evt) =>
  window.addEventListener(evt, loadChatWidget, { once: true, passive: true })
);

setTimeout(loadChatWidget, 5000); // fallback for the patient visitor

The page now paints as if the widget doesn't exist, because for the first few seconds it doesn't.

Fonts: stop hiding the text

A custom font loaded naively gives you invisible text until the file arrives. One CSS line fixes it:

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-display: swap; /* show fallback text immediately, swap when ready */
}

In Next.js, next/font does this plus self-hosting plus preloading in one import — no external request to Google Fonts at all.

Suspect #3: the waterfall

A page that fetches the user, then their orders, then each order's items in sequence has turned one screen into six round trips. On a 100 ms connection, six sequential requests is 600 ms of pure waiting before rendering can even start — and on mobile it's worse.

The crime, as usually written

// ❌ Each await waits for the previous one. N orders = N+2 round trips.
const user = await fetch(`/api/users/${id}`).then((r) => r.json());
const orders = await fetch(`/api/users/${id}/orders`).then((r) => r.json());

for (const order of orders) {
  order.items = await fetch(`/api/orders/${order.id}/items`).then((r) => r.json());
}

Fix one: parallelize what's independent

// ✅ Independent requests fire together
const [user, orders] = await Promise.all([
  fetch(`/api/users/${id}`).then((r) => r.json()),
  fetch(`/api/users/${id}/orders`).then((r) => r.json()),
]);

await Promise.all(
  orders.map(async (order) => {
    order.items = await fetch(`/api/orders/${order.id}/items`).then((r) => r.json());
  })
);

Better — but the browser is still making N+2 requests. The real fix is to stop shaping endpoints around tables and start shaping them around screens.

Fix two: one endpoint, one response, shaped for the page

// server: /api/account-page
app.get('/api/account-page', async (req, res) => {
  const userId = req.session.userId;

  const [user, orders] = await Promise.all([
    User.findById(userId).select('name email').lean(),
    Order.find({ user: userId })
      .sort({ createdAt: -1 })
      .limit(10)
      .lean(), // items are embedded in the order — one query returns everything
  ]);

  res.json({ user, orders });
});
// client: the entire page is now ONE round trip
const { user, orders } = await fetch('/api/account-page').then((r) => r.json());

This is where server-side rendering quietly earns its keep — the server sits a millisecond from the database and can afford several queries; the phone on hotel Wi-Fi cannot afford several round trips. The same logic dropped into getServerSideProps or a Server Component means the browser receives finished HTML and makes zero data requests for first paint.

Measure first. The profiler is allowed to surprise you; your intuition is not evidence.

Measuring: make the profiler confess

Two tools cover 95% of audits. First, Lighthouse (in Chrome DevTools, or headless):

npx lighthouse https://example.com --preset=perf --view

Read the "Opportunities" section top-down — it's literally sorted by estimated savings, and the top three are usually images, scripts, and caching. Second, field data from real users, because your dev machine on fiber lies to you:

// Report Core Web Vitals from real visitors
import { onLCP, onCLS, onINP } from 'web-vitals';

function report(metric) {
  navigator.sendBeacon('/api/vitals', JSON.stringify(metric));
}

onLCP(report); // Largest Contentful Paint — usually your hero image
onCLS(report); // Cumulative Layout Shift — usually missing width/height
onINP(report); // Interaction to Next Paint — usually too much JavaScript

Each vital maps back to a suspect: a bad LCP points at images, a bad CLS points at images without dimensions, a bad INP points at scripts.

The order of operations

  • Run Lighthouse and note the top three opportunities — they are usually images, scripts, and caching. Don't touch code until you've looked.
  • Fix images the same afternoon. It is the highest ratio of impact to effort in web development: a resize script and some markup changes, no architecture meetings required.
  • Defer or delay every third-party script, and make the chat widget earn its bytes.
  • Collapse waterfalls into page-shaped endpoints — or let the server render the first paint entirely.
  • Only after the cheap wins, consider architecture: caching layers, ISR, a CDN. These are real, but they multiply the gains of the fixes above — they don't replace them. A CDN serving a 4 MB image is just a faster way to send too much.

Fast websites are rarely clever. They are mostly websites that stopped doing unnecessary work.

#performance#images#core-web-vitals
AA
Written byAmanat Ali

A full-stack developer who builds websites end-to-end — Next.js, Node.js, and MongoDB — and writes buildlog in the margins.