How I cut a Next.js page's TBT from 510ms to 300ms in one PR
HacktoberFest 2025. PrivGPT-Studio's docs page was sluggish on mobile. Profiling found three problems I would never have guessed from reading the code.
HacktoberFest 2025. PrivGPT-Studio was on my list. A React/Next.js app for local-first LLM workflows. The maintainer had an open issue: "Docs page is slow on mobile." Sounded vague. Total Blocking Time on the page was 510ms. Lighthouse score: 62 on mobile.
I fixed it in one PR. The page now lands at TBT 300ms. About 41% better. Here's what actually moved the needle.
Step 1: profile, don't guess
I almost did the wrong thing here. The first instinct is to look at the code and start guessing. "Probably a heavy dep. Let me look for moment.js."
Instead: Chrome DevTools Performance tab. Open the page, start recording, scroll, stop. The flame graph showed exactly what was burning the main thread:
- A markdown-to-react parse running on the client for the entire docs corpus on every route change
- A syntax-highlighting component re-rendering inside a useEffect that ran on every scroll event
- A useFonts hook that was awaiting font loading before painting
Three problems. Two of them I would never have guessed by reading the code.
Step 2: server-component the markdown
The biggest win was moving the markdown rendering off the client. The docs were Markdown files in /content/, parsed at runtime in a "use client" component. Heavy. Pointless.
// before: client component parses every render
"use client";
import { Markdown } from "@/components/Markdown";
export default function DocsPage({ slug }) {
const raw = useRawMarkdown(slug);
return <Markdown source={raw} />; // ← parses on every nav
}
// after: server component pre-renders the HTML
export default async function DocsPage({ slug }) {
const html = await renderMarkdown(slug); // ← runs at build time
return <article dangerouslySetInnerHTML={{ __html: html }} />;
}Saved ~180ms of TBT just on initial paint.
Step 3: don't highlight on every scroll
The syntax highlighter was hooked up like this:
useEffect(() => {
hljs.highlightAll();
}, [scrollY]); // ← whoever wrote this, I don't blame you, butIt was meant to handle late-mounted code blocks. The fix was to highlight on mount and observe added nodes via MutationObserver, instead of running on every scroll:
useEffect(() => {
document.querySelectorAll("pre code:not(.hljs-done)").forEach((el) => {
hljs.highlightElement(el as HTMLElement);
el.classList.add("hljs-done");
});
}, []);About 80ms of TBT removed.
Step 4: fonts shouldn't block
useFonts was returning early until fonts loaded. That meant the first frame was blank, which counts against TBT because the browser had nothing to paint. Swap to CSS font-display: swap and the page renders with system fonts while the web font streams in.
What I'd do differently next time
I'd start with profiling on day one. I spent maybe an hour reading code before opening DevTools. Most of that hour was wasted. The issues weren't readable as "this function is slow." They were readable as "this happens on every event."
The 510 → 300 ms result was satisfying. The bigger win was realizing performance work is a measurement problem first, a code problem second.