Skip to content

development

4 posts

Forty-one to eighty-eight

Showrunner’s mobile PageSpeed score was 41 (boo!). It is now 88 (yay?). This is a technical note on how that happened, mostly because the most useful lesson from the whole exercise was far from what I expected, and maybe that will help someone who goes Googling looking for a solution.

Showrunner is built on Next.js, React, TypeScript and Tailwind, with Supabase as the backing store. The public pages stream server-rendered content, the admin side is where I actually write posts. I’d noticed that the site’s Performance score wasn’t terrific: 41/100. I know enough not to obsess too much on the specific score, but that it could be drastically improved by focusing on the specific metrics that contribute to the big number.

I had some ideas of where to start, but for this I recruited both Claude Code and OpenAI’s Codex into this process at various points, whether diagnosing issues and suggesting approaches, or carrying out the larger structural changes.

When you break down that score of 44, the starting position wasn’t a disaster. CLS was already zero. Images were AVIF/WebP. next/image was everywhere it was supposed to be — except, crucially, inside markdown content, where the rendered HTML was being injected via dangerouslySetInnerHTML and bypassing the image pipeline entirely. Claude flagged this as the likely LCP bottleneck. It was right.

Codex proposed a four-part plan. I agreed on the targets, pushed back on the scope (a “homepage-only” markdown renderer, when the cards in question render on /writing, /tag/[slug], /search, and inside the load-more flow), and asked for the changes to be sequenced so each one could be attributed to a number. That last bit turned out to matter more than anything else.

The first four

Deleting app/loading.tsx was a one-line change. The root loading skeleton was wrapping the entire public tree, which meant Lighthouse was measuring the skeleton as the LCP candidate rather than the actual timeline content streaming in behind it. Score climbed into the mid-50s.

Wrapping getAllSiteSettings in React 19’s cache() deduped three Supabase round-trips per request down to one. Small but real.

Rewriting getTimelineItems to use limit + 1 instead of count: "exact" saved Postgres a full row count on every query. The performance win was minor; this was more of a hygiene change.

Then the big one: overriding md.renderer.rules.image so markdown-rendered images emitted /_next/image URLs with srcset, sizes, dimensions, and fetchpriority for the first image on a priority card. (Cards are the React components that render each post in the timeline — one per post type, so a TextPostCard, an AlbumPostCard, a LetterboxdCard, and so on.) The dimensions came from the upload handler reading them out of the buffer at the time of upload — stored in posts.metadata.inline_images, JSONB, no migration required. After this landed: Performance 65, LCP down from 15.2s to 4.5s.

The change that did the work

PageSpeed at 65 was respectable, but it wasn’t good.

Wiring in the bundle analyzer made the next move obvious. A 109 KB chunk on the public bundle was entirely markdown-it and its dependencies — markdown-it-footnote, entities, linkify-it, punycode, mdurl. The homepage was shipping a full markdown parser to the client, even though the server already had all the markdown and was perfectly capable of rendering it. The cards were importing renderMarkdown directly, the dynamic-imported LoadMoreButton was prefetching them, and so the parser was riding along with every public page.

The fix was conceptually simple and structurally invasive: render HTML at the query layer, attach it to the post as content_html, and strip the @/lib/markdown import out of every card component. Seven cards lost their markdown imports. RecapPostCard lost its editorial-slicing helper (the slicing now happens server-side, once). VideoPostCard’s embed extraction simplified for the same reason. The RSS route got a { optimizeImages: false } flag so feed readers receive absolute URLs rather than /_next/image paths.

Bundle analyzer confirmed it: markdown-it gone from public bundles, shipping only with the admin editor. Performance jumped from 65 to 88. LCP 4.5s to 2.9s. TBT nearly halved — exactly what you’d expect from pulling a parser off the main thread.

This was the whole phase, really. The rest was setup and cleanup.

The lesson worth writing down

At 88, PageSpeed flagged “613 KiB unused JavaScript” and “99 KiB unused CSS.” I went looking for what was left to cut. There wasn’t much. Total production CSS was 8.7 KB gzipped — Lighthouse’s “99 KiB unused” figure was larger than the entire stylesheet, an artefact of how it counts(?). The top four JS chunks were framework, react-dom-client, the Next.js client runtime, and main. All untrimmable. We were sitting on the framework floor.

The only real lever left was Google Analytics. afterInteractive runs gtag.js as soon as React hydration finishes; lazyOnload waits for window.onload. Deferring further was the first thing that occurred to me. I changed two strategy attributes.

Performance went from 88 to 67. TBT went from 250ms to 870ms. Oh dear.

The theory had been plausible: get GA out of the hydration window, free up the main thread, save some TBT. The measurement said the opposite. On Lighthouse’s throttled mobile, lazyOnload shifted GA’s parse and eval to land inside the TBT measurement window, where it counted as blocking. afterInteractive had let it ride alongside hydration, which Lighthouse already budgets for. The counterintuitive answer was the right one: defer less, not more.

Reverted, documented, moved on.

What I’d take from this

Sequence changes and measure each one. Without the per-change attribution, I’d never have known the server-side markdown render was worth twenty-three points on its own — and worth the structural disruption it caused.

The framework floor is real. Once React and Next.js dominate the bundle, Lighthouse’s “unused JS” metric becomes misleading. It’s coverage noise rather than a lever.

“Homepage-only” is almost always the wrong scope boundary when components are shared. The pre-rendering refactor worked because it was the same code path everywhere.

And measurement beats intuition, every time. The lazyOnload reversal cost twenty-one points on a change that any reasonable person would have shipped without checking. That one’s now an invariant in CLAUDE.md, recorded specifically so I don’t try it again, and nor does Claude.

Fabian's Arena

Fabian’s Quest handled the classroom side—times tables, spelling, the nightly homework loop. This one exists because the kid is obsessed with football in a way that borders on clinical.

He’s had us both up before seven every day this past week, out at the park for over an hour before breakfast. This isn’t unusual. Other children on his team play their Saturday match and that’s football done for the week. Fabian would play for eight hours a day if you let him, and he’d be annoyed when you stopped. The app was his idea—he wanted something that would make our training sessions better, not just longer.

The problem was familiar: we’d show up with a ball, a bag of cones and a vague intention to “work on shooting,” which meant twenty minutes of him firing shots at me followed by forty minutes of whatever felt easy. Enjoyable, but aimless. He knew it was aimless. He told me.

Fabian’s Arena gives those sessions structure while keeping everything fun—which is the whole point when you’re eight. It generates 60-minute blocks (warm-up, two skill rounds, a 1v1 match, cool-down) with drills drawn from a library of 30 across six categories: dribbling, passing, shooting, first touch, speed and agility, defending. Each drill comes with coaching tips, a suggested duration, and a linked YouTube tutorial.[1]

There’s also a spin wheel for when you just want one quick drill picked at random, which is most Tuesday evenings.

The visual language is dark pitch-green with amber and gold accents, glassmorphic cards, and the same Fredoka headings used in Fabian’s Quest. It runs as a PWA on my phone, works offline at the park. Progress data persists locally via Zustand and syncs to Supabase when there’s a connection, which means nothing is lost between the front door and the goalposts.

Some details worth noting:

  • The session generator uses weighted random selection, boosting underrepresented skill categories and biasing toward a chosen focus skill—so sessions feel varied without being chaotic
  • A badge system with 17 achievements across milestones, streaks, and per-category mastery, surfaced through a full-screen celebration overlay that an eight-year-old finds deeply satisfying
  • The spin wheel is an SVG with Framer Motion rotation—ten random drills on coloured segments, four to six full spins before landing
  • Drills show a suggested duration and a manual “Done” button. I’d toyed with an in-app timer, but phone screens lock during actual training, which kills any running countdown
  • The 1v1 match block has no drills—it’s twenty minutes of free-play football, Dad vs Fabian, rendered as its own card in the session flow

Built with Next.js, TypeScript, Tailwind and Framer Motion. A companion to Fabian’s Quest rather than a sequel.


  1. I watched hours of kids’ football tutorial videos. The quality YouTube search returns is remarkably variable—even with precise coach-speak in the search terms, I’d get anything from a Premier League academy production to a man kicking a beachball around a garden with a toddler. ↩︎

Fabian's Quest

My son is in Year 3 and needs to practise times tables, spelling, and a handful of other subjects every night. The available apps were either too broad, too patronising, or too keen on subscription revenue. I built him something instead.

Fabian’s Quest is a learning app dressed up as an RPG. There’s a Daily Quest—37 questions across seven rounds covering maths, scales, spelling, grammar, science, history, and geography—designed to take about ten minutes. Short enough that it happens every night regardless of energy levels. You can also take a deeper dive into any of the subjects on their own. Each correct answer earns XP, streaks build, levels unlock. The kind of feedback loop that works on a child who treats everything as a competition.

It runs on an iPad, built with React and Vite, deployed to Cloudflare Pages. No backend—player data lives in localStorage, which is fine when your entire user base shares a device and a surname. The visual language is dark navy with gold accents, chunky rounded type, and the sort of progress bars that make eight-year-olds feel like they’re levelling up in a proper game. (Claude helped with the styling—it’s not my thing at all.)

Some details worth noting:

  • Procedurally generated measurement questions with inline SVG number lines—no static image assets, no question bank to exhaust
  • A spelling hint system that auto-detects patterns (double letters, silent letters, tricky vowel pairs) and generates contextual clues rather than just revealing the answer
  • Programmatic sound effects via the Web Audio API and text-to-speech via the Web Speech API—zero external media files
  • The entire application lives in a single 3,200-line React component, which I mostly think is admirably focused but occasionally see as a future problem
  • I’ve built a workflow with Claude Code to automatically update it each week when the school sends home new material

The name was Fabian’s idea. He wanted a quest.

Showrunner

A personal publishing system built to scratch a very specific itch: I wanted a Tumblr-style blog with editorial design sensibilities, and nothing that existed was quite right.

Showrunner is a headless CMS backed by Supabase and served through Next.js. It supports the post types I actually use—text, links, quotes, photos, albums—and pulls in activity from Letterboxd, Goodreads, Backloggd, and Pinboard to create a unified timeline of everything I’m reading, watching, playing, and bookmarking.

The design takes cues from Frank Chimero and Max MacWright: warm off-white backgrounds, a serif body column, restrained typography. Dark mode, naturally. The kind of site that looks like it was made by a person rather than a platform.

Some details worth noting:

  • Bluesky cross-posting, so I don’t have to choose between owning my content and participating in public conversation
  • Full-text search across posts and external feeds
  • Monthly Last.fm listening recaps generated automatically
  • Album posts enriched with metadata from Last.fm and MusicBrainz
  • Letterboxd reviews enriched with TMDB director credits and tag scraping

The whole thing was built collaboratively with Claude Code—architecture decisions, implementation, the editorial restyle, all of it. That process deserves its own post at some point.

The name is borrowed from television production. The showrunner is the person responsible for the creative direction of a series. Felt appropriate for a system whose entire purpose is giving one person control over how their work appears on the web.