Web performance patterns — lazy loading, bundle optimization, query optimization, compression, and resource management
81
77%
Does it follow best practices?
Impact
97%
3.23xAverage score across 3 eval scenarios
Passed
No known issues
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.
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.
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]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);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)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') },
];Every <img> element MUST include performance attributes. Never use bare <img src="..."> without optimization.
<!-- 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.
<!-- 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"
/>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" />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>// 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';// 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));// 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);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;
}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.
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();
};
}, []);See the react-patterns tile for detailed memoization guidance. Key points:
React.memo on child components that receive stable props and re-render oftenuseCallback ONLY for functions passed to React.memo childrenuseMemo for expensive computations or object/array props passed to memoized childrenWhen building pages, be aware of Core Web Vitals:
<link rel="preload">.width/height on images. Reserve space for dynamic content. Avoid inserting content above existing content.startTransition for non-urgent React updates.React.lazy / dynamic imports<Suspense> fallback wrapping lazy-loaded routesloading="lazy"width and height to prevent CLS