
Perf Web Optimization
- 198 installs
- 5k repo stars
- Updated August 4, 2026
- tech-leads-club/agent-skills
Use perf-web-optimization for development tasks
About
perf-web-optimization: A skill for development. This provides functionality for development workflows.
- perf-web-optimization
Perf Web Optimization by the numbers
- 198 all-time installs (skills.sh)
- +8 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #2,050 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tech-leads-club/agent-skills --skill perf-web-optimizationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 198 |
|---|---|
| repo stars | ★ 5k |
| Last updated | August 4, 2026 |
| Repository | tech-leads-club/agent-skills ↗ |
What it does
Use perf-web-optimization for development tasks
Files
Web Performance Optimization
Systematic approach: Measure → Identify → Prioritize → Implement → Verify.
Target Metrics
| Metric | Good | Needs Work | Poor |
|---|---|---|---|
| LCP | < 2.5s | 2.5-4s | > 4s |
| INP | < 200ms | 200-500ms | > 500ms |
| CLS | < 0.1 | 0.1-0.25 | > 0.25 |
| TTFB | < 800ms | 800ms-1.8s | > 1.8s |
Quick Wins
1. Images (usually biggest impact on LCP)
<!-- Hero/LCP image: eager + high priority -->
<img src="/hero.webp" alt="Hero" width="1200" height="600" loading="eager" fetchpriority="high" decoding="async" />
<!-- Below fold: lazy load -->
<img src="/product.webp" alt="Product" width="400" height="300" loading="lazy" decoding="async" />Always set width and height to prevent CLS.
2. Fonts (common LCP/CLS culprit)
<!-- Preconnect to font origin -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-- Non-blocking font load -->
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter&display=swap"
media="print"
onload="this.media='all'"
/>3. Third-party Scripts (common INP killer)
<!-- Defer to user interaction -->
<script>
function loadThirdParty() {
// Load analytics, chat widgets, etc.
}
;['scroll', 'click', 'touchstart'].forEach((e) => addEventListener(e, loadThirdParty, { once: true, passive: true }))
setTimeout(loadThirdParty, 5000)
</script>4. Critical CSS
Inline critical CSS in <head>, defer the rest:
<style>
/* critical styles */
</style>
<link rel="preload" href="/styles.css" as="style" onload="this.rel='stylesheet'" />Bundle Analysis
# Webpack
npx webpack-bundle-analyzer dist/stats.json
# Vite
npx vite-bundle-visualizer
# Check package size before installing
npx bundlephobia <package-name>Common heavy packages to replace:
moment(67KB) →date-fns(12KB) ordayjs(2KB)lodash(72KB) → cherry-pick imports or native methods
Code Splitting Patterns
// React lazy
const Chart = lazy(() => import('./Chart'))
// Next.js dynamic
const Admin = dynamic(() => import('./Admin'), { ssr: false })
// Vite/Rollup manual chunks
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom']
}
}
}
}Caching Headers
# Static assets (immutable hash in filename)
Cache-Control: public, max-age=31536000, immutable
# HTML (revalidate)
Cache-Control: no-cache
# API responses
Cache-Control: private, max-age=0, must-revalidateMeasurement
For running audits, reading reports, and setting budgets, use the perf-lighthouse skill.
Checklist
Images
- [ ] Modern formats (WebP/AVIF)
- [ ] Responsive
srcset - [ ]
width/heightattributes - [ ]
loading="lazy"below fold - [ ]
fetchpriority="high"on LCP image
JavaScript
- [ ] Bundle < 200KB gzipped
- [ ] Code splitting by route
- [ ] Third-party scripts deferred
- [ ] No unused dependencies
CSS
- [ ] Critical CSS inlined
- [ ] Non-critical CSS deferred
- [ ] No unused CSS
Fonts
- [ ]
font-display: swap - [ ] Preconnect to font origin
- [ ] Subset if possible
Detailed Examples
For in-depth optimization patterns, see:
- references/core-web-vitals.md - Fixing LCP, CLS, INP issues
- references/bundle-optimization.md - Reducing JS bundle size
- references/image-optimization.md - Image formats, responsive images, sharp scripts
Bundle Size Optimization
Table of Contents
---
Analysis Tools
# Webpack - generates interactive treemap
npx webpack-bundle-analyzer dist/stats.json
# Generate stats file first
webpack --profile --json > dist/stats.json
# Vite
npx vite-bundle-visualizer
# Source map explorer
npx source-map-explorer dist/**/*.js
# Check package size before adding
npx bundlephobia lodash---
Heavy Dependencies
moment → date-fns/dayjs
// Before: moment (67KB)
import moment from 'moment';
moment(date).format('YYYY-MM-DD');
// After: date-fns (tree-shakeable, ~2KB per function)
import { format } from 'date-fns';
format(date, 'yyyy-MM-dd');
// After: dayjs (2KB total, moment-compatible API)
import dayjs from 'dayjs';
dayjs(date).format('YYYY-MM-DD');lodash → cherry-pick or native
// Before: entire lodash (72KB)
import _ from 'lodash';
_.uniq(array);
_.debounce(fn, 300);
// After: cherry-pick (2KB each)
import uniq from 'lodash/uniq';
import debounce from 'lodash/debounce';
// After: native alternatives
[...new Set(array)]; // uniq
// debounce - use custom or lodash-es/debounceOther common swaps
| Heavy | Light Alternative |
|---|---|
axios (13KB) | fetch (native) or ky (3KB) |
uuid (4KB) | crypto.randomUUID() (native) |
classnames (1KB) | template literals |
---
Code Splitting
React.lazy
import { lazy, Suspense } from 'react';
const Chart = lazy(() => import('./Chart'));
const AdminPanel = lazy(() => import('./AdminPanel'));
function App() {
return (
<Suspense fallback={<Loading />}>
{showChart && <Chart />}
{isAdmin && <AdminPanel />}
</Suspense>
);
}Next.js dynamic
import dynamic from 'next/dynamic';
// Client-only component
const Map = dynamic(() => import('./Map'), { ssr: false });
// With loading state
const Chart = dynamic(() => import('./Chart'), {
loading: () => <Skeleton height={300} />
});Route-based splitting (automatic in most frameworks)
// Next.js - each page is a separate chunk
// pages/dashboard.js → chunks/pages/dashboard.js
// pages/admin.js → chunks/pages/admin.js
// React Router with lazy
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Admin = lazy(() => import('./pages/Admin'));Manual chunks (Vite/Rollup)
// vite.config.js
export default {
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
charts: ['recharts', 'd3'],
}
}
}
}
};---
Tree Shaking
Enable in webpack
// webpack.config.js
module.exports = {
mode: 'production', // enables tree shaking
optimization: {
usedExports: true,
sideEffects: true,
}
};Mark package as side-effect free
// package.json
{
"sideEffects": false
}
// Or specify files with side effects
{
"sideEffects": ["*.css", "*.scss"]
}Write tree-shakeable exports
// Bad: default export of object
export default { foo, bar, baz };
// Good: named exports
export { foo, bar, baz };Core Web Vitals Optimization
Table of Contents
---
LCP (Largest Contentful Paint)
Target: < 2.5s
Common Causes
- Large unoptimized images
- Slow server response (TTFB)
- Render-blocking resources
- Client-side rendering delays
Fix: Optimize LCP Image
<!-- Preload in <head> -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high">
<!-- Image tag -->
<picture>
<source srcset="/hero.avif" type="image/avif">
<source srcset="/hero.webp" type="image/webp">
<img src="/hero.jpg" alt="Hero" width="1200" height="600"
loading="eager" fetchpriority="high" decoding="async">
</picture>Fix: Reduce TTFB
// Next.js: Use static generation when possible
export async function getStaticProps() {
const data = await fetchData();
return { props: { data }, revalidate: 60 };
}
// Add stale-while-revalidate for dynamic content
// Cache-Control: public, s-maxage=60, stale-while-revalidate=300---
CLS (Cumulative Layout Shift)
Target: < 0.1
Common Causes
- Images without dimensions
- Ads/embeds without reserved space
- Web fonts causing FOIT/FOUT
- Dynamic content injection
Fix: Reserve Space for Images
<!-- Always specify dimensions -->
<img src="/photo.jpg" alt="Photo" width="800" height="600">
<!-- Or use aspect-ratio -->
<img src="/photo.jpg" alt="Photo" style="aspect-ratio: 4/3; width: 100%;">Fix: Reserve Space for Dynamic Content
/* Skeleton loader with fixed height */
.ad-slot {
min-height: 250px;
background: #f0f0f0;
}
/* Aspect ratio container for embeds */
.video-container {
aspect-ratio: 16/9;
width: 100%;
}Fix: Prevent Font Flash
@font-face {
font-family: 'CustomFont';
src: url('/font.woff2') format('woff2');
font-display: swap; /* or optional for less shift */
}---
INP (Interaction to Next Paint)
Target: < 200ms
Common Causes
- Long JavaScript tasks (>50ms)
- Heavy event handlers
- Layout thrashing
- Too much main thread work
Fix: Break Up Long Tasks
// Before: blocks main thread
items.forEach(item => processItem(item));
// After: yield to main thread
async function processWithYield(items) {
for (const item of items) {
processItem(item);
// Yield every 5ms
if (performance.now() - start > 5) {
await new Promise(r => setTimeout(r, 0));
start = performance.now();
}
}
}Fix: Debounce/Throttle Event Handlers
// Debounce search input
const search = debounce((query) => {
fetchResults(query);
}, 300);
input.addEventListener('input', (e) => search(e.target.value));Fix: Use CSS Instead of JS
/* Prefer CSS for animations */
.animate {
transition: transform 0.3s ease;
}
.animate:hover {
transform: scale(1.05);
}
/* Use content-visibility for off-screen content */
.lazy-section {
content-visibility: auto;
contain-intrinsic-size: 0 500px;
}Image Optimization
Table of Contents
---
Modern Formats
| Format | Use Case | Savings |
|---|---|---|
| AVIF | Best compression, modern browsers | 50-80% vs JPEG |
| WebP | Good compression, wide support | 25-35% vs JPEG |
| JPEG | Fallback for old browsers | baseline |
Picture Element with Fallbacks
<picture>
<source srcset="/image.avif" type="image/avif">
<source srcset="/image.webp" type="image/webp">
<img src="/image.jpg" alt="Description" width="800" height="600">
</picture>---
Responsive Images
srcset with width descriptors
<img
src="/image-800.jpg"
srcset="
/image-400.jpg 400w,
/image-800.jpg 800w,
/image-1200.jpg 1200w
"
sizes="(max-width: 600px) 100vw, 50vw"
alt="Description"
width="800"
height="600"
loading="lazy"
>Full responsive picture
<picture>
<source
srcset="/hero-400.avif 400w, /hero-800.avif 800w, /hero-1200.avif 1200w"
sizes="100vw"
type="image/avif"
>
<source
srcset="/hero-400.webp 400w, /hero-800.webp 800w, /hero-1200.webp 1200w"
sizes="100vw"
type="image/webp"
>
<img
src="/hero-800.jpg"
srcset="/hero-400.jpg 400w, /hero-800.jpg 800w, /hero-1200.jpg 1200w"
sizes="100vw"
alt="Hero"
width="1200"
height="600"
>
</picture>---
Sharp Script
Batch convert images to modern formats and sizes:
// scripts/optimize-images.js
const sharp = require('sharp');
const fs = require('fs');
const path = require('path');
const SIZES = [400, 800, 1200];
const INPUT_DIR = './images/original';
const OUTPUT_DIR = './public/images';
async function optimizeImage(inputPath) {
const filename = path.basename(inputPath, path.extname(inputPath));
for (const size of SIZES) {
const resized = sharp(inputPath).resize(size);
// AVIF
await resized
.avif({ quality: 70 })
.toFile(`${OUTPUT_DIR}/${filename}-${size}.avif`);
// WebP
await resized
.webp({ quality: 80 })
.toFile(`${OUTPUT_DIR}/${filename}-${size}.webp`);
// JPEG fallback
await resized
.jpeg({ quality: 80, progressive: true })
.toFile(`${OUTPUT_DIR}/${filename}-${size}.jpg`);
}
}
// Process all images
fs.readdirSync(INPUT_DIR)
.filter(f => /\.(jpg|jpeg|png)$/i.test(f))
.forEach(f => optimizeImage(path.join(INPUT_DIR, f)));Run: node scripts/optimize-images.js
---
Framework Components
Next.js Image
import Image from 'next/image';
// Automatic optimization
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority // For LCP images
/>
// Fill container
<div style={{ position: 'relative', height: 400 }}>
<Image src="/bg.jpg" alt="Background" fill style={{ objectFit: 'cover' }} />
</div>Astro Image
---
import { Image } from 'astro:assets';
import hero from '../assets/hero.png';
---
<Image src={hero} alt="Hero" width={1200} height={600} />Vite imagetools
// vite.config.js
import { imagetools } from 'vite-imagetools';
export default {
plugins: [imagetools()]
};
// Usage in code
import heroSrcset from './hero.jpg?w=400;800;1200&format=webp&as=srcset';