What you'll learn
By the end of this you'll understand how a seemingly simple browser tool can wreck your Core Web Vitals and what the fix actually looks like in a Next.js App Router project. You'll see the initial architecture, the Lighthouse 61, the diagnosis, and the step-by-step path to 97.
Who this is for
- Frontend developers building interactive browser tools on content sites
- Anyone who has shipped a
"use client"component and watched their LCP slide - Performance engineers who want a real example of Web Worker adoption in a Next.js project
You can skip this if your tool page is server-rendered static content. The performance traps here are specific to large client-side bundles and main-thread-heavy parsing work.
What is the JSON formatter tool? Plain English
It's a text box. You paste JSON in, press Format, and it comes back indented and syntax-highlighted. That's the entire feature. It sounds like a one-afternoon job. The afternoon part was right — the performance regression took four more days.
Prerequisites
- Comfortable with Next.js App Router and the
"use client"directive - Basic understanding of how browsers parse and paint
- Familiarity with Chrome DevTools Performance panel (you don't need to master it)
Setup from zero
Step 1 — The first (bad) version
The initial implementation was straightforward:
"use client";
import { useState } from "react";
import { highlight } from "prism-react-renderer";
export default function JsonFormatterPage() {
const [input, setInput] = useState("");
const [output, setOutput] = useState("");
function handleFormat() {
const parsed = JSON.parse(input);
setOutput(JSON.stringify(parsed, null, 2));
}
return (
<main>
<textarea value={input} onChange={(e) => setInput(e.target.value)} />
<button onClick={handleFormat}>Format</button>
<pre>{output}</pre>
</main>
);
}
Lighthouse mobile: 61. TBT (Total Blocking Time): 840 ms. The page was technically interactive but the main thread was locked during initial JS parse.
Step 2 — Diagnose before fixing
The Performance panel showed three problems:
prism-react-rendererloaded eagerly — 112 KB parsed on the main thread before the user had typed a character- The entire tool page was
"use client", which pulled its full dependency tree into the initial bundle - The
textareahad norowsorcols, causing a layout shift when content appeared
None of these are obvious from reading the code. You need the Performance panel to see them.
Step 3 — Split server and client
The page shell (heading, description, SEO) is static. Only the interactive widget needs the browser. We split them:
// app/tools/text/json-formatter/page.tsx — Server Component
import { JsonFormatterWidget } from "@/components/tools/JsonFormatterWidget";
export default function JsonFormatterPage() {
return (
<main>
<h1>JSON Formatter</h1>
<p>Paste your JSON below…</p>
<JsonFormatterWidget />
</main>
);
}
// components/tools/JsonFormatterWidget.tsx
"use client";
// Only this file and its imports are client bundle
This alone cut the initial bundle by 34 KB because the static prose no longer dragged in React client runtime for SSR hydration of dynamic content.
Step 4 — Lazy-load the syntax highlighter
const highlight = dynamic(() => import("@/lib/highlightJson"), { ssr: false });
The highlighter is only needed after the user clicks Format. Deferring it removes 112 KB from the initial parse. We show a plain <pre> on first render and swap to the highlighted version once the module loads.
Step 5 — Move JSON.parse to a Web Worker
Large JSON blobs (>500 KB) blocked the main thread for up to 300 ms. We offloaded parsing:
// lib/jsonWorker.ts
self.onmessage = (e: MessageEvent<string>) => {
try {
const parsed = JSON.parse(e.data);
self.postMessage({ ok: true, result: JSON.stringify(parsed, null, 2) });
} catch (err) {
self.postMessage({ ok: false, error: (err as Error).message });
}
};
The component sends the raw string to the worker and waits for the formatted result. Main thread stays free.
The mental model
The mental model for tool page performance is: the page shell is content, the widget is an enhancement.
Your LCP is the heading and the description — pure HTML, server-rendered, zero JS. The interactive part loads after. Users see a usable page immediately; the tool becomes interactive 200–400 ms later. That's acceptable. A blank screen while 400 KB of JavaScript parses is not.
Key terms
TBT (Total Blocking Time) — the total time the main thread was blocked by long tasks between First Contentful Paint and Time to Interactive. High TBT means the page feels janky even if it looks loaded.
Web Worker — a JavaScript context that runs off the main thread. Can't touch the DOM but can do CPU-heavy work (parsing, formatting, cryptography) without blocking user input.
Dynamic import — import() syntax that delays loading a module until it's actually needed. Combined with Next.js dynamic(), keeps the initial bundle small.
Code splitting — automatically splitting JavaScript bundles so each page only loads what it uses.
Step-by-step
Work through the performance pass in the order we actually shipped it — diagnosis before refactor, measure after every change.
Problem: Lighthouse 61 on a one-feature page
The first version bundled Prism, React client runtime, and the formatter logic into the initial parse. Users saw a heading but couldn't type for nearly a second on throttled mobile. TBT hit 840 ms — unacceptable for a tool page that should feel instant.
Approach: shell + island + deferred heavy work
We split the page into a Server Component shell (heading, description, SEO prose) and a client island (textarea + format button). Syntax highlighting moved behind a dynamic import. Parsing moved to a Web Worker once inputs exceeded ~50 KB.
Outcome: Lighthouse 97, TBT 45 ms
LCP dropped from 3.2 s to 1.1 s because the LCP element is server-rendered HTML. CLS fell to 0.01 after we gave the textarea explicit dimensions. The pattern now ships on every Baseline tool page.
Problem: large JSON paste freezes input
A QA engineer pasted a 1.8 MB API response. JSON.parse on the main thread blocked typing for ~300 ms — long enough to feel broken.
Approach: Web Worker with postMessage fallback
Worker receives raw string, returns formatted JSON or an error object. Component shows a lightweight "Formatting…" state while the module loads.
Outcome: main thread stays responsive
Even on 4x CPU throttle, input latency stayed under 16 ms during parse. Users stopped reporting "the page locked up" in feedback.
Working examples
After the refactor, the Lighthouse scores:
| Metric | Before | After |
|--------|--------|-------|
| Performance | 61 | 97 |
| TBT | 840 ms | 45 ms |
| LCP | 3.2 s | 1.1 s |
| CLS | 0.18 | 0.01 |
CLS dropped because we gave the textarea explicit dimensions.
Patterns
Shell + enhancement pattern — server-render the static wrapper, lazy-load the interactive widget. Works for any tool page.
Worker-first parsing pattern — anything that parses user input over ~50 KB goes to a worker. The threshold is low because you don't know what your users will paste.
Dimension-first textarea pattern — always set rows and cols (or a CSS min-height) before the component hydrates. Prevents layout shift.
Common mistakes
Marking the whole page "use client" because one small component needs it. Use the server wrapper + client island pattern instead.
Loading syntax highlighting eagerly. It's almost always unnecessary on first paint. Users need to input text before they see highlighted output — that's hundreds of milliseconds of idle time to load the module.
Little tip
Use performance.mark() around your JSON.parse call during development to see exactly how long it takes on a mid-range device. Chrome's "CPU slowdown 4x" throttle in DevTools is your friend here.
Little tip
When splitting to a Web Worker in Next.js, put the worker file in public/workers/ and reference it with new Worker("/workers/jsonWorker.js"). The build system won't try to bundle it and it's always accessible from the client.
Troubleshooting
Worker file 404 in production. Next.js doesn't automatically copy files from src/ to the output. Put workers in public/ or use a webpack worker loader plugin.
Syntax highlighting flickers on first format click. The dynamic import hasn't resolved yet. Show a loading state (Formatting…) for the 200 ms it takes to load.
CLS regression after adding the widget. The widget container doesn't have a fixed height before hydration. Set min-height in CSS to match the post-hydration size.
Checklist
- [ ] Page shell is a Server Component
- [ ] Interactive widget is a separate
"use client"file - [ ] Syntax highlighter is dynamically imported
- [ ] JSON parsing moves to a Web Worker for inputs >50 KB
- [ ] textarea has explicit dimensions set
- [ ] Lighthouse mobile Performance ≥ 90 before shipping
- [ ] TBT < 200 ms on throttled mobile
Practice task
Take any existing "use client" page in your project. Split it into a server shell and a client island. Measure the bundle size difference with next build before and after. Report the delta.
FAQ
Why a Web Worker for JSON.parse? Isn't it fast?
For small inputs, yes. For a 2 MB API response someone pastes in from Postman, no. A worker costs ~5 ms to spin up and saves hundreds on large input.
Why not use a server action to format the JSON?
You could. A server round trip adds latency and puts server resources on the critical path for a tool that could run entirely in the browser. Save server actions for things that need server resources (auth, DB, secrets).
Can I use a SharedArrayBuffer instead of postMessage?
Yes, but it requires COOP/COEP headers and is overkill for a formatter. postMessage serialises the string — for JSON under a few MB that's fast enough.
What to learn next
- Next.js bundle analysis with
@next/bundle-analyzer - Web Workers in depth — transferable objects, comlink, worker pools
- Core Web Vitals field data with the Chrome User Experience Report (CrUX)
Related on Baseline
- [Building the Baseline content hub](/case-studies/building-baseline-content-hub)
- [Next.js performance optimisation guide](/developers/nextjs/nextjs-performance-optimization)
- [SEO IA for AI media](/case-studies/seo-ia-for-ai-media)
Takeaways
A 300-line tool component can wreck your Lighthouse scores faster than any blog post. The fix is almost always the same: move static content to the server, lazy-load what the user hasn't asked for yet, and push CPU work off the main thread.
If you remember only one thing: server-render the shell, lazy-load the widget, and your LCP will be fine. Everything else is tuning.