DEV Community

Cover image for 51 tools, one container: build-time reference tables and a CLS bug hunt
Sonny
Sonny

Posted on

51 tools, one container: build-time reference tables and a CLS bug hunt

I've spent the last weeks building lans.cloud: 51 free single-purpose web tools — classroom seating charts, crochet calculators, D&D stat rollers, Minecraft build math. This post is about the three technical decisions that shaped it, and a layout-shift bug hunt that ended somewhere I didn't expect.

The one hard rule: everything computes in the browser

Every tool is client-side. The only server code in the entire project is an anonymous page-view counter (one Postgres upsert) and a feedback endpoint. No accounts, no sessions, and the privacy page can make a claim most sites can't: class lists and student names never reach a server, because there is no code that could receive them.

The payoff is operational: the whole site is statically prerendered by Next.js and served by one 41 MB container behind Traefik on a small VPS. Deploys are docker compose up --build. Capacity planning is "the network port saturates before anything else does."

Reference tables that cannot drift

Every tool page carries reference content — a Minecraft circle block chart, grading tables, dice probabilities, pomodoro preset math. The rule: pages import the tool's own logic library and compute their tables at build time.

<ReferenceTable
  title="Classic tabata 20/10 — total time by number of rounds"
  rows={ROUND_COUNTS.map((rounds) => [
    String(rounds),
    formatTotal(
      totalDuration(buildSchedule({ prepare: 10, work: 20, rest: 10, rounds }))
    ),
  ])}
/>
Enter fullscreen mode Exit fullscreen mode

The table on the page and the timer the visitor runs share one implementation, so they can't disagree. When I added canon Dragon Ball power-level matchups, the lookup throws at build time if a character name doesn't match the data — a typo fails the build instead of publishing a wrong number.

As a side effect, this is SEO content that's genuinely useful and impossible for a scraper to keep current: it regenerates from code on every build.

The CLS hunt: 0.71 → 0

Lighthouse flagged Cumulative Layout Shift 0.71 on the busiest page (threshold for "good": 0.10). Three fixes, each revealing the next problem:

Round 1 — the skeleton mismatch. The interactive tools hydrate behind a Suspense skeleton. The skeletons were ~500px; the hydrated apps were up to 1,186px on mobile. Everything below jumped on hydration. Fix: measure every tool's real hydrated height headlessly at mobile and desktop widths, and put those values as responsive min-height on the wrapper that survives the swap. Result: … CLS unchanged. Correct fix, wrong culprit.

Round 2 — the ad slot. A PerformanceObserver layout-shift trace on the live page showed the entire app area moving as one block: the ad slot above it reserved 90px, but the responsive AdSense unit rendered ~250px. Raised the reservation per breakpoint. Result: better, still failing on one page.

Round 3 — the real culprit. The trace rects made no sense until I measured the <ins> element directly: AdSense's script pre-sizes a full-width-responsive unit to roughly viewport-width height — 412px tall on a 412px phone — before any ad even fills. No reservation short of the viewport width survives that.

The documented escape hatch: give the <ins> an explicit CSS size and remove data-ad-format/data-full-width-responsive. AdSense then serves into exactly that box and never resizes it:

<ins
  className="adsbygoogle block h-[250px] w-full max-w-[336px] lg:h-[90px] lg:max-w-[728px]"
  data-ad-client={CLIENT_ID}
  data-ad-slot={SLOT}
/>
Enter fullscreen mode Exit fullscreen mode

CLS after: 0 on every page I measured. The trade-off is a slightly narrower ad auction than "auto" — but 300×250 is the most-bid display size there is, and CLS is the Core Web Vital that feeds rankings, which feed everything else.

Assorted lessons

  • A min-height reservation is a guess; a CSS-sized ad unit is a contract.
  • Measure with PerformanceObserver('layout-shift') sources on the real page — Lighthouse tells you that something moved, the sources tell you what and from where.
  • If you pipe git commit through tail in a && chain, the pipeline's exit code is tail's. My pre-commit hook failed silently and I deployed a working tree that git didn't have. Check git log origin/master, not exit codes.
  • Entering browser fullscreen remounts your React tree if the fullscreen branch renders differently. Anything not persisted is gone. Users find this in minutes.

The site is lans.cloud — all free, feedback button on every page. Happy to answer anything about the setup.

Top comments (0)