JavaScript Performance Optimization

Techniques and Strategies for Building Fast Web Applications

Introduction to JavaScript Performance

In our previous lectures, we explored performance testing tools and load testing with Artillery. Now we'll shift our focus to optimizing the performance of JavaScript applications. As applications grow more complex, performance becomes a critical factor in user experience and business success.

Performance optimization isn't just about making your code run faster—it's about creating better experiences for your users. Slow applications frustrate users, increase bounce rates, and can negatively impact business metrics. According to research, users typically abandon sites that take more than 3 seconds to load, making performance optimization not just a technical concern but a business imperative.

The Performance Optimization Process

flowchart TD A[Measure Performance] --> B[Identify Bottlenecks] B --> C[Apply Optimization Techniques] C --> D[Test Results] D --> A

Why Performance Matters

Before diving into specific optimization techniques, let's understand why JavaScript performance matters for modern web applications:

Real-World Performance Impact

  • Pinterest reduced perceived wait times by 40% and increased search engine traffic and sign-ups by 15%
  • The BBC found they lost 10% of users for every additional second their site took to load
  • Walmart saw a 2% conversion increase for every 1 second of improvement in page load time

Measuring Performance

Before optimizing, you need to know what to optimize. Effective performance optimization starts with accurate measurement.

Browser Development Tools

Web Vitals and Core Metrics

Modern performance optimization focuses on user-centric metrics that measure real user experience:

Metric Description Target
Largest Contentful Paint (LCP) Time to render the largest content element < 2.5s
First Input Delay (FID) Time from first user interaction to response < 100ms
Cumulative Layout Shift (CLS) Unexpected layout shifts during page load < 0.1
First Contentful Paint (FCP) Time to render first DOM content < 1.8s
Time to Interactive (TTI) Time until page is fully interactive < 3.8s
Total Blocking Time (TBT) Sum of time main thread is blocked < 200ms

JavaScript Performance APIs

JavaScript provides built-in APIs for measuring performance within your application:


// Mark the beginning of a performance measurement
performance.mark('myFunction-start');

// Run function to measure
myFunction();

// Mark the end of the performance measurement
performance.mark('myFunction-end');

// Create a performance measure
performance.measure(
    'myFunction', 
    'myFunction-start', 
    'myFunction-end'
);

// Get all measurements
const measurements = performance.getEntriesByType('measure');
console.log(measurements);

// Clear marks and measures
performance.clearMarks();
performance.clearMeasures();
                

User-centric Measurement

Don't forget to collect real-world performance data from actual users:

Optimizing Load Time

The first impression users have of your application is how quickly it loads. Here are key techniques to optimize load time:

JavaScript Loading Strategies

Async and Defer Attributes

<!-- Regular script: blocks parsing -->
<script src="regular.js"></script>

<!-- Async: downloads in parallel, executes as soon as available -->
<script src="analytics.js" async></script>

<!-- Defer: downloads in parallel, executes after parsing -->
<script src="app.js" defer></script>
                

Script Loading Comparison

gantt title Script Loading Timeline dateFormat s axisFormat %S section Regular Parse HTML :a1, 0, 1s Download JS :a2, after a1, 2s Execute JS :a3, after a2, 1s Parse Rest :a4, after a3, 1s section Async Parse HTML :b1, 0, 3s Download JS :b2, 0, 2s Execute JS :b3, after b2, 1s section Defer Parse HTML :c1, 0, 3s Download JS :c2, 0, 2s Execute JS :c3, after c1, 1s

Code Splitting

Modern bundlers like Webpack, Rollup, or Vite allow you to split your JavaScript into smaller chunks that can be loaded on demand:

Dynamic Import Example

// Instead of importing everything upfront
// import { heavyFunction } from './heavy-module';

// Use dynamic import when needed
button.addEventListener('click', async () => {
    // This code only loads when the button is clicked
    const { heavyFunction } = await import('./heavy-module');
    heavyFunction();
});
                

Tree Shaking

Tree shaking removes unused code from your bundles. Modern bundlers support this automatically for ES modules:

Before Tree Shaking

// utils.js
export function add(a, b) {
    return a + b;
}

export function subtract(a, b) {
    return a - b;
}

// app.js
import { add } from './utils';
console.log(add(2, 3)); // Only using add
                

After tree shaking, the subtract function is removed from the final bundle.

Compression and Minification

Reduce file size with these essential techniques:

Example Webpack Configuration for Minification

// webpack.config.js
const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  // ... other configuration
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          compress: {
            drop_console: true, // Remove console.log in production
          },
          output: {
            comments: false, // Remove comments
          },
        },
      }),
    ],
  },
};
                

Caching Strategies

Properly configured caching can dramatically improve load times for returning visitors:

Content Hashing in Webpack

// webpack.config.js
module.exports = {
  output: {
    filename: '[name].[contenthash].js',
    path: path.resolve(__dirname, 'dist'),
  },
};
                

Preloading and Prefetching

Give browsers hints about resources they'll need soon:


<!-- Preload critical resources -->
<link rel="preload" href="critical.js" as="script">
<link rel="preload" href="critical.css" as="style">

<!-- Prefetch resources that might be needed later -->
<link rel="prefetch" href="non-critical.js">
                

Optimizing Runtime Performance

Once your application has loaded, runtime performance becomes crucial for smooth interactions. Here are key techniques to optimize runtime performance:

Efficient DOM Manipulation

The DOM is often the bottleneck in web applications. Minimize DOM operations with these techniques:

Use DocumentFragment for Batch Updates

// Inefficient: Multiple DOM updates
for (let i = 0; i < 1000; i++) {
    const listItem = document.createElement('li');
    listItem.textContent = `Item ${i}`;
    document.getElementById('myList').appendChild(listItem);
}

// Efficient: Batch DOM updates with DocumentFragment
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
    const listItem = document.createElement('li');
    listItem.textContent = `Item ${i}`;
    fragment.appendChild(listItem);
}
document.getElementById('myList').appendChild(fragment);
                
Minimize Reflows and Repaints

// Inefficient: Causes multiple reflows
const element = document.getElementById('myElement');
element.style.width = '100px';
element.style.height = '100px';
element.style.backgroundColor = 'red';

// Efficient: Batches style changes
const element = document.getElementById('myElement');
element.style.cssText = 'width: 100px; height: 100px; background-color: red;';

// Or use classes
element.classList.add('styled-element');
                

Event Handling Optimization

Poorly implemented event handling can significantly impact performance:

Event Delegation

// Inefficient: Attaching many event listeners
document.querySelectorAll('.button').forEach(button => {
    button.addEventListener('click', handleClick);
});

// Efficient: Using event delegation
document.querySelector('.button-container').addEventListener('click', (event) => {
    if (event.target.matches('.button')) {
        handleClick(event);
    }
});
                
Debouncing and Throttling

// Debounce: Only execute after a period of inactivity
function debounce(func, wait) {
    let timeout;
    return function(...args) {
        clearTimeout(timeout);
        timeout = setTimeout(() => func.apply(this, args), wait);
    };
}

// Throttle: Execute at most once per specified period
function throttle(func, limit) {
    let inThrottle;
    return function(...args) {
        if (!inThrottle) {
            func.apply(this, args);
            inThrottle = true;
            setTimeout(() => inThrottle = false, limit);
        }
    };
}

// Usage
const debouncedSearch = debounce(searchFunction, 300);
const throttledScroll = throttle(scrollHandler, 100);

window.addEventListener('scroll', throttledScroll);
searchInput.addEventListener('input', debouncedSearch);
                

Memory Management

JavaScript's garbage collection is automatic, but poor coding patterns can still cause memory leaks:

Common Memory Leak Patterns

// Leak 1: Forgetting to remove event listeners
function setupEvents() {
    const button = document.getElementById('myButton');
    button.addEventListener('click', handleClick);
}

// Fix: Remove event listeners when no longer needed
function cleanup() {
    const button = document.getElementById('myButton');
    button.removeEventListener('click', handleClick);
}

// Leak 2: Circular references
function createClosure() {
    const data = { counter: 0 };
    
    // Element references closure, closure references element
    const element = document.getElementById('counter');
    element.data = data;
    
    data.increment = function() {
        this.counter++;
        element.textContent = this.counter;
    };
    
    return data;
}

// Fix: Break circular references
function createClosure() {
    const data = { counter: 0 };
    const element = document.getElementById('counter');
    
    data.increment = function() {
        this.counter++;
        // Use a selector instead of storing the element
        document.getElementById('counter').textContent = this.counter;
    };
    
    return data;
}
                
Monitoring Memory Usage

// Check memory usage periodically
function logMemoryUsage() {
    if (performance.memory) {
        console.log('Memory usage:', {
            total: Math.round(performance.memory.totalJSHeapSize / (1024 * 1024)) + ' MB',
            used: Math.round(performance.memory.usedJSHeapSize / (1024 * 1024)) + ' MB',
            limit: Math.round(performance.memory.jsHeapSizeLimit / (1024 * 1024)) + ' MB'
        });
    }
}

// Call every 10 seconds
setInterval(logMemoryUsage, 10000);
                

Efficient Data Structures and Algorithms

Choosing the right data structures can significantly impact performance:

Map vs Object for Lookups

// Testing lookup performance with different data structures
function testLookupPerformance() {
    const iterations = 1000000;
    const keys = [];
    
    // Generate random keys
    for (let i = 0; i < 1000; i++) {
        keys.push(`key${i}`);
    }
    
    // Setup Object
    const obj = {};
    for (const key of keys) {
        obj[key] = key;
    }
    
    // Setup Map
    const map = new Map();
    for (const key of keys) {
        map.set(key, key);
    }
    
    // Test Object lookup
    console.time('Object lookup');
    for (let i = 0; i < iterations; i++) {
        const randomKey = keys[i % keys.length];
        const value = obj[randomKey];
    }
    console.timeEnd('Object lookup');
    
    // Test Map lookup
    console.time('Map lookup');
    for (let i = 0; i < iterations; i++) {
        const randomKey = keys[i % keys.length];
        const value = map.get(randomKey);
    }
    console.timeEnd('Map lookup');
}

testLookupPerformance();
                
Set for Unique Values

// Inefficient: Using array to check for duplicates
function uniqueArray(arr) {
    const result = [];
    for (const item of arr) {
        if (!result.includes(item)) { // O(n) operation
            result.push(item);
        }
    }
    return result;
}

// Efficient: Using Set
function uniqueArray(arr) {
    return [...new Set(arr)]; // O(1) lookup time
}

// Compare performance
const largeArray = Array.from({ length: 10000 }, (_, i) => i % 1000);
console.time('Array method');
uniqueArray(largeArray);
console.timeEnd('Array method');

console.time('Set method');
uniqueArray(largeArray);
console.timeEnd('Set method');
                

Worker Threads for CPU-Intensive Tasks

JavaScript is single-threaded, but Web Workers enable parallel processing:

Main Thread (main.js)

// Create a worker
const worker = new Worker('worker.js');

// Send data to the worker
worker.postMessage({
    task: 'processData',
    data: largeDataArray
});

// Receive results from the worker
worker.onmessage = function(event) {
    const result = event.data;
    console.log('Processing complete:', result);
};

// UI remains responsive while the worker processes data
document.querySelector('#status').textContent = 'Processing in background...';
                
Worker Thread (worker.js)

// Listen for messages from the main thread
self.onmessage = function(event) {
    const { task, data } = event.data;
    
    if (task === 'processData') {
        // CPU-intensive processing
        const result = processLargeData(data);
        
        // Send result back to main thread
        self.postMessage(result);
    }
};

function processLargeData(data) {
    // Simulate CPU-intensive work
    let result = 0;
    for (let i = 0; i < data.length; i++) {
        // Complex calculations...
        result += Math.sqrt(data[i] * data[i]);
    }
    return result;
}
                

Framework-Specific Optimizations

Modern JavaScript frameworks have their own performance considerations. Here are some tips for common frameworks:

React Optimizations

React.memo for Component Memoization

// Wrap functional components with React.memo to prevent unnecessary re-renders
const MyComponent = React.memo(function MyComponent(props) {
    return (
        

{props.title}

{props.content}

); }); // Custom comparison function for complex props const MyComplexComponent = React.memo( function MyComplexComponent(props) { return
{/* component logic */}
; }, (prevProps, nextProps) => { // Return true if passing nextProps to render would return // the same result as passing prevProps return prevProps.data.id === nextProps.data.id; } );
useMemo and useCallback Hooks

function SearchResults({ query, data }) {
    // Memoize expensive calculations
    const filteredData = useMemo(() => {
        console.log('Filtering data...');
        return data.filter(item => 
            item.title.toLowerCase().includes(query.toLowerCase())
        );
    }, [data, query]); // Only recalculate when data or query changes
    
    // Memoize callback functions
    const handleItemClick = useCallback((item) => {
        console.log('Item clicked:', item);
        // Handle the click event
    }, []); // No dependencies, function never changes
    
    return (
        <ul>
            {filteredData.map(item => (
                <li key={item.id} onClick={() => handleItemClick(item)}>
                    {item.title}
                </li>
            ))}
        </ul>
    );
}

                
Virtual List for Large Datasets

import { FixedSizeList } from 'react-window';

function VirtualizedList({ items }) {
    // Render only the visible items in a large list
    const Row = ({ index, style }) => (
        <div style={style}>
            Item {index}: {items[index]}
        </div>
    );
    
    return (
        <FixedSizeList
            height={400}
            width={300}
            itemCount={items.length}
            itemSize={35}
        >
            {Row}
        </FixedSizeList>
    );
}
                

Vue Optimizations

Use v-show for Conditional Display

<!-- v-if removes/adds elements from DOM (higher cost) -->
<div v-if="isVisible">Heavy content</div>

<!-- v-show just toggles display: none (lower cost if toggled frequently) -->
<div v-show="isVisible">Heavy content</div>
                
Use Computed Properties

export default {
    data() {
        return {
            items: [...], // Large array
            query: ''
        };
    },
    computed: {
        // Automatically cached until dependencies change
        filteredItems() {
            return this.items.filter(item => 
                item.name.toLowerCase().includes(this.query.toLowerCase())
            );
        }
    }
}
                
Functional Components for Simple Components

<!-- Simple components can be defined as functional components -->
<template functional>
    <div class="item">
        <h3>{{ props.title }}</h3>
        <p>{{ props.description }}</p>
    </div>
</template>
                

Angular Optimizations

OnPush Change Detection

import { Component, Input, ChangeDetectionStrategy } from '@angular/core';

@Component({
    selector: 'app-item',
    template: `
        <div class="item">
            <h3>{{ item.title }}</h3>
            <p>{{ item.description }}</p>
        </div>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class ItemComponent {
    @Input() item: any;
}
                
TrackBy Function with ngFor

@Component({
    selector: 'app-list',
    template: `
        <!-- Use trackBy to avoid unnecessary DOM recreation -->
        <div *ngFor="let item of items; trackBy: trackByFn">
            {{ item.name }}
        </div>
    `
})
export class ListComponent {
    items = [...]; // List of items
    
    trackByFn(index, item) {
        return item.id; // Use unique identifier
    }
}
                
Lazy Loading Modules

// app-routing.module.ts
const routes: Routes = [
    {
        path: 'admin',
        loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule)
    },
    {
        path: 'reports',
        loadChildren: () => import('./reports/reports.module').then(m => m.ReportsModule)
    }
];
                

Leveraging Modern Browser Features

Modern browsers offer powerful features that can significantly improve performance:

Intersection Observer for Lazy Loading


// Lazy load images when they enter the viewport
const images = document.querySelectorAll('.lazy-image');

const imageObserver = new IntersectionObserver((entries, observer) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            const img = entry.target;
            const src = img.getAttribute('data-src');
            
            if (src) {
                img.src = src;
                img.removeAttribute('data-src');
                observer.unobserve(img);
            }
        }
    });
});

images.forEach(img => {
    imageObserver.observe(img);
});
                

Resize Observer for Responsive Optimizations


// Optimize rendering based on element size
const chartContainer = document.querySelector('#chart');
const chart = createChart(chartContainer);

const resizeObserver = new ResizeObserver(entries => {
    for (const entry of entries) {
        // Instead of listening to window resize events,
        // we get notified when this specific element resizes
        const { width, height } = entry.contentRect;
        
        // Optimize rendering based on available space
        if (width < 600) {
            chart.setOptions({ showLegend: false, simplified: true });
        } else {
            chart.setOptions({ showLegend: true, simplified: false });
        }
        
        chart.resize(width, height);
    }
});

resizeObserver.observe(chartContainer);
                

WebGL for Complex Visualizations


// Instead of manipulating thousands of DOM elements for visualization
// Use WebGL or Canvas for high-performance rendering
import * as THREE from 'three';

function createDataVisualization(container, data) {
    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
    const renderer = new THREE.WebGLRenderer();
    
    renderer.setSize(window.innerWidth, window.innerHeight);
    container.appendChild(renderer.domElement);
    
    // Add thousands of points without impacting DOM performance
    const geometry = new THREE.BufferGeometry();
    const vertices = [];
    
    // Convert data to 3D points
    data.forEach(item => {
        vertices.push(item.x, item.y, item.z);
    });
    
    geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
    
    const material = new THREE.PointsMaterial({ color: 0x888888, size: 0.1 });
    const points = new THREE.Points(geometry, material);
    
    scene.add(points);
    camera.position.z = 5;
    
    function animate() {
        requestAnimationFrame(animate);
        points.rotation.x += 0.001;
        points.rotation.y += 0.002;
        renderer.render(scene, camera);
    }
    
    animate();
}
                

Deployment and Delivery Optimizations

Optimizing how your code is delivered to users can have a significant impact on performance:

CDN Deployment

Content Delivery Networks distribute your assets to servers worldwide, reducing latency for users:

Modern Protocol Support

Configure your server to use the latest web protocols:

Nginx Configuration for HTTP/2 and Brotli

server {
    listen 443 ssl http2;
    server_name example.com;
    
    # SSL configuration
    ssl_certificate /etc/ssl/certs/example.com.crt;
    ssl_certificate_key /etc/ssl/private/example.com.key;
    
    # Enable Brotli compression
    brotli on;
    brotli_comp_level 6;
    brotli_types text/plain text/css application/javascript application/json image/svg+xml;
    
    # Static assets with cache headers
    location /assets/ {
        root /var/www/example.com;
        expires 1y;
        add_header Cache-Control "public, max-age=31536000, immutable";
        try_files $uri =404;
    }
    
    # API endpoints
    location /api/ {
        proxy_pass http://backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
    }
}
                

Differential Serving

Serve optimized code based on browser capabilities:

Module/Nomodule Pattern

<!-- Modern browsers get smaller, optimized ES modules bundle -->
<script type="module" src="app.modern.js"></script>

<!-- Legacy browsers get transpiled code with polyfills -->
<script nomodule src="app.legacy.js"></script>
                
Babel Configuration for Differential Builds

// babel.config.js
module.exports = {
    presets: [
        ['@babel/preset-env', {
            // Modern build - target browsers with ES modules support
            ...(process.env.BROWSERSLIST_ENV === 'modern' ? {
                targets: { esmodules: true },
                bugfixes: true,
                loose: true
            } : 
            // Legacy build - wider browser support
            {
                targets: '> 0.25%, not dead',
                useBuiltIns: 'usage',
                corejs: 3
            })
        }]
    ]
};
                

Implementing a Performance Optimization Workflow

Performance optimization should be an ongoing process integrated into your development workflow:

Performance Budgets

Set measurable targets for performance metrics:

Webpack Performance Budget

// webpack.config.js
module.exports = {
  // ...
  performance: {
    hints: 'error',
    maxAssetSize: 100000, // 100 KB
    maxEntrypointSize: 300000, // 300 KB
  },
};
                
Lighthouse CI Configuration

// lighthouserc.js
module.exports = {
  ci: {
    collect: {
      url: ['https://example.com/'],
      numberOfRuns: 3,
    },
    assert: {
      assertions: {
        'first-contentful-paint': ['warn', {maxNumericValue: 2000}],
        'interactive': ['error', {maxNumericValue: 3000}],
        'max-potential-fid': ['error', {maxNumericValue: 100}],
        'cumulative-layout-shift': ['error', {maxNumericValue: 0.1}],
        'largest-contentful-paint': ['error', {maxNumericValue: 2500}],
      },
    },
    upload: {
      target: 'temporary-public-storage',
    },
  },
};
                

Automated Performance Testing

Integrate performance testing into your CI/CD pipeline:

Performance Monitoring in Production

Monitor real-world performance to identify issues:

Capturing Performance Metrics with Web Vitals

import { getCLS, getFID, getLCP } from 'web-vitals';

function sendToAnalytics({ name, delta, id }) {
  // Send metrics to your analytics platform
  navigator.sendBeacon('/analytics', JSON.stringify({
    name,
    delta,
    id,
    // Add additional info
    page: window.location.pathname,
    timestamp: Date.now()
  }));
}

// Register listeners to capture and report Core Web Vitals
getCLS(sendToAnalytics);
getFID(sendToAnalytics);
getLCP(sendToAnalytics);
                

Practical Activity: Performance Audit and Optimization

Let's put our knowledge into practice by optimizing a real application. In this exercise, we'll identify and fix performance issues in a sample application.

Step 1: Performance Audit

  1. Clone the sample application repository:
    git clone https://github.com/example/performance-workshop.git
  2. Install dependencies and start the development server:
    npm install
    npm start
  3. Open Chrome DevTools and run a Lighthouse audit
  4. Identify the top 3 performance issues
  5. Use the Performance panel to profile JavaScript execution

Step 2: Implement Optimizations

Based on your audit, implement at least three of the following optimizations:

  1. Code splitting for large components
  2. Implement lazy loading for images
  3. Optimize event handlers with debouncing or throttling
  4. Improve rendering performance by reducing DOM operations
  5. Add memoization for expensive calculations
  6. Fix memory leaks

Step 3: Measure Improvements

  1. Run another Lighthouse audit to compare before and after scores
  2. Quantify the improvements in each key metric
  3. Use the Performance API to measure specific optimizations

Step 4: Group Discussion

Share your findings with the class:

Key Takeaways

Performance optimization is a continuous process, not a one-time task. Here are the key principles to remember:

Further Resources

Homework Assignment

For your homework, you'll apply performance optimization techniques to your own project:

Assignment Requirements

  1. Choose a project to optimize:
    • Your final project application
    • A personal or work project
    • The provided sample application with additional features
  2. Perform a baseline performance audit:
    • Run a Lighthouse audit
    • Capture key metrics (LCP, FID, CLS, etc.)
    • Profile JavaScript execution
    • Document baseline performance
  3. Implement at least five optimizations from different categories:
    • Loading optimizations (code splitting, lazy loading, etc.)
    • Runtime optimizations (DOM, events, memory, etc.)
    • Framework-specific optimizations
    • Build and delivery optimizations
  4. Measure the impact of your optimizations:
    • Run post-optimization audits
    • Quantify improvements in key metrics
    • Document the most effective optimizations
  5. Create a performance optimization report:
    • Baseline performance analysis
    • Identified bottlenecks
    • Implemented optimizations with code examples
    • Performance improvements with metrics
    • Recommendations for further improvements

Submission Guidelines

Due Date

Submit your completed assignment before our next class session.