Latency Budgeting in Frontend Systems
Latency Budgeting in Frontend Systems
Performance as a System Design Discipline
Latency budgeting transforms performance from a reactive concern ("this page is slow, fix it") into a proactive architectural constraint. By allocating specific time budgets to each phase of request processing—DNS, TLS, CDN, API, database, rendering, hydration—teams can make informed trade-offs, identify bottlenecks before they become problems, and design systems that meet performance targets by construction.
This article presents a comprehensive framework for latency budgeting that treats performance as a first-class architectural concern.
The Anatomy of Frontend Latency
┌─────────────────────────────────────────────────────────────────────────────┐
│ Request Lifecycle Latency Breakdown │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ User Click │
│ │ │
│ ▼ │
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌──────────┐ │
│ │ DNS │─▶│ TCP │─▶│ TLS │─▶│ Request│─▶│Response│─▶│ Download │ │
│ │ │ │Connect │ │Handshk │ │ Sent │ │ Start │ │ │ │
│ └────────┘ └────────┘ └────────┘ └────────┘ └────────┘ └──────────┘ │
│ 20ms 30ms 50ms 10ms 200ms 100ms │
│ │
│ NETWORK (410ms) │
│ │
│ ┌────────────────────────────────────────────────────────────────────────┐│
│ │ ││
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────────────┐ ││
│ │ │ Parse │─▶│ Style │─▶│ Layout │─▶│ Paint │─▶│ Hydration │ ││
│ │ │ HTML │ │ Calc │ │ │ │ │ │ │ ││
│ │ └────────┘ └────────┘ └────────┘ └────────┘ └────────────────┘ ││
│ │ 50ms 30ms 20ms 30ms 200ms ││
│ │ ││
│ │ BROWSER (330ms) ││
│ │ ││
│ └────────────────────────────────────────────────────────────────────────┘│
│ │
│ ┌────────────────────────────────────────────────────────────────────────┐│
│ │ JavaScript Execution ││
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────────────┐ ││
│ │ │ Parse │─▶│Compile │─▶│Execute │─▶│ React │─▶│ Interactive │ ││
│ │ │ JS │ │ JS │ │ Init │ │ Render │ │ │ ││
│ │ └────────┘ └────────┘ └────────┘ └────────┘ └────────────────┘ ││
│ │ 100ms 50ms 150ms 100ms ││
│ │ ││
│ │ JAVASCRIPT (400ms) ││
│ │ ││
│ └────────────────────────────────────────────────────────────────────────┘│
│ │
│ Total Time to Interactive: ~1140ms │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Budget Allocation Framework
Defining the Total Budget
// src/performance/budget.ts
interface LatencyBudget {
total: number; // Total time to interactive (ms)
phases: PhaseBudgets;
margins: {
safety: number; // Buffer for variance (%)
degradation: number; // Acceptable degradation threshold (%)
};
}
interface PhaseBudgets {
// Network phases
dns: number;
tcp: number;
tls: number;
ttfb: number; // Time to first byte (server processing)
download: number;
// Server phases (included in TTFB)
edgeCompute: number;
originRouting: number;
apiGateway: number;
database: number;
serialization: number;
// Browser phases
parsing: number;
styleCalculation: number;
layout: number;
paint: number;
compositing: number;
// JavaScript phases
jsParsing: number;
jsCompilation: number;
jsExecution: number;
hydration: number;
reactRender: number;
}
// Example budget for a P75 target of 2 seconds on 4G
const productionBudget: LatencyBudget = {
total: 2000,
phases: {
// Network: 600ms (30%)
dns: 20,
tcp: 30,
tls: 50,
ttfb: 400,
download: 100,
// Server (within TTFB): 400ms
edgeCompute: 50,
originRouting: 30,
apiGateway: 50,
database: 200,
serialization: 70,
// Browser: 400ms (20%)
parsing: 100,
styleCalculation: 80,
layout: 80,
paint: 100,
compositing: 40,
// JavaScript: 800ms (40%)
jsParsing: 150,
jsCompilation: 100,
jsExecution: 200,
hydration: 200,
reactRender: 150,
},
margins: {
safety: 10,
degradation: 25,
},
};
// Budget allocation by device tier
function getBudgetForDevice(deviceTier: 'high' | 'mid' | 'low'): LatencyBudget {
const multipliers = {
high: 1.0,
mid: 1.5,
low: 2.5,
};
const multiplier = multipliers[deviceTier];
return {
...productionBudget,
total: productionBudget.total * multiplier,
phases: Object.fromEntries(
Object.entries(productionBudget.phases).map(([key, value]) => [
key,
Math.round(value * multiplier),
])
) as PhaseBudgets,
};
}
// Budget allocation by network condition
function getBudgetForNetwork(
effectiveType: '4g' | '3g' | '2g' | 'slow-2g'
): LatencyBudget {
const networkMultipliers = {
'4g': 1.0,
'3g': 2.0,
'2g': 4.0,
'slow-2g': 8.0,
};
const multiplier = networkMultipliers[effectiveType];
// Network phases scale more than computation phases
return {
...productionBudget,
total: productionBudget.total * multiplier,
phases: {
...productionBudget.phases,
dns: productionBudget.phases.dns * multiplier,
tcp: productionBudget.phases.tcp * multiplier * 1.5,
tls: productionBudget.phases.tls * multiplier * 1.5,
ttfb: productionBudget.phases.ttfb * multiplier,
download: productionBudget.phases.download * multiplier * 2,
},
};
}
Budget Distribution Strategy
┌─────────────────────────────────────────────────────────────────────────────┐
│ Budget Distribution by Page Type │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Landing Page (LCP Focus) │
│ ───────────────────────── │
│ Total: 2500ms │
│ │
│ ├── Network (40%) 1000ms │
│ │ ├── DNS/TCP/TLS 100ms │
│ │ ├── TTFB 600ms ◄── Prioritize server response │
│ │ └── Download 300ms │
│ │ │
│ ├── Browser (35%) 875ms │
│ │ ├── Parse + Style 200ms │
│ │ ├── Layout + Paint 300ms ◄── Critical for LCP │
│ │ └── LCP Element 375ms │
│ │ │
│ └── JavaScript (25%) 625ms │
│ ├── Parse + Compile 200ms │
│ ├── Execution 225ms │
│ └── Hydration 200ms ◄── Defer non-critical │
│ │
│ ═══════════════════════════════════════════════════════════════════════ │
│ │
│ Dashboard (TTI Focus) │
│ ───────────────────── │
│ Total: 3000ms │
│ │
│ ├── Network (30%) 900ms │
│ │ ├── DNS/TCP/TLS 100ms │
│ │ ├── TTFB 500ms │
│ │ └── Download 300ms │
│ │ │
│ ├── Browser (20%) 600ms │
│ │ ├── Parse + Style 200ms │
│ │ └── Layout + Paint 400ms │
│ │ │
│ └── JavaScript (50%) 1500ms │
│ ├── Parse + Compile 400ms ◄── More JS for interactivity │
│ ├── Execution 500ms │
│ └── Hydration 600ms ◄── Full interactivity required │
│ │
│ ═══════════════════════════════════════════════════════════════════════ │
│ │
│ E-commerce PDP (INP Focus) │
│ ───────────────────────────── │
│ Total: 2000ms │
│ │
│ ├── Network (35%) 700ms │
│ │ ├── DNS/TCP/TLS 100ms │
│ │ ├── TTFB 400ms │
│ │ └── Download 200ms │
│ │ │
│ ├── Browser (30%) 600ms │
│ │ ├── Parse + Style 150ms │
│ │ └── Layout + Paint 450ms ◄── Image-heavy │
│ │ │
│ └── JavaScript (35%) 700ms │
│ ├── Parse + Compile 200ms │
│ ├── Execution 200ms │
│ └── Hydration 300ms ◄── Fast interactions critical │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Server-Side Budget Tracking
Server-Timing Headers
// server/middleware/timing.ts
import { NextRequest, NextResponse } from 'next/server';
interface TimingEntry {
name: string;
startTime: number;
duration?: number;
description?: string;
}
class ServerTiming {
private entries: TimingEntry[] = [];
private startTime: number;
constructor() {
this.startTime = performance.now();
}
start(name: string, description?: string): () => void {
const entry: TimingEntry = {
name,
startTime: performance.now(),
description,
};
this.entries.push(entry);
return () => {
entry.duration = performance.now() - entry.startTime;
};
}
measure(name: string, duration: number, description?: string): void {
this.entries.push({
name,
startTime: performance.now() - duration,
duration,
description,
});
}
toHeader(): string {
return this.entries
.filter(e => e.duration !== undefined)
.map(e => {
const parts = [e.name];
if (e.duration !== undefined) {
parts.push(`dur=${e.duration.toFixed(2)}`);
}
if (e.description) {
parts.push(`desc="${e.description}"`);
}
return parts.join(';');
})
.join(', ');
}
getTotalDuration(): number {
return performance.now() - this.startTime;
}
getEntry(name: string): TimingEntry | undefined {
return this.entries.find(e => e.name === name);
}
}
// Middleware for timing
export async function timingMiddleware(
request: NextRequest,
handler: (req: NextRequest, timing: ServerTiming) => Promise<NextResponse>
): Promise<NextResponse> {
const timing = new ServerTiming();
// Track overall request
const endTotal = timing.start('total', 'Total request time');
try {
const response = await handler(request, timing);
endTotal();
// Add Server-Timing header
response.headers.set('Server-Timing', timing.toHeader());
// Check budget violations
const budgetViolations = checkBudgetViolations(timing);
if (budgetViolations.length > 0) {
response.headers.set('X-Budget-Violations', JSON.stringify(budgetViolations));
}
return response;
} catch (error) {
endTotal();
throw error;
}
}
// Example usage in API route
export async function GET(request: NextRequest) {
return timingMiddleware(request, async (req, timing) => {
// Database query
const endDb = timing.start('db', 'Database query');
const users = await prisma.user.findMany({ take: 10 });
endDb();
// Cache check
const endCache = timing.start('cache', 'Cache lookup');
const cached = await redis.get('user-list');
endCache();
// Serialization
const endSerial = timing.start('serial', 'JSON serialization');
const body = JSON.stringify(users);
endSerial();
return NextResponse.json(users);
});
}
interface BudgetViolation {
phase: string;
actual: number;
budget: number;
severity: 'warning' | 'critical';
}
function checkBudgetViolations(timing: ServerTiming): BudgetViolation[] {
const violations: BudgetViolation[] = [];
const budgets: Record<string, { budget: number; critical: number }> = {
db: { budget: 200, critical: 500 },
cache: { budget: 10, critical: 50 },
serial: { budget: 50, critical: 100 },
total: { budget: 400, critical: 800 },
};
for (const [name, thresholds] of Object.entries(budgets)) {
const entry = timing.getEntry(name);
if (entry?.duration) {
if (entry.duration > thresholds.critical) {
violations.push({
phase: name,
actual: entry.duration,
budget: thresholds.budget,
severity: 'critical',
});
} else if (entry.duration > thresholds.budget) {
violations.push({
phase: name,
actual: entry.duration,
budget: thresholds.budget,
severity: 'warning',
});
}
}
}
return violations;
}
Edge Function Timing
// edge/timing-edge.ts (Cloudflare Worker)
interface EdgeTiming {
edgeReceive: number;
cacheCheck: number;
originFetch: number;
transform: number;
edgeSend: number;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const timing: EdgeTiming = {
edgeReceive: 0,
cacheCheck: 0,
originFetch: 0,
transform: 0,
edgeSend: 0,
};
const requestStart = Date.now();
// Edge receive time (time to receive full request)
timing.edgeReceive = Date.now() - requestStart;
// Cache check
const cacheStart = Date.now();
const cache = caches.default;
const cacheKey = new Request(request.url, request);
let response = await cache.match(cacheKey);
timing.cacheCheck = Date.now() - cacheStart;
if (!response) {
// Origin fetch
const originStart = Date.now();
response = await fetch(request);
timing.originFetch = Date.now() - originStart;
// Transform response
const transformStart = Date.now();
response = await transformResponse(response);
timing.transform = Date.now() - transformStart;
// Cache the response
ctx.waitUntil(cache.put(cacheKey, response.clone()));
}
// Calculate total edge time
timing.edgeSend = Date.now() - requestStart;
// Add timing headers
const headers = new Headers(response.headers);
headers.set('Server-Timing', formatEdgeTiming(timing));
headers.set('X-Edge-Cache', response ? 'HIT' : 'MISS');
headers.set('X-Edge-Location', request.cf?.colo as string);
return new Response(response.body, {
status: response.status,
headers,
});
},
};
function formatEdgeTiming(timing: EdgeTiming): string {
return Object.entries(timing)
.map(([name, duration]) => `${name};dur=${duration}`)
.join(', ');
}
async function transformResponse(response: Response): Promise<Response> {
// Example: inject timing beacon
if (response.headers.get('content-type')?.includes('text/html')) {
const html = await response.text();
const injected = html.replace(
'</head>',
`<script>window.__EDGE_TIMING__=${Date.now()}</script></head>`
);
return new Response(injected, {
status: response.status,
headers: response.headers,
});
}
return response;
}
Client-Side Budget Monitoring
Performance Observer Integration
// src/performance/monitor.ts
interface PerformanceMetrics {
// Navigation timing
dns: number;
tcp: number;
tls: number;
ttfb: number;
download: number;
// Paint timing
fcp: number;
lcp: number;
// Interaction timing
fid: number;
inp: number;
// Custom timing
hydration: number;
dataFetch: number;
// Server timing (from headers)
serverDb: number;
serverCache: number;
serverTotal: number;
}
interface BudgetConfig {
budgets: Record<keyof PerformanceMetrics, number>;
onViolation: (violations: BudgetViolation[]) => void;
sampleRate: number;
}
class PerformanceBudgetMonitor {
private metrics: Partial<PerformanceMetrics> = {};
private observers: PerformanceObserver[] = [];
private hydrationStart: number = 0;
constructor(private config: BudgetConfig) {
this.setupObservers();
}
private setupObservers() {
// Navigation timing
this.observeNavigation();
// Paint timing
this.observePaint();
// Long tasks
this.observeLongTasks();
// Layout shifts
this.observeLayoutShifts();
// First input
this.observeFirstInput();
// Event timing (INP)
this.observeEventTiming();
}
private observeNavigation() {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries() as PerformanceNavigationTiming[];
const nav = entries[0];
if (nav) {
this.metrics.dns = nav.domainLookupEnd - nav.domainLookupStart;
this.metrics.tcp = nav.connectEnd - nav.connectStart;
this.metrics.tls = nav.secureConnectionStart > 0
? nav.connectEnd - nav.secureConnectionStart
: 0;
this.metrics.ttfb = nav.responseStart - nav.requestStart;
this.metrics.download = nav.responseEnd - nav.responseStart;
// Parse server timing from response
this.parseServerTiming();
this.checkBudgets();
}
});
observer.observe({ type: 'navigation', buffered: true });
this.observers.push(observer);
}
private observePaint() {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-contentful-paint') {
this.metrics.fcp = entry.startTime;
}
}
this.checkBudgets();
});
observer.observe({ type: 'paint', buffered: true });
this.observers.push(observer);
// LCP observer
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
this.metrics.lcp = lastEntry.startTime;
this.checkBudgets();
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
this.observers.push(lcpObserver);
}
private observeFirstInput() {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries() as PerformanceEventTiming[];
const firstInput = entries[0];
if (firstInput) {
this.metrics.fid = firstInput.processingStart - firstInput.startTime;
this.checkBudgets();
}
});
observer.observe({ type: 'first-input', buffered: true });
this.observers.push(observer);
}
private observeEventTiming() {
let maxINP = 0;
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries() as PerformanceEventTiming[]) {
// INP considers interactions, not all events
if (['pointerdown', 'pointerup', 'keydown', 'keyup', 'click'].includes(entry.name)) {
const duration = entry.duration;
if (duration > maxINP) {
maxINP = duration;
this.metrics.inp = duration;
this.checkBudgets();
}
}
}
});
observer.observe({ type: 'event', buffered: true });
this.observers.push(observer);
}
private observeLongTasks() {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Log long tasks that may impact interactivity
if (entry.duration > 50) {
console.warn(`Long task detected: ${entry.duration}ms`, entry);
}
}
});
observer.observe({ type: 'longtask', buffered: true });
this.observers.push(observer);
}
private observeLayoutShifts() {
let clsValue = 0;
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const shift = entry as PerformanceEntry & { value: number; hadRecentInput: boolean };
if (!shift.hadRecentInput) {
clsValue += shift.value;
}
}
});
observer.observe({ type: 'layout-shift', buffered: true });
this.observers.push(observer);
}
private parseServerTiming() {
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
if (nav?.serverTiming) {
for (const timing of nav.serverTiming) {
switch (timing.name) {
case 'db':
this.metrics.serverDb = timing.duration;
break;
case 'cache':
this.metrics.serverCache = timing.duration;
break;
case 'total':
this.metrics.serverTotal = timing.duration;
break;
}
}
}
}
// Mark hydration timing
markHydrationStart() {
this.hydrationStart = performance.now();
}
markHydrationEnd() {
if (this.hydrationStart > 0) {
this.metrics.hydration = performance.now() - this.hydrationStart;
this.checkBudgets();
}
}
// Mark custom timing
markDataFetch(duration: number) {
this.metrics.dataFetch = duration;
this.checkBudgets();
}
private checkBudgets() {
// Sample to reduce overhead
if (Math.random() > this.config.sampleRate) return;
const violations: BudgetViolation[] = [];
for (const [metric, value] of Object.entries(this.metrics)) {
const budget = this.config.budgets[metric as keyof PerformanceMetrics];
if (budget && value && value > budget) {
violations.push({
phase: metric,
actual: value,
budget,
severity: value > budget * 2 ? 'critical' : 'warning',
});
}
}
if (violations.length > 0) {
this.config.onViolation(violations);
}
}
getMetrics(): Partial<PerformanceMetrics> {
return { ...this.metrics };
}
disconnect() {
for (const observer of this.observers) {
observer.disconnect();
}
}
}
// Initialize monitor
const budgetMonitor = new PerformanceBudgetMonitor({
budgets: {
dns: 50,
tcp: 50,
tls: 100,
ttfb: 600,
download: 200,
fcp: 1800,
lcp: 2500,
fid: 100,
inp: 200,
hydration: 300,
dataFetch: 500,
serverDb: 200,
serverCache: 20,
serverTotal: 400,
},
onViolation: (violations) => {
// Report to analytics
violations.forEach(v => {
console.warn(`Budget violation: ${v.phase} took ${v.actual}ms (budget: ${v.budget}ms)`);
// Send to monitoring
fetch('/api/telemetry/budget-violation', {
method: 'POST',
body: JSON.stringify(v),
keepalive: true,
});
});
},
sampleRate: 0.1, // 10% sampling
});
export { budgetMonitor, PerformanceBudgetMonitor };
Hydration Budget Strategies
Progressive Hydration with Budget
// src/hydration/budgeted-hydration.ts
interface HydrationTask {
id: string;
priority: 'critical' | 'high' | 'medium' | 'low';
estimatedCost: number; // ms
hydrate: () => Promise<void>;
}
interface HydrationSchedulerConfig {
frameBudget: number; // Max ms per frame (default: 10)
totalBudget: number; // Total hydration budget (default: 500)
idleTimeout: number; // requestIdleCallback timeout
}
class BudgetedHydrationScheduler {
private queue: HydrationTask[] = [];
private isProcessing = false;
private totalTimeSpent = 0;
private completedTasks: string[] = [];
constructor(private config: HydrationSchedulerConfig = {
frameBudget: 10,
totalBudget: 500,
idleTimeout: 2000,
}) {}
schedule(task: HydrationTask): void {
// Insert by priority
const insertIndex = this.queue.findIndex(t =>
this.getPriorityValue(t.priority) < this.getPriorityValue(task.priority)
);
if (insertIndex === -1) {
this.queue.push(task);
} else {
this.queue.splice(insertIndex, 0, task);
}
this.startProcessing();
}
private getPriorityValue(priority: HydrationTask['priority']): number {
const values = { critical: 4, high: 3, medium: 2, low: 1 };
return values[priority];
}
private startProcessing(): void {
if (this.isProcessing) return;
this.isProcessing = true;
this.processQueue();
}
private processQueue(): void {
if (this.queue.length === 0) {
this.isProcessing = false;
return;
}
// Use requestIdleCallback for non-critical tasks
const nextTask = this.queue[0];
if (nextTask.priority === 'critical' || nextTask.priority === 'high') {
// Process immediately but yield to main thread
this.processWithYield();
} else {
// Use idle callback
requestIdleCallback(
(deadline) => this.processInIdleTime(deadline),
{ timeout: this.config.idleTimeout }
);
}
}
private async processWithYield(): Promise<void> {
while (this.queue.length > 0) {
const task = this.queue[0];
// Check if we've exceeded total budget
if (this.totalTimeSpent >= this.config.totalBudget) {
console.warn('Hydration budget exceeded, deferring remaining tasks');
this.deferRemainingTasks();
return;
}
const start = performance.now();
try {
await task.hydrate();
const duration = performance.now() - start;
this.totalTimeSpent += duration;
this.completedTasks.push(task.id);
// Report timing
this.reportTaskTiming(task, duration);
} catch (error) {
console.error(`Hydration failed for ${task.id}:`, error);
}
this.queue.shift();
// Yield to main thread every frame budget
if (performance.now() - start > this.config.frameBudget) {
await this.yieldToMainThread();
}
}
this.isProcessing = false;
}
private processInIdleTime(deadline: IdleDeadline): void {
while (
this.queue.length > 0 &&
deadline.timeRemaining() > 0 &&
this.totalTimeSpent < this.config.totalBudget
) {
const task = this.queue.shift()!;
const start = performance.now();
try {
// Synchronous hydration for idle time
// Note: This is simplified; real implementation would handle async
task.hydrate();
const duration = performance.now() - start;
this.totalTimeSpent += duration;
this.completedTasks.push(task.id);
this.reportTaskTiming(task, duration);
} catch (error) {
console.error(`Hydration failed for ${task.id}:`, error);
}
}
if (this.queue.length > 0) {
requestIdleCallback(
(deadline) => this.processInIdleTime(deadline),
{ timeout: this.config.idleTimeout }
);
} else {
this.isProcessing = false;
}
}
private yieldToMainThread(): Promise<void> {
return new Promise(resolve => {
// Use scheduler.yield() if available, otherwise setTimeout
if ('scheduler' in globalThis && 'yield' in (globalThis as any).scheduler) {
(globalThis as any).scheduler.yield().then(resolve);
} else {
setTimeout(resolve, 0);
}
});
}
private deferRemainingTasks(): void {
// Move remaining tasks to be hydrated on interaction
for (const task of this.queue) {
this.setupInteractionHydration(task);
}
this.queue = [];
this.isProcessing = false;
}
private setupInteractionHydration(task: HydrationTask): void {
const element = document.getElementById(task.id);
if (!element) return;
const hydrate = () => {
task.hydrate();
element.removeEventListener('click', hydrate);
element.removeEventListener('focus', hydrate);
element.removeEventListener('mouseenter', hydrate);
};
element.addEventListener('click', hydrate, { once: true });
element.addEventListener('focus', hydrate, { once: true });
element.addEventListener('mouseenter', hydrate, { once: true });
}
private reportTaskTiming(task: HydrationTask, duration: number): void {
// Report to performance monitoring
performance.measure(`hydration:${task.id}`, {
start: performance.now() - duration,
duration,
});
}
getStats(): {
completed: number;
pending: number;
totalTime: number;
budgetRemaining: number;
} {
return {
completed: this.completedTasks.length,
pending: this.queue.length,
totalTime: this.totalTimeSpent,
budgetRemaining: Math.max(0, this.config.totalBudget - this.totalTimeSpent),
};
}
}
export const hydrationScheduler = new BudgetedHydrationScheduler();
// React integration
function useBudgetedHydration(
id: string,
priority: HydrationTask['priority'] = 'medium'
) {
const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => {
const estimatedCost = estimateHydrationCost(id);
hydrationScheduler.schedule({
id,
priority,
estimatedCost,
hydrate: async () => {
setIsHydrated(true);
},
});
}, [id, priority]);
return isHydrated;
}
function estimateHydrationCost(componentId: string): number {
// Estimate based on DOM complexity
const element = document.getElementById(componentId);
if (!element) return 50;
const nodeCount = element.querySelectorAll('*').length;
const hasEventHandlers = element.querySelectorAll('[data-event]').length;
return Math.min(500, nodeCount * 2 + hasEventHandlers * 10);
}
export { useBudgetedHydration, BudgetedHydrationScheduler };
Budget Visualization and Debugging
Performance Budget Dashboard
// src/performance/budget-dashboard.ts
interface BudgetDashboardData {
timestamp: number;
metrics: Record<string, number>;
budgets: Record<string, number>;
violations: BudgetViolation[];
percentiles: Record<string, { p50: number; p75: number; p95: number; p99: number }>;
}
class BudgetDashboard {
private canvas: HTMLCanvasElement | null = null;
private ctx: CanvasRenderingContext2D | null = null;
private data: BudgetDashboardData | null = null;
mount(container: HTMLElement): void {
this.canvas = document.createElement('canvas');
this.canvas.width = 800;
this.canvas.height = 600;
this.canvas.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
background: rgba(0, 0, 0, 0.9);
border-radius: 8px;
z-index: 99999;
`;
container.appendChild(this.canvas);
this.ctx = this.canvas.getContext('2d');
this.startMonitoring();
}
private startMonitoring(): void {
setInterval(() => this.updateData(), 1000);
}
private updateData(): void {
const metrics = budgetMonitor.getMetrics();
const budgets = productionBudget.phases;
this.data = {
timestamp: Date.now(),
metrics: metrics as Record<string, number>,
budgets: budgets as unknown as Record<string, number>,
violations: this.checkViolations(metrics, budgets),
percentiles: this.calculatePercentiles(),
};
this.render();
}
private checkViolations(
metrics: Record<string, number>,
budgets: Record<string, number>
): BudgetViolation[] {
const violations: BudgetViolation[] = [];
for (const [key, value] of Object.entries(metrics)) {
const budget = budgets[key];
if (budget && value > budget) {
violations.push({
phase: key,
actual: value,
budget,
severity: value > budget * 2 ? 'critical' : 'warning',
});
}
}
return violations;
}
private calculatePercentiles(): Record<string, { p50: number; p75: number; p95: number; p99: number }> {
// This would aggregate from stored metrics
return {};
}
private render(): void {
if (!this.ctx || !this.data) return;
const ctx = this.ctx;
const { metrics, budgets, violations } = this.data;
// Clear canvas
ctx.fillStyle = 'rgba(0, 0, 0, 0.9)';
ctx.fillRect(0, 0, 800, 600);
// Title
ctx.fillStyle = '#fff';
ctx.font = 'bold 18px monospace';
ctx.fillText('Performance Budget Dashboard', 20, 30);
// Draw waterfall
this.drawWaterfall(ctx, metrics, budgets, 20, 60, 760, 200);
// Draw budget bars
this.drawBudgetBars(ctx, metrics, budgets, 20, 280, 760, 150);
// Draw violations
this.drawViolations(ctx, violations, 20, 450, 760, 130);
}
private drawWaterfall(
ctx: CanvasRenderingContext2D,
metrics: Record<string, number>,
budgets: Record<string, number>,
x: number,
y: number,
width: number,
height: number
): void {
const phases = ['dns', 'tcp', 'tls', 'ttfb', 'download', 'fcp', 'lcp', 'hydration'];
const barHeight = height / phases.length - 5;
const maxTime = Math.max(...Object.values(metrics), 3000);
ctx.fillStyle = '#333';
ctx.fillRect(x, y, width, height);
phases.forEach((phase, i) => {
const value = metrics[phase] || 0;
const budget = budgets[phase] || 0;
const barY = y + i * (barHeight + 5);
const barWidth = (value / maxTime) * width;
const budgetX = x + (budget / maxTime) * width;
// Draw bar
ctx.fillStyle = value > budget ? '#e74c3c' : '#2ecc71';
ctx.fillRect(x, barY, barWidth, barHeight);
// Draw budget line
ctx.strokeStyle = '#f39c12';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(budgetX, barY);
ctx.lineTo(budgetX, barY + barHeight);
ctx.stroke();
// Draw label
ctx.fillStyle = '#fff';
ctx.font = '12px monospace';
ctx.fillText(`${phase}: ${value.toFixed(0)}ms`, x + 5, barY + barHeight - 5);
});
}
private drawBudgetBars(
ctx: CanvasRenderingContext2D,
metrics: Record<string, number>,
budgets: Record<string, number>,
x: number,
y: number,
width: number,
height: number
): void {
ctx.fillStyle = '#fff';
ctx.font = 'bold 14px monospace';
ctx.fillText('Budget Utilization', x, y - 10);
const categories = [
{ name: 'Network', phases: ['dns', 'tcp', 'tls', 'ttfb', 'download'] },
{ name: 'Browser', phases: ['fcp', 'lcp'] },
{ name: 'JavaScript', phases: ['hydration'] },
];
const barWidth = (width - 40) / categories.length;
categories.forEach((category, i) => {
const catX = x + i * (barWidth + 20);
const totalBudget = category.phases.reduce((sum, p) => sum + (budgets[p] || 0), 0);
const totalActual = category.phases.reduce((sum, p) => sum + (metrics[p] || 0), 0);
const utilization = totalBudget > 0 ? (totalActual / totalBudget) * 100 : 0;
// Background
ctx.fillStyle = '#333';
ctx.fillRect(catX, y, barWidth, height - 30);
// Utilization bar
const barHeight = Math.min((utilization / 100) * (height - 30), height - 30);
ctx.fillStyle = utilization > 100 ? '#e74c3c' : utilization > 80 ? '#f39c12' : '#2ecc71';
ctx.fillRect(catX, y + height - 30 - barHeight, barWidth, barHeight);
// Label
ctx.fillStyle = '#fff';
ctx.font = '11px monospace';
ctx.fillText(category.name, catX, y + height - 10);
ctx.fillText(`${utilization.toFixed(0)}%`, catX, y + height + 5);
});
}
private drawViolations(
ctx: CanvasRenderingContext2D,
violations: BudgetViolation[],
x: number,
y: number,
width: number,
height: number
): void {
ctx.fillStyle = '#fff';
ctx.font = 'bold 14px monospace';
ctx.fillText(`Violations (${violations.length})`, x, y - 10);
if (violations.length === 0) {
ctx.fillStyle = '#2ecc71';
ctx.font = '14px monospace';
ctx.fillText('All metrics within budget', x, y + 20);
return;
}
violations.slice(0, 5).forEach((v, i) => {
const vY = y + i * 22;
ctx.fillStyle = v.severity === 'critical' ? '#e74c3c' : '#f39c12';
ctx.font = '12px monospace';
ctx.fillText(
`${v.severity.toUpperCase()}: ${v.phase} - ${v.actual.toFixed(0)}ms (budget: ${v.budget}ms)`,
x,
vY + 15
);
});
}
unmount(): void {
this.canvas?.remove();
}
}
// Enable in development
if (process.env.NODE_ENV === 'development') {
const dashboard = new BudgetDashboard();
dashboard.mount(document.body);
}
export { BudgetDashboard };
Key Takeaways
-
Budgets are architectural constraints: Define performance targets before implementation, not after
-
Allocate by phase, not total: Break down the total budget into DNS, TLS, TTFB, rendering, hydration components
-
Device and network tiers need different budgets: A 3G user on a low-end phone has a different reality than fiber + MacBook
-
Server-Timing headers enable visibility: Propagate backend timing to the client for full-stack analysis
-
Hydration often dominates TTI: Progressive and budgeted hydration prevents JavaScript from consuming the entire budget
-
Monitor actual vs budget continuously: Performance Observer APIs enable real-time budget tracking
-
Violations should trigger alerts: Integrate budget monitoring with your incident response system
-
Yield to main thread: Long tasks that exceed frame budget harm interactivity regardless of total time
-
Sample strategically: 100% monitoring is expensive; sample more from slow sessions
-
Make budgets visible: Dashboards and dev tools help teams understand their performance impact
Performance budgets transform vague performance goals into concrete, measurable architectural constraints that guide design decisions throughout development.
What did you think?