I'll be the first to admit it: when I first ran Lighthouse on our JSON formatter tool, the results were... not great. We were sitting at a 72 for performance, 85 for accessibility, and honestly, I thought that was fine. "It works, right?" I told myself. "Users can format their JSON. What more do they need?"
Then I actually used the tool on a slow 3G connection, and reality hit me like a ton of bricks. The page took forever to load. CodeMirror (the code editor library we're using) was massive, and we were loading everything upfront. The tree view component was blocking the initial render. It was a mess.
That's when I decided: we're going for 100. Not because we had to, but because we should. If we're building a tool that developers rely on, it should be fast. It should be accessible. It should be the best version of itself.
Six weeks later, we hit 100 across the board. Here's exactly how we did it, what we learned, and what you can take away for your own projects.
The Starting Point: Where We Were
Let me paint you a picture of where we started. Our JSON formatter was built with Next.js 14, which is great, but we weren't using it to its full potential. We had:
- A 2.1MB initial JavaScript bundle (yes, you read that right)
- CodeMirror loading on every page visit, even if users never touched the editor
- No code splitting to speak of
- Images that weren't optimized
- Fonts loading synchronously
- A tree view component that rendered everything upfront
- Zero accessibility considerations beyond basic HTML
Our Lighthouse scores told the story:
- Performance: 72
- Accessibility: 85
- Best Practices: 92
- SEO: 88
Not terrible, but definitely not great. And definitely not 100.
The First Win: Understanding What Lighthouse Actually Measures
Before we started optimizing, I spent a day just understanding what Lighthouse is actually looking for. This might sound obvious, but I'd been treating it like a black box—run it, see the score, try to fix the red items. That's not the right approach.
Lighthouse measures real user experience. It's simulating what happens when someone visits your site on a slow connection with a mid-range device. It's not about making your site fast on your M2 MacBook Pro with gigabit internet. It's about making it fast for everyone.
The key metrics are:
- First Contentful Paint (FCP): When the first content appears
- Largest Contentful Paint (LCP): When the main content is visible
- Time to Interactive (TTI): When the page is actually usable
- Total Blocking Time (TBT): How long JavaScript blocks the main thread
- Cumulative Layout Shift (CLS): How much the page jumps around while loading
Once I understood this, the optimizations became obvious. We needed to:
- Reduce JavaScript bundle size
- Load critical resources first
- Defer non-critical JavaScript
- Optimize images and fonts
- Make sure nothing blocks rendering
Optimization #1: Code Splitting and Dynamic Imports
The biggest win came from code splitting. CodeMirror is a fantastic editor, but it's heavy—around 800KB minified. We were loading it on every page visit, even though users might just want to paste JSON and format it without using the editor features.
The solution? Dynamic imports with Next.js.
const JsonFormatterClient = dynamic(() => import('./JsonFormatterClient'), {
ssr: false,
loading: () => <div>Loading formatter...</div>
})
This was our first major change. By making the entire client component load dynamically, we cut our initial bundle size in half. The page could render, show content, and then load the interactive editor in the background.
But we didn't stop there. Inside the client component, we also lazy-loaded CodeMirror itself:
const CodeMirror = dynamic(() => import('@uiw/react-codemirror'), {
ssr: false
})
This meant CodeMirror only loaded when the component actually mounted, not during the initial page load. For users on slow connections, this was huge.
Impact: Initial bundle size dropped from 2.1MB to 890KB. Performance score jumped from 72 to 84.
Optimization #2: Font Optimization
Fonts were another easy win. We were using Inter from Google Fonts, but we were loading it the old-fashioned way—with a <link> tag in the HTML. This blocked rendering.
Next.js has a built-in font optimization system, and we weren't using it. So we switched:
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter'
})
The display: 'swap' is crucial here. It tells the browser to show fallback text immediately, then swap in the custom font when it's ready. This prevents invisible text during font load (FOIT) and improves FCP.
Impact: FCP improved by 0.3 seconds. Performance score: 84 → 87.
Optimization #3: Image Optimization
We had a few images on the page—icons, logos, that kind of thing. Nothing huge, but they weren't optimized. We were serving full-resolution PNGs when we could have been using WebP or even SVGs.
Next.js Image component handles this automatically, but since we're using static export, we had to be more careful. We:
- Converted icons to SVGs where possible (they're tiny and scale perfectly)
- Used WebP format for photos
- Added proper
widthandheightattributes to prevent layout shift - Used
loading="lazy"for below-the-fold images
For the cover image, we created multiple sizes and let the browser choose:
<img
src="/images/cover.webp"
srcset="/images/cover-400.webp 400w, /images/cover-800.webp 800w"
sizes="(max-width: 768px) 100vw, 800px"
alt="JSON Formatter"
loading="lazy"
/>
Impact: Image load time dropped by 60%. CLS improved. Performance: 87 → 90.
Optimization #4: Tree View Lazy Rendering
The tree view was a performance killer. We were rendering the entire JSON structure upfront, even for massive files. If someone pasted a 10MB JSON file, we'd try to render thousands of nodes immediately.
The fix was virtual scrolling. Instead of rendering all nodes, we only render what's visible in the viewport, plus a small buffer. As the user scrolls, we render more.
We also made the tree view collapsible by default. Users have to expand sections they want to see, which means we're not rendering everything at once.
const [expandedNodes, setExpandedNodes] = useState(new Set())
// Only render nodes that are expanded or in viewport
const visibleNodes = useMemo(() => {
return nodes.filter(node =>
expandedNodes.has(node.id) || isInViewport(node)
)
}, [nodes, expandedNodes])
Impact: Initial render time for large JSON files dropped from 3-4 seconds to under 500ms. TBT improved significantly. Performance: 90 → 93.



