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.
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.
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.
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.
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 — 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.