CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/web-performance

Web performance patterns — lazy loading, bundle optimization, query optimization, compression, and resource management

81

3.23x
Quality

77%

Does it follow best practices?

Impact

97%

3.23x

Average score across 3 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files
name:
web-performance
description:
Web performance patterns to ALWAYS apply in production apps — lazy loading, code splitting, image optimization, database query optimization, response compression, pagination, and resource cleanup. Apply these proactively without being asked.
keywords:
web performance, lazy loading, code splitting, React.lazy, dynamic import, image optimization, srcset, loading lazy, bundle size, tree shaking, barrel exports, React.memo, useMemo, useCallback, web vitals, LCP, CLS, INP, N+1 query, eager loading, connection pooling, gzip, brotli, compression, pagination, caching, async, resource cleanup
license:
MIT

Web Performance Patterns — Always Apply

These patterns must be applied proactively in every production app you build, even when the user does not ask for them. They are not optional extras — they are the baseline for performant web applications.


1. API Pagination — Never Return Unbounded Arrays

Every API endpoint that returns a list MUST support pagination. Never return all rows from a database table without a limit. This applies to every list endpoint — products, orders, users, comments, logs, anything.

// WRONG — returns every row, will crash or timeout on large tables
app.get('/api/products', async (req, res) => {
  const products = await db.query('SELECT * FROM products');
  res.json(products);
});

// RIGHT — paginated with limit and offset, includes total count
app.get('/api/products', async (req, res) => {
  const page = parseInt(req.query.page as string) || 1;
  const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
  const offset = (page - 1) * limit;

  const [products, [{ total }]] = await Promise.all([
    db.query('SELECT * FROM products ORDER BY id LIMIT ? OFFSET ?', [limit, offset]),
    db.query('SELECT COUNT(*) as total FROM products'),
  ]);

  res.json({
    data: products,
    pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
  });
});

Always cap the maximum limit value (e.g. 100) to prevent clients from requesting unbounded results.


2. N+1 Query Prevention — Always Eager Load Related Data

When loading a list of records with related data, ALWAYS fetch related records in a single query using JOINs or batch loading. Never query inside a loop.

// WRONG — N+1: one query per order to get line items
app.get('/api/orders', async (req, res) => {
  const orders = await db.query('SELECT * FROM orders LIMIT 20');
  for (const order of orders) {
    order.items = await db.query('SELECT * FROM order_items WHERE order_id = ?', [order.id]);
  }
  res.json(orders);
});

// RIGHT — single JOIN query or batch load
app.get('/api/orders', async (req, res) => {
  const rows = await db.query(`
    SELECT o.*, oi.id as item_id, oi.product_name, oi.quantity, oi.price
    FROM orders o
    LEFT JOIN order_items oi ON oi.order_id = o.id
    ORDER BY o.id
    LIMIT 20
  `);

  const orders = groupByOrder(rows);
  res.json(orders);
});

// RIGHT — alternative: batch load with IN clause
app.get('/api/orders', async (req, res) => {
  const orders = await db.query('SELECT * FROM orders LIMIT 20');
  const orderIds = orders.map(o => o.id);
  const items = await db.query(
    `SELECT * FROM order_items WHERE order_id IN (${orderIds.map(() => '?').join(',')})`,
    orderIds
  );

  const itemsByOrder = Object.groupBy(items, item => item.order_id);
  for (const order of orders) {
    order.items = itemsByOrder[order.id] || [];
  }
  res.json(orders);
});

With ORMs, use eager loading:

// Prisma — use include
const orders = await prisma.order.findMany({
  include: { items: true },
  take: 20,
});

// Sequelize — use include
const orders = await Order.findAll({
  include: [OrderItem],
  limit: 20,
});

// Django — use select_related / prefetch_related
orders = Order.objects.prefetch_related('items')[:20]

Indexes on Foreign Keys

Always add database indexes on foreign key columns used in JOINs and WHERE clauses:

CREATE INDEX idx_order_items_order_id ON order_items(order_id);
CREATE INDEX idx_products_category_id ON products(category_id);

3. Response Compression — Always Enable

Every production API server MUST compress responses. Uncompressed JSON payloads waste bandwidth and slow page loads. Add compression middleware as one of the first middleware in the stack.

// Node.js (Express)
import compression from 'compression';
app.use(compression()); // gzip by default, brotli if client supports it

// Node.js (Fastify)
await fastify.register(import('@fastify/compress'));

// Node.js (Hono)
import { compress } from 'hono/compress';
app.use(compress());
# FastAPI
from fastapi.middleware.gzip import GZipMiddleware
app.add_middleware(GZipMiddleware, minimum_size=1000)

# Django
MIDDLEWARE = [
    'django.middleware.gzip.GZipMiddleware',
    # ... other middleware
]

# Flask
from flask_compress import Compress
Compress(app)

4. Route-Based Code Splitting — Always Split by Route

Every multi-page React/Vue/Svelte app MUST use route-based code splitting. Load only the code needed for the current page. Never import all page components at the top of the router.

// WRONG — all pages bundled together, loaded upfront
import HomePage from './pages/HomePage';
import ProductPage from './pages/ProductPage';
import AdminDashboard from './pages/AdminDashboard';
import OrderHistory from './pages/OrderHistory';

// RIGHT — lazy load each route
import { lazy, Suspense } from 'react';

const HomePage = lazy(() => import('./pages/HomePage'));
const ProductPage = lazy(() => import('./pages/ProductPage'));
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));
const OrderHistory = lazy(() => import('./pages/OrderHistory'));

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/" element={<HomePage />} />
        <Route path="/products/:id" element={<ProductPage />} />
        <Route path="/admin" element={<AdminDashboard />} />
        <Route path="/orders" element={<OrderHistory />} />
      </Routes>
    </Suspense>
  );
}

For Next.js, pages are automatically code-split. Use dynamic for heavy components within a page:

import dynamic from 'next/dynamic';
const HeavyChart = dynamic(() => import('../components/HeavyChart'), {
  loading: () => <p>Loading chart...</p>,
});

Vue:

const routes = [
  { path: '/', component: () => import('./pages/HomePage.vue') },
  { path: '/admin', component: () => import('./pages/AdminDashboard.vue') },
];

5. Image Optimization — Always Apply

Every <img> element MUST include performance attributes. Never use bare <img src="..."> without optimization.

loading="lazy" on below-the-fold images

<!-- WRONG -->
<img src="/images/product-photo.jpg" alt="Product" />

<!-- RIGHT — lazy load images not in initial viewport -->
<img src="/images/product-photo.jpg" alt="Product" loading="lazy" />

Do NOT add loading="lazy" to the largest image above the fold (hero image, primary product image) — that is your LCP element and should load eagerly.

Responsive images with srcset

<!-- RIGHT — browser picks the best size -->
<img
  src="/images/product-400.jpg"
  srcset="/images/product-400.jpg 400w, /images/product-800.jpg 800w, /images/product-1200.jpg 1200w"
  sizes="(max-width: 600px) 400px, (max-width: 1024px) 800px, 1200px"
  alt="Product photo"
  loading="lazy"
  width="800"
  height="600"
/>

Always include width and height

Set explicit width and height attributes (or CSS aspect-ratio) to prevent Cumulative Layout Shift (CLS):

<!-- WRONG — causes layout shift as image loads -->
<img src="/photo.jpg" alt="Photo" loading="lazy" />

<!-- RIGHT — browser reserves space before image loads -->
<img src="/photo.jpg" alt="Photo" loading="lazy" width="800" height="600" />

Use modern image formats

Prefer WebP or AVIF over JPEG/PNG. Use <picture> for format fallbacks:

<picture>
  <source srcset="/photo.avif" type="image/avif" />
  <source srcset="/photo.webp" type="image/webp" />
  <img src="/photo.jpg" alt="Photo" loading="lazy" width="800" height="600" />
</picture>

6. Bundle Size Awareness — Avoid Bloat

Avoid barrel exports in large projects

// WRONG — importing from barrel re-exports the entire module tree
import { Button } from './components';

// RIGHT — import directly from the component file
import { Button } from './components/Button';

Use tree-shakeable imports

// WRONG — imports entire lodash (70KB+)
import _ from 'lodash';
const sorted = _.sortBy(items, 'name');

// RIGHT — import only the function you need
import sortBy from 'lodash/sortBy';
const sorted = sortBy(items, 'name');

// BETTER — use native JS
const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));

Avoid importing heavy libraries for simple tasks

// WRONG — importing moment.js (300KB) for basic date formatting
import moment from 'moment';
const formatted = moment(date).format('YYYY-MM-DD');

// RIGHT — use native Intl API or lightweight alternative
const formatted = new Intl.DateTimeFormat('en-CA').format(date);

7. Application-Level Caching

Cache expensive computations and frequently-accessed data at the application layer. (HTTP-level caching headers are covered by the http-caching-strategy tile.)

// In-memory cache for expensive aggregations
const statsCache = new Map<string, { data: any; expiresAt: number }>();

async function getDashboardStats() {
  const cached = statsCache.get('dashboard');
  if (cached && Date.now() < cached.expiresAt) return cached.data;

  const stats = await computeExpensiveStats();
  statsCache.set('dashboard', { data: stats, expiresAt: Date.now() + 60_000 });
  return stats;
}

When to cache

  • Database aggregation results that don't change every request
  • External API responses with rate limits
  • Computed values derived from rarely-changing data

When NOT to cache

  • User-specific data that changes per request
  • Data that must be real-time consistent
  • Small, fast queries (caching adds complexity for no gain)

8. Async Operations for Non-Blocking Work

Do not block request handlers with long-running operations. Offload work that the client does not need to wait for.

// WRONG — client waits for email to send before getting response
app.post('/api/orders', async (req, res) => {
  const order = await createOrder(req.body);
  await sendConfirmationEmail(order);  // slow, blocks response
  await updateInventory(order);        // slow, blocks response
  res.json(order);
});

// RIGHT — respond immediately, handle side effects asynchronously
app.post('/api/orders', async (req, res) => {
  const order = await createOrder(req.body);

  // Fire and forget — don't await
  sendConfirmationEmail(order).catch(err => logger.error('Email failed', err));
  updateInventory(order).catch(err => logger.error('Inventory update failed', err));

  res.json(order);
});

For production systems, use a job queue (Bull, BullMQ, Celery) instead of fire-and-forget for critical async work.


9. Resource Cleanup — Always Clean Up

Always close database connections, clear intervals, and abort pending requests when they are no longer needed.

// Backend — close connections on shutdown
process.on('SIGTERM', async () => {
  await db.end();
  server.close();
});

// React — clear intervals and abort fetches
useEffect(() => {
  const interval = setInterval(pollData, 5000);
  const controller = new AbortController();

  fetch('/api/data', { signal: controller.signal })
    .then(res => res.json())
    .then(data => setData(data))
    .catch(err => { if (err.name !== 'AbortError') setError(err); });

  return () => {
    clearInterval(interval);
    controller.abort();
  };
}, []);

10. Minimizing Re-renders — Apply When It Matters

See the react-patterns tile for detailed memoization guidance. Key points:

  • Use React.memo on child components that receive stable props and re-render often
  • Use useCallback ONLY for functions passed to React.memo children
  • Use useMemo for expensive computations or object/array props passed to memoized children
  • Do NOT memoize everything — only where profiling shows a problem or where the pattern clearly applies (large lists, frequent parent re-renders)

Web Vitals Awareness

When building pages, be aware of Core Web Vitals:

  • LCP (Largest Contentful Paint): The hero image or main heading should load fast. Do NOT lazy-load LCP elements. Preload critical images with <link rel="preload">.
  • CLS (Cumulative Layout Shift): Always set width/height on images. Reserve space for dynamic content. Avoid inserting content above existing content.
  • INP (Interaction to Next Paint): Keep event handlers fast. Offload heavy computation to Web Workers. Use startTransition for non-urgent React updates.

Checklist — Apply to Every Production App

API / Backend

  • Every list endpoint is paginated with a capped maximum limit
  • Related data loaded via JOINs or batch queries, never queries in loops (N+1)
  • Foreign key columns have database indexes
  • Response compression middleware enabled (gzip/brotli)
  • Long-running side effects handled asynchronously, not blocking responses
  • Database connections closed on server shutdown
  • Application-level caching for expensive, rarely-changing queries

Frontend

  • Routes lazy-loaded with React.lazy / dynamic imports
  • <Suspense> fallback wrapping lazy-loaded routes
  • Images below the fold use loading="lazy"
  • Images have explicit width and height to prevent CLS
  • LCP element (hero image) is NOT lazy-loaded
  • Direct imports instead of barrel exports for large component libraries
  • Tree-shakeable imports (no full-library imports for single functions)
  • Intervals and fetch requests cleaned up in useEffect return

References

  • web.dev — Core Web Vitals
  • React.lazy — Code Splitting
  • MDN — Lazy loading images
  • MDN — Responsive images

Verifiers

  • api-pagination — API endpoints returning lists must be paginated
  • n-plus-one-prevention — Related data loaded without N+1 queries
  • response-compression — Response compression middleware enabled
  • lazy-loading-routes — Route-based code splitting with lazy loading
  • image-optimization — Images optimized with lazy loading and dimensions
Workspace
tessl-labs
Visibility
Public
Created
Last updated
Publish Source
CLI
Badge
tessl-labs/web-performance badge