Engineering notes

Five bugs I found in my own portfolio

Every one of these was live on this site. None of them threw an error. Below is how each was measured, what actually caused it, and what changed — with the numbers on both sides.

Each case ends with a demo you can run yourself. They reproduce the bug in a box rather than describing it, and two of them measure your machine rather than quoting mine.

  1. 01The film grain that never stopped
  2. 02A blend layer on top of everything
  3. 03The observer storm
  4. 04The viewport tag Next quietly dropped
  5. 05Two fonts downloaded, neither one used
01app/globals.css — .noise-layer

The film grain that never stopped

A decorative texture was the single heaviest thing on the page — and it re-downloaded every five minutes.

756 KBfor a texture nobody could name

Symptom

The site felt heavy on some machines. There is no 3D here and no heavy animation, so nothing in the code obviously accounted for it — which is exactly why it survived so long.

How I measured it

A HEAD request against the background-image URL. It answered content-length: 755823 and cache-control: max-age=300 — three quarters of a megabyte, re-fetched every five minutes, served from a third-party host.

Root cause

A viewport-sized fixed element at z-index 9000 painted an animated GIF. Because the GIF animates, the compositor repaints a full-screen layer continuously for the entire life of the page — not once, forever. The reduced-motion rule set animation: none on it, which does nothing at all to a GIF: that is an image decoding on its own timer, not a CSS animation.

Fix

Replaced with an inline SVG fractal-noise tile as a data URI — rasterised once by the browser, then cached. The grain reads the same standing still.

Before
.noise-layer {
  position: fixed;
  inset: 0;
  z-index: 9000;
  background-image: url(https://raw.githubusercontent.com/…/noise.gif);
  opacity: .03;
}
After
.noise-layer {
  position: fixed;
  inset: 0;
  z-index: 9000;
  background-image: url("data:image/svg+xml,…feTurbulence…");
  opacity: .035;
  contain: strict;
}
Transfer
was756 KB
now0.27 KB
Third-party requests
was1 per 5 min
now0
Full-screen repaints
wascontinuous
nowone

Try it — the same grain, weighed

281 bytes

Old — remote animated GIF738 KB
New — inline SVG data URI0.27 KB
On screen for
0:00
Old cost so far (1×)
738 KB
New cost so far
0.27 KB

2,690× smaller, and it never asks the network again.

The swatch is the real texture this page ships, rendered from the same 281-byte data URI — measured in your browser just now, not quoted from my notes. The clock starts when the demo scrolls into view and reproduces the old asset’s max-age=300: one full re-download every five minutes, from a third-party host, for as long as the tab stayed open.

02app/globals.css — .blob

A blend layer on top of everything

One CSS property turned an ambient glow into a tax on every repaint on the page.

z-index 5000blending above all content, 24/7

Symptom

Jank on desktops and laptops, but not phones. That split is what made it read as a vague 'some devices are slow' problem rather than a bug.

How I measured it

Reading the stacking order rather than a profiler. The element was 160vw × 160vh, fixed, above every section, with a blend mode and an infinite rotation.

Root cause

mix-blend-mode forces the compositor to blend that layer against everything painted beneath it. Sitting above the content at z-index 5000, any repaint anywhere underneath had to be re-blended through a full-screen layer — permanently, on its own 22-second timer. It was hidden below 1000px, so phones never paid the cost. That is why only 'some devices' were affected.

Fix

Moved it behind the content at z-index -1, dropped the blend mode, and promoted it with will-change so the rotation is a pure GPU transform that never repaints. Then split it into two counter-rotating orbs.

Before
.blob {
  position: fixed;
  z-index: 5000;             /* above all content */
  mix-blend-mode: hard-light;
  animation: blobOrbit 22s linear infinite;
}
After
.blob {
  position: fixed;
  z-index: -1;               /* behind all content */
  border-radius: 50%;
  will-change: transform;    /* own compositor layer */
  contain: strict;
}
Blended layers above content
was1
now0
Layer size
was160vw × 160vh
now70vmax²
Rotation cost
wasrepaint
nowGPU transform

Try it — blending above vs. behind the content

Content sitting underneath

Body copy, a link, and a button — the ordinary things a page is made of. Watch what the orb does to their contrast as it passes over.

A buttonand a link
Stacking
above content
Blend mode
hard-light
Repaints re-blended
all of them

What you can see is the appearance: above the content, the blend eats contrast out of text it happens to pass over. What you cannot see is the cost — every repaint underneath a blended full-screen layer has to be re-blended through it, which is why the original only janked on the machines wide enough to render it at all.

03app/components/CustomCursor.tsx

The observer storm

A custom cursor re-scanned the entire document dozens of times a second, because the hero types.

~40×/secfull-document queries, for the life of the page

Symptom

Typing in the hero felt sticky, and the whole page felt slightly behind the pointer.

How I measured it

Counting callback invocations. The observer fired on every DOM mutation anywhere on the page, and each call ran a fresh document-wide query.

Root cause

A MutationObserver watched document.body with subtree: true. Its callback ran querySelectorAll across the whole document and re-bound mouseenter/mouseleave on every match. Nothing about that is expensive once — but the hero typewriter mutates the DOM every ~65ms and the terminal every ~26ms, so it ran perhaps forty times a second, forever. The cost scaled with the size of the page.

Fix

Two delegated listeners on document using closest(). No observer, no re-scanning, no rebinding. The animation loop now also parks itself when the pointer stops instead of writing transforms every frame forever.

Before
const addHoverListeners = () => {
  document.querySelectorAll(selector).forEach(el => {
    el.addEventListener('mouseenter', onEnter);
    el.addEventListener('mouseleave', onLeave);
  });
};
new MutationObserver(addHoverListeners)
  .observe(document.body, { childList: true, subtree: true });
After
const handleOver = (e) => {
  if (e.target?.closest?.(selector)) setHovering(true);
};
document.addEventListener('mouseover', handleOver, { passive: true });
document.addEventListener('mouseout', handleOut, { passive: true });
Document scans
was~40/sec
now0
Listeners bound
washundreds/sec
now2 (once)
Idle rAF callbacks
was60/sec
now0

Try it — the observer storm

heronavcardchiplinkbutton
querySelectorAll calls
0
addEventListener calls
0
Status
idle

Scoped to this box, not the document — the real observer watched document.body with subtree: true, so every count here is a floor. While it runs, hovering the chips highlights them identically in both modes — that is the point, the two are indistinguishable from the outside — and in observer mode it makes the counts climb faster, because a hover changes a class and a class change is another mutation.

04app/layout.tsx — metadata export

The viewport tag Next quietly dropped

No error, no warning at runtime, nothing visibly broken — the tag was simply absent.

~980 pxthe width phones were laying out at

Symptom

Mobile looked subtly wrong. Everything slightly too small, proportions a little off, but nothing you could point at.

How I measured it

Rebuilding the previous version of the file and reading the build output. Next said it plainly: ⚠ Unsupported metadata viewport is configured in metadata export. Then confirmed the rendered HTML contained no viewport meta tag at all.

Root cause

viewport was set as a key inside the metadata export. Next 16 no longer honours that and drops it, emitting nothing. Without the tag, mobile browsers fall back to a ~980px virtual viewport and scale the result down — so the phone renders far more layout than the screen needs, then shrinks it.

Fix

Moved it to the dedicated viewport export that Next 16 expects.

Before
export const metadata: Metadata = {
  title: "…",
  viewport: "width=device-width, initial-scale=1.0", // silently dropped
};
After
export const metadata: Metadata = { title: "…" };

export const viewport: Viewport = {
  width: "device-width",
  initialScale: 1,
};
Viewport meta tag
wasabsent
nowpresent
Mobile layout width
was~980 px
nowdevice width
Build warnings
was2
now0

Try it — a phone with and without the tag

elkarmi.dev
WorkAboutContact
Front-end developer, Marrakech

I build fast, accessible interfaces — and occasionally write up the bugs I find in my own. This paragraph is set at 15px in both panes.

Next.jsTypeScriptTailwind
View the work →

 

Layout width
980 px
Applied scale
1.00×
15px body text renders at
15.0 px

Same markup, same font sizes, in both panes. Without the tag the phone lays out at 980px and shrinks the result to fit — so body text set at 15px arrives at roughly 15.0px on the glass. Nothing is broken, nothing errors; everything is just quietly too small.

05app/layout.tsx + app/globals.css

Two fonts downloaded, neither one used

The site asked for a typeface it never loaded, while loading two it never rendered.

2 familiesdownloaded and discarded on every visit

Symptom

Type looked different from machine to machine — a little wider here, a little heavier there. The same class of 'looks off on some devices' complaint as the viewport bug.

How I measured it

Grepping for the font variables, then reading the compiled Tailwind CSS and the computed font-family on body. The variables were defined and never referenced.

Root cause

The layout loaded Geist Sans and Geist Mono and exposed them only as CSS variables that nothing consumed. Meanwhile the stylesheet set font-family: 'Fira Code', which was never loaded, and Tailwind's font-mono utility resolved to its own default stack. So every visitor downloaded two families and then read the site in whatever monospace their OS happened to ship — Menlo, Consolas, DejaVu Sans Mono.

Fix

Dropped Geist entirely, loaded Fira Code properly through next/font, and pointed both body and Tailwind's --font-mono at it. One family, actually rendered, identical everywhere. This page is set in it.

Before
const geistSans = Geist({ variable: "--font-geist-sans" });
const geistMono = Geist_Mono({ variable: "--font-geist-mono" });
// …neither variable is referenced anywhere

body { font-family: 'Fira Code', monospace; }  /* never loaded */
After
const firaCode = Fira_Code({
  variable: "--font-fira",
  weight: ["400", "500", "700"],
});

@theme inline { --font-mono: var(--font-fira), monospace; }
body { font-family: var(--font-fira), monospace; }
Families downloaded
was2
now1
Families rendered
was0
now1
Cross-platform type
wasOS-dependent
nowidentical

Try it — what your machine used to render

Before — generic monospace, whatever your OS ships
const measured = "the same line, twice";
After — Fira Code, loaded and actually rendered
const measured = "the same line, twice";
Fallback line width
measuring…
Fira Code line width
measuring…
Difference

Both lines are the same 40 characters at the same size. The top one is set in the generic monospace family — the one the old rule silently resolved to on your machine. The bottom one is the Fira Code this page loads, which is the same on every machine. If the delta reads 0.0 px, your system default happens to be metric-compatible; that is luck, not design, and it was different for the next visitor.

And what went wrong

Three things I got wrong along the way

Every fix above is written as though it arrived fully formed. None of them did.

I fixed the glow and broke its shape

Adding contain: strict to isolate the ambient orb also clipped its paint to a rectangular box. The gradient was a circle wider than its own element, so it came out with a hard straight edge. The fix needed a square box, a closest-side gradient and a border-radius — the containment was right, the geometry was not.

Two failed attempts at restoring focus

Closing the project overlay should return focus to the button that opened it. The first attempt captured that button on open — but it unmounts while the dialog is open, so the reference was detached and .focus() silently did nothing. The second moved the restore to exit-complete, because the dialog stays mounted through its 0.35s exit animation and the background is still inert until it unmounts. Focusing into an inert subtree also does nothing, silently. Two different silent no-ops in the same twenty lines.

I trusted a server that had already died

pkill -f 'next start' does not kill this server — the listener runs in a differently named child process. So a rebuild appeared to succeed while a stale process kept serving the old build. For several minutes I was reading old CSS and concluding the build system was broken. The lesson is not about ports: it is that a verification step you have not verified is not a verification step.