WEIRDSOFT
Back to blog

Designing for Performance — A Developer's Checklist

Performance is not an audit you run the night before launch. It is a design constraint you enforce from the first commit. At WEIRDSOFT, we treat performance as a first-class feature, with the same rigor as accessibility or security. Every project ships with a performance budget, real-user monitoring, and a CI gate that rejects regressions. This checklist is the framework we run against every sprint — not every quarter, not just before go-live.

Build Phase: Optimising Before the Browser Sees a Byte

The build pipeline is the earliest point where performance decisions compound. A badly configured build pipeline ships bloat to every user on every page. Getting this right means the browser has less to parse, less to execute, and less to download before it can paint anything useful.

Bundle Size Budgets in CI

Set a hard limit on your JavaScript bundle and fail the build when it is exceeded. Use tools like @next/bundle-analyzer or webpack-bundle-analyzer to visualise where bytes are going, then codify the threshold in CI.

// next.config.ts
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
})

module.exports = withBundleAnalyzer({
  experimental: {
    budget: {
      page: { max: 150_000 },    // 150 KB per page
      layout: { max: 80_000 },   // 80 KB per shared layout
    },
  },
})

Without a budget, bundles grow monotonically. With a budget, every dependency addition triggers a conversation. We have seen teams cut bundle size by 40% in a single quarter simply by enforcing a max: 200_000 rule on their main entry point.

Dynamic Imports for Below-Fold and Heavy Components

Not every component needs to load upfront. Use Next.js dynamic imports with ssr: false for components that are below the fold or depend on heavy client-side libraries.

import dynamic from 'next/dynamic'

const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
  ssr: false,
  loading: () => <ChartSkeleton />,
})

This pattern is especially powerful for data visualisation libraries (Chart.js, D3), rich text editors, and interactive maps. One WEIRDSOFT client reduced their initial bundle from 320 KB to 140 KB by deferring a map component that only 30% of users ever interacted with.

Tree-Shakeable Imports

Barrel files and wildcard imports defeat tree-shaking. Audit your imports regularly and prefer direct named imports.

// ❌ Bad — pulls in the entire library
import { format } from 'date-fns'

// ✅ Good — tree-shakeable, only what you need
import format from 'date-fns/format'

Configure your ESLint rules to catch this automatically:

{
  "rules": {
    "no-restricted-imports": ["error", {
      "patterns": ["*index", "*/index"]
    }]
  }
}

CSS Purged in Production

If you use Tailwind CSS, purging is automatic in v4. If you use vanilla CSS or CSS Modules, audit for dead code with tools like purgecss. Even Tailwind projects can accumulate dead utilities from stray class name strings in dynamic code. Keep an eye on the final CSS output in your build logs.

Network Strategy: Every Millisecond on the Wire

Once the build is lean, the next bottleneck is the network. Modern performance is dominated by latency, not bandwidth. A 300 KB bundle on a 4G connection with 200 ms RTT takes over 3 seconds to load — but the same bundle on a cache-first service worker feels instant.

Fonts: Self-Host or Preconnect

Third-party font hosts introduce DNS lookups, TLS negotiation, and unpredictable latency. Self-host your fonts and subset them to the Latin characters your site actually uses.

// next.config.ts
module.exports = {
  experimental: {
    optimizeFonts: true,
  },
}

When you must use a third-party font host, preconnect to the origin and preload the primary font file:

<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin />

Font swap strategies matter too. Use font-display: optional for body text to avoid layout shift from invisible text, and font-display: swap for headings where the visual change is acceptable.

Critical CSS Inlined

Extract the CSS needed to render the above-fold content and inline it in the <head>. Tools like critters (built into Next.js) handle this automatically, but verify it in your staging environment:

// next.config.ts
module.exports = {
  experimental: {
    inlineCss: true,
  },
}

In our testing, inlining critical CSS improves Largest Contentful Paint (LCP) by 15-25% on mobile 3G connections, because the browser does not have to wait for a stylesheet download before beginning the render pass.

Image Optimisation

Next.js Image components handle most of this, but there are subtleties:

import Image from 'next/image'

<Image
  src="/hero.webp"
  alt="Product hero"
  width={1200}
  height={675}
  priority={true}           // LCP image — load immediately
  sizes="(max-width: 768px) 100vw, 50vw"
  placeholder="blur"
/>

Always provide a sizes attribute. Without it, the browser assumes the image is 100vw wide and downloads the largest variant even on a phone screen. The placeholder="blur" option gives users a low-quality image placeholder (LQIP) while the real image loads, reducing perceived latency.

API Response Caching and Compression

Your API responses are part of your page weight. Ensure every API endpoint returns compressed responses and is cached aggressively where appropriate:

Endpoint Type Cache Strategy TTL
Public content (blog posts, docs) CDN cache (SWR) 5 minutes stale-while-revalidate
User-specific data (profile, settings) Browser cache + ETag Until changed (304 Not Modified)
Real-time data (dashboards) In-memory cache (Redis) 30 seconds
Static references (countries, categories) CDN cache 24 hours
// Route handler with caching headers
export async function GET() {
  const data = await getPosts()
  return Response.json(data, {
    headers: {
      'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=60',
    },
  })
}

Runtime Performance: What Happens After the Page Loads

Getting the page to load fast is half the battle. Keeping it responsive during interaction — that is the other half, and it is where most sites fail.

No Render-Blocking Scripts Above the Fold

Every <script> tag without defer or async blocks the HTML parser. Audit your <head> for synchronous scripts and move them out.

<!-- ❌ Blocks rendering -->
<script src="/tracker.js"></script>

<!-- ✅ Non-blocking -->
<script src="/tracker.js" defer></script>

Next.js handles this automatically for its own scripts, but third-party scripts (analytics, ads, widgets) need manual care. Use the next/script component with the afterInteractive or lazyOnload strategies:

import Script from 'next/script'

<Script
  src="https://analytics.example.com/script.js"
  strategy="lazyOnload"
/>

Passive Event Listeners

Touch and wheel event listeners can block scrolling if they are not marked passive. Modern React handles this for synthetic events, but be careful with native addEventListener calls in effects:

useEffect(() => {
  const handler = (e: WheelEvent) => { /* ... */ }
  window.addEventListener('wheel', handler, { passive: true })
  return () => window.removeEventListener('wheel', handler)
}, [])

The passive flag tells the browser the handler will not call preventDefault(). This allows the browser to start the scroll immediately rather than waiting for the handler to complete.

Animations on the Compositor Thread

Animating width, height, top, or left triggers layout recalculations. Animating transform and opacity does not — they run entirely on the GPU compositor thread.

/* ❌ Triggers layout — expensive */
.card { transition: left 300ms; left: 100px; }

/* ✅ Compositor thread — cheap */
.card { transition: transform 300ms; transform: translateX(100px); }

When you see janky animations, open the Performance tab in DevTools and look for purple "Rendering" bars. If you see Layout or Paint events alongside your animation frames, switch to transform-based animations.

Long Tasks and the Main Thread

A long task is any JavaScript execution that blocks the main thread for 50 ms or more. Long tasks delay user interactions and push out Time to Interactive (TTI). You can find them in the Lighthouse report or the Performance panel.

// Break up heavy work into micro-tasks using scheduler.yield() or setTimeout
async function processItems(items: Item[]) {
  for (let i = 0; i < items.length; i++) {
    processItem(items[i])
    if (i % 10 === 0) {
      // Yield to the browser
      await new Promise(resolve => setTimeout(resolve, 0))
    }
  }
}

For truly expensive computations, move them to a Web Worker:

const worker = new Worker(new URL('./heavy-worker.ts', import.meta.url))
worker.postMessage(items)
worker.onmessage = (event) => setResults(event.data)

Monitoring and Observability: You Cannot Fix What You Do Not Measure

Performance optimisation is a feedback loop. Without data, you are guessing. With real-user monitoring (RUM), you know exactly where your users are feeling the pain.

Real-User Monitoring (RUM)

Synthetic tests (Lighthouse, WebPageTest) are useful for catching obvious regressions in CI, but they do not reflect real-world conditions. Users have different devices, networks, and browser extensions. Deploy RUM on every page.

// app/layout.tsx
import { reportWebVitals } from 'next/client'

export function onReport(metric: any) {
  // Send to your analytics provider
  fetch('/api/analytics', {
    method: 'POST',
    body: JSON.stringify(metric),
  })
}

Track these metrics on every page:

  • LCP (Largest Contentful Paint): Perceived load speed — target < 2.5 seconds
  • FID/INP (Interaction to Next Paint): Responsiveness — target < 100 ms for FID, < 200 ms for INP
  • CLS (Cumulative Layout Shift): Visual stability — target < 0.1
  • TTFB (Time to First Byte): Server response time — target < 800 ms

Core Web Vitals per Page

Not all pages are equal. Your landing page may have excellent scores while your product listing page is a disaster. Track Core Web Vitals per page path and set up alerts for regressions.

A simple dashboard in DataDog or Grafana should show you the 75th percentile of each metric per page, per device type, per connection type. When you deploy a change, watch these numbers for 24 hours. If LCP jumps by 10%, roll back or investigate.

Performance Budget Alerts

Extend your CI pipeline to include performance budgets:

# .github/workflows/performance.yml
name: Performance Budget
on: [pull_request]
jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: treosh/lighthouse-ci-action@v10
        with:
          budgetPath: ./budget.json
{
  "budgets": [
    {
      "path": "/*",
      "resourceSizes": [
        { "resourceType": "script", "budget": 200 },
        { "resourceType": "total", "budget": 500 }
      ],
      "timings": [
        { "metric": "interactive", "budget": 4000 },
        { "metric": "first-contentful-paint", "budget": 2000 }
      ]
    }
  ]
}

When a PR introduces a regression, the check fails and the developer sees exactly which metric exceeded the budget and by how much.

Real-World Performance Wins

Over the past year, WEIRDSOFT has worked with several clients to transform their performance profile. Here are two examples that illustrate the impact of a systematic approach.

E-Commerce Platform: 62% LCP Reduction

An e-commerce client had an LCP of 6.2 seconds on mobile. The culprit was a hero image loaded without priority, render-blocking Google Tag Manager scripts, and a 480 KB bundle that included a full charting library used only on the admin dashboard.

Changes made:

  1. Added priority and sizes attributes to the hero image
  2. Moved Tag Manager to strategy="lazyOnload"
  3. Code-split the charting library into the admin route only
  4. Inlined critical CSS

Result: LCP dropped to 2.4 seconds. Conversion rate increased 11%.

SaaS Dashboard: 40% Reduction in INP

A SaaS client's dashboard had an Interaction to Next Paint (INP) of 420 ms. The problem was a heavy row-rendering component that recalculated on every keystroke in a search filter.

Changes made:

  1. Debounced the search input with a 300 ms delay
  2. Virtualized the row list with react-window
  3. Moved sorting and filtering logic into a Web Worker

Result: INP dropped to 140 ms. User session duration increased by 24%.

Making Performance a Habit

The most important principle is consistency. Run your performance checklist every sprint, not every quarter. Post a performance dashboard in your team chat. Celebrate improvements and investigate regressions with the same urgency as a production bug.

Performance is not a project phase. It is a discipline. And like any discipline, it is built on habits, not heroics.

Here is the checklist we use at WEIRDSOFT — the same one we have reproduced above — distilled to its essence:

Category Check Tool / Method
Build Bundle size under budget @next/bundle-analyzer
Build Dynamic imports for heavy components next/dynamic
Build Tree-shakeable imports ESLint rule + manual audit
Network Fonts self-hosted or preconnected next/font or
Network Critical CSS inlined critters / next.config
Network Images optimised with sizes next/image
Network API responses cached Cache-Control headers
Runtime No render-blocking scripts next/script with lazyOnload
Runtime Passive listeners for scroll/wheel addEventListener
Runtime Compositor-only animations transform / opacity
Runtime Long tasks deferred Web Workers / scheduler.yield()
Monitor RUM active per page next/client reportWebVitals
Monitor Core Web Vitals per path DataDog / Grafana dashboard
CI Performance budget gate lighthouse-ci-action

Print it. Stick it on your wall. Run it every sprint. Your users will feel the difference.