Performance optimization techniques

This guide covers advanced performance optimization techniques for Aurelia applications, including framework-specific optimizations, build configuration, and best practices for high-performance applications.

Framework-Specific Optimizations

Task Queue Performance

The Aurelia task queue provides several performance optimization features:

Task Batching

Batch DOM updates to improve rendering performance:

import { batch } from 'aurelia';

// Batch multiple DOM updates in a single frame
batch(() => {
  // both assignment will not immediately trigger rerendering
  component.prop = someValue;
  component2.prop = someOtherValue;
});

// With mordern browser implementation, normally all DOM changes execute in the same task
// and without triggering layout-ing or reflow unless there's a DOM property read in between
// that triggers those.
element1.style.left = '100px';
element2.style.top = '200px';
element3.textContent = 'Updated';

State Management Performance

Memoization System

Use the built-in memoization system for expensive computations:

import { createStateMemoizer } from '@aurelia/state';

// Single selector memoization
const selectTotal = createStateMemoizer(
  (state: AppState) => state.items,
  (items) => items.reduce((sum, item) => sum + item.value, 0)
);

// Usage in component
@customElement({ name: 'dashboard' })
export class Dashboard {
  @fromState(selectTotal) total: number;
}

Shared Memoization

Share memoized selectors across components for better performance:

// Define once, use everywhere
const selectFilteredItems = createStateMemoizer(
  (state: AppState) => state.items,
  (state: AppState) => state.filter,
  (items, filter) => items.filter(item => item.category === filter)
);

// Multiple components share the same computation
@customElement({ name: 'item-list' })
export class ItemList {
  @fromState(selectFilteredItems) items: Item[];
}

@customElement({ name: 'item-count' })
export class ItemCount {
  @fromState(selectFilteredItems) items: Item[];
  
  get count(): number {
    return this.items.length;
  }
}

Computed Observer Performance

Sync vs Async Flush Modes

Choose the appropriate flush mode for computed properties:

// Async mode (default) - better performance for most cases
@computed()
get expensiveCalculation(): number {
  return this.complexComputation();
}

// Sync mode - for critical computations that need immediate updates
@computed({ flush: 'sync' })
get criticalValue(): number {
  return this.criticalComputation();
}

// Deep observation - watch nested property changes
@computed({ deep: true })
get nestedTotal(): number {
  return this.items.reduce((sum, item) => sum + item.value, 0);
}

Computed Property Optimization

Optimize computed properties for better performance:

export class OptimizedComponent {
  private _memoizedResult: number | null = null;
  private _lastInputs: [number, number] | null = null;

  @computed()
  get optimizedCalculation(): number {
    const inputs: [number, number] = [this.input1, this.input2];
    
    // Manual memoization for expensive calculations
    if (this._lastInputs && 
        this._lastInputs[0] === inputs[0] && 
        this._lastInputs[1] === inputs[1]) {
      return this._memoizedResult!;
    }
    
    this._lastInputs = inputs;
    this._memoizedResult = this.expensiveComputation(inputs[0], inputs[1]);
    return this._memoizedResult;
  }
}

Manual computed dependencies declaration

Read more on @computed decorator here.

Watch Performance Optimization

Efficient Watch Expressions

Use efficient watch expressions to minimize performance impact:

export class OptimizedWatching {
  // Good: Watch specific properties
  @watch('user.profile.name')
  onUserNameChange(newName: string): void {
    this.updateDisplay(newName);
  }

  // Better: Use computed properties for complex expressions
  @computed()
  get userDisplayName(): string {
    return `${this.user.profile.firstName} ${this.user.profile.lastName}`;
  }

  @watch('userDisplayName')
  onDisplayNameChange(newName: string): void {
    this.updateDisplay(newName);
  }
}

Watch Flush Timing

Control when watch callbacks execute:

// Async flush (default) - better performance
@watch('counter')
onCounterChange(newValue: number): void {
  // Executes in next microtask
  this.performUpdate(newValue);
}

// Sync flush - for critical updates
@watch('criticalValue', { flush: 'sync' })
onCriticalValueChange(newValue: number): void {
  // Executes immediately
  this.updateCriticalUI(newValue);
}

Binding Behaviors for Performance

Throttle Binding Behavior

Limit how often a binding updates, useful for expensive operations triggered by user input:

<!-- Throttle search updates - max once every 300ms -->
<input type="text"
       value.bind="searchQuery & throttle:300">

<!-- Throttle scroll position updates -->
<div scroll.trigger="handleScroll($event) & throttle:100">
  <!-- Content -->
</div>

<!-- Multiple values: delay and signal name -->
<input value.bind="filterText & throttle:200:'filter-changed'">
import { customElement } from 'aurelia';

@customElement({ name: 'search-box' })
export class SearchBox {
  searchQuery = '';

  // This will only be called max once every 300ms
  searchQueryChanged(newValue: string): void {
    this.performExpensiveSearch(newValue);
  }

  private performExpensiveSearch(query: string): void {
    // Expensive API call or computation
  }
}

Debounce Binding Behavior

Delay binding updates until user stops typing, ideal for search-as-you-type:

<!-- Wait 500ms after user stops typing before updating -->
<input type="text"
       value.bind="searchTerm & debounce:500">

<!-- Debounce with custom signal -->
<textarea value.bind="content & debounce:1000:'content-saved'">
</textarea>
import { customElement } from 'aurelia';

@customElement({ name: 'live-search' })
export class LiveSearch {
  searchTerm = '';
  results: any[] = [];

  // Only called 500ms after user stops typing
  async searchTermChanged(newValue: string): Promise<void> {
    if (newValue.length < 3) return;

    // This expensive API call only fires after user stops typing
    this.results = await this.searchAPI.query(newValue);
  }
}

Performance Tips:

  • Use throttle for continuous events (scroll, mousemove, resize)

  • Use debounce for discrete user input (typing, form fields)

  • Throttle allows periodic updates; debounce waits for quiet period

  • Default delay is 200ms if not specified

Virtual Repeat Performance

Optimize Virtual Repeat for Large Collections

Configure virtual-repeat inline for optimal performance with large datasets:

<!-- Fixed item height - best performance -->
<div virtual-repeat.for="item of items"
     item-height="60"
     buffer-size="20">
  <item-view item.bind="item"></item-view>
</div>

<!-- Horizontal layout -->
<div virtual-repeat.for="item of items"
     layout="horizontal"
     item-width="200"
     buffer-size="10">
  <item-card item.bind="item"></item-card>
</div>

<!-- Configure minimum views -->
<div virtual-repeat.for="item of items"
     item-height="80"
     min-views="15">
  <item-row item.bind="item"></item-row>
</div>

Virtual Repeat with Variable Heights

For items with varying heights, enable the variable-height option:

<!-- Variable height support - Aurelia will measure each item -->
<div virtual-repeat.for="item of items"
     variable-height="true"
     buffer-size="20">
  <div class="item">
    <h3>${item.title}</h3>
    <p>${item.description}</p>
    <!-- Heights can vary based on content -->
  </div>
</div>

<!-- Variable width for horizontal layouts -->
<div virtual-repeat.for="item of items"
     layout="horizontal"
     variable-width="true"
     buffer-size="15">
  <item-card item.bind="item"></item-card>
</div>

Performance Note: Variable sizing has more overhead than fixed sizing. Use fixed heights when possible for best performance.

Build Optimization

Bundle Size Optimization

Tree Shaking Configuration

Configure your bundler for optimal tree shaking:

// webpack.config.js
module.exports = {
  optimization: {
    usedExports: true,
    sideEffects: false,
    minimize: true,
  },
  resolve: {
    mainFields: ['module', 'main']
  }
};

Selective Imports

Import only what you need from Aurelia packages:

// Good: Import specific functions
import { observable, computed } from 'aurelia';

// Better: Import from specific modules
import { observable } from '@aurelia/runtime';
import { computed } from '@aurelia/runtime';

// Best: Use direct imports for better tree shaking
import { observable } from '@aurelia/runtime/dist/esm/observation/observable';

Code Splitting

Route-Based Code Splitting

Split your application by routes for better loading performance:

// Lazy load route components
@route({
  routes: [
    { path: '', component: () => import('./home') },
    { path: 'dashboard', component: () => import('./dashboard') },
    { path: 'settings', component: () => import('./settings') }
  ]
})
export class App { }

Component-Based Code Splitting

Split large components into separate chunks:

// Dynamic component loading
@customElement({ name: 'lazy-component' })
export class LazyComponent {
  private heavyComponent: Promise<any>;
  
  binding(): void {
    this.heavyComponent = import('./heavy-component');
  }
}

Production Optimization

Minification and Compression

Configure production builds for optimal performance:

// vite.config.js
export default defineConfig({
  build: {
    minify: 'terser',
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ['aurelia'],
          utils: ['lodash', 'date-fns']
        }
      }
    }
  }
});

Service Worker Integration

Implement caching strategies for better performance:

// service-worker.ts
self.addEventListener('fetch', (event) => {
  if (event.request.url.includes('/api/')) {
    // Cache API responses
    event.respondWith(
      caches.open('api-cache').then(cache => {
        return cache.match(event.request).then(response => {
          if (response) {
            return response;
          }
          return fetch(event.request).then(response => {
            cache.put(event.request, response.clone());
            return response;
          });
        });
      })
    );
  }
});

Memory Management

Component Cleanup

Proper Event Listener Cleanup

@customElement({ name: 'event-component' })
export class EventComponent {
  private resizeHandler = this.onResize.bind(this);
  
  attached(): void {
    window.addEventListener('resize', this.resizeHandler);
  }
  
  detached(): void {
    window.removeEventListener('resize', this.resizeHandler);
  }
  
  private onResize(): void {
    // Handle resize
  }
}

Subscription Management

import { resolve } from '@aurelia/kernel';
import { IEventAggregator } from '@aurelia/kernel';
import { customElement } from 'aurelia';

@customElement({ name: 'subscription-component' })
export class SubscriptionComponent {
  private eventAggregator = resolve(IEventAggregator);
  private subscriptions: Subscription[] = [];

  attached(): void {
    this.subscriptions.push(
      this.eventAggregator.subscribe('event', this.handleEvent.bind(this))
    );
  }

  detached(): void {
    this.subscriptions.forEach(sub => sub.dispose());
    this.subscriptions = [];
  }

  private handleEvent(data: unknown): void {
    // Handle event
  }
}

Memory Leak Prevention

Avoid Circular References

// Avoid this pattern
export class Parent {
  children: Child[] = [];
  
  addChild(child: Child): void {
    child.parent = this; // Circular reference
    this.children.push(child);
  }
}

// Better approach
export class Parent {
  children: Child[] = [];
  
  addChild(child: Child): void {
    child.setParent(this);
    this.children.push(child);
  }
  
  dispose(): void {
    this.children.forEach(child => child.setParent(null));
    this.children = [];
  }
}

WeakMap for Metadata

Use WeakMap for storing metadata that should be garbage collected:

const componentMetadata = new WeakMap<object, ComponentMetadata>();

export class MetadataManager {
  static setMetadata(component: object, metadata: ComponentMetadata): void {
    componentMetadata.set(component, metadata);
  }
  
  static getMetadata(component: object): ComponentMetadata | undefined {
    return componentMetadata.get(component);
  }
}

Observable Batching

Batch Multiple State Changes

When making multiple property changes, use batch() to combine them into a single change notification:

import { batch, observable } from '@aurelia/runtime';
import { customElement } from 'aurelia';

@customElement({ name: 'user-profile' })
export class UserProfile {
  @observable firstName = '';
  @observable lastName = '';
  @observable email = '';
  @observable phoneNumber = '';

  // Without batching: 4 separate change notifications
  updateUserSlow(data: UserData): void {
    this.firstName = data.firstName;    // triggers update
    this.lastName = data.lastName;      // triggers update
    this.email = data.email;            // triggers update
    this.phoneNumber = data.phoneNumber; // triggers update
  }

  // With batching: 1 combined change notification
  updateUserFast(data: UserData): void {
    batch(() => {
      this.firstName = data.firstName;
      this.lastName = data.lastName;
      this.email = data.email;
      this.phoneNumber = data.phoneNumber;
      // All changes are batched into a single update cycle
    });
  }
}

Batch Array Mutations

Batch multiple array operations to prevent repeated re-renders:

import { batch, observable } from '@aurelia/runtime';
import { customElement } from 'aurelia';

@customElement({ name: 'todo-list' })
export class TodoList {
  @observable items: TodoItem[] = [];

  // Batch multiple array operations
  bulkUpdate(updates: TodoUpdate[]): void {
    batch(() => {
      for (const update of updates) {
        if (update.action === 'add') {
          this.items.push(update.item);
        } else if (update.action === 'remove') {
          const index = this.items.indexOf(update.item);
          if (index > -1) this.items.splice(index, 1);
        } else if (update.action === 'update') {
          Object.assign(update.item, update.changes);
        }
      }
      // Only one change notification for all operations
    });
  }
}

Performance Benefits:

  • Reduces the number of change notifications

  • Prevents unnecessary intermediate UI updates

  • Particularly effective when updating multiple related properties

  • Essential for bulk data operations

Large Data Handling

Pagination Strategies

Virtual Pagination

@customElement({ name: 'virtual-pagination' })
export class VirtualPagination {
  private allItems: Item[] = [];
  private pageSize = 50;
  private currentPage = 0;
  
  get visibleItems(): Item[] {
    const start = this.currentPage * this.pageSize;
    const end = start + this.pageSize;
    return this.allItems.slice(start, end);
  }
  
  loadMoreItems(): void {
    if (this.hasMoreItems) {
      this.currentPage++;
    }
  }
  
  get hasMoreItems(): boolean {
    return (this.currentPage + 1) * this.pageSize < this.allItems.length;
  }
}

Infinite Scroll

import { observable } from '@aurelia/runtime';
import { customElement } from 'aurelia';

interface IDataService {
  getItems(offset: number, limit: number): Promise<Item[]>;
}

@customElement({ name: 'infinite-scroll' })
export class InfiniteScroll {
  private loadingMore = false;
  private hasMore = true;
  private scrollContainer!: HTMLElement;

  @observable items: Item[] = [];

  attached(): void {
    this.scrollContainer.addEventListener('scroll', this.onScroll.bind(this));
  }

  detached(): void {
    this.scrollContainer.removeEventListener('scroll', this.onScroll.bind(this));
  }

  private onScroll(): void {
    if (this.loadingMore || !this.hasMore) return;

    const { scrollTop, scrollHeight, clientHeight } = this.scrollContainer;
    const threshold = 200;

    if (scrollTop + clientHeight >= scrollHeight - threshold) {
      void this.loadMoreItems();
    }
  }

  private async loadMoreItems(): Promise<void> {
    this.loadingMore = true;

    try {
      // Fetch from your data service
      const newItems = await this.fetchItems(this.items.length, 20);
      this.items.push(...newItems);
      this.hasMore = newItems.length === 20;
    } finally {
      this.loadingMore = false;
    }
  }

  private async fetchItems(offset: number, limit: number): Promise<Item[]> {
    // Your API call here
    return [] as Item[];
  }
}

Data Streaming

Streaming Large Datasets

import { customElement, batch } from 'aurelia';

@customElement({ name: 'data-stream' })
export class DataStream {
  private items: Item[] = [];
  private processingQueue: Item[] = [];

  async loadData(): Promise<void> {
    const stream = this.getStreamingData();

    for await (const chunk of stream) {
      this.processingQueue.push(...chunk);

      // Process in batches to avoid blocking the UI
      if (this.processingQueue.length >= 100) {
        await this.processBatch();
      }
    }

    // Process remaining items
    if (this.processingQueue.length > 0) {
      await this.processBatch();
    }
  }

  private async processBatch(): Promise<void> {
    const batch = this.processingQueue.splice(0, 100);

    // Process batch in task queue to avoid blocking
    await new Promise<void>(resolve => {
      batch(() => {
        this.items.push(...batch);
        resolve();
      });
    });
  }

  private async *getStreamingData(): AsyncGenerator<Item[]> {
    // Your streaming data source
    // Example: fetch data in chunks from API
    yield [] as Item[];
  }
}

Performance Monitoring

Runtime Performance Profiling

Performance Metrics Collection

class PerformanceMetrics {
  private metrics: Map<string, number[]> = new Map();
  
  measure<T>(name: string, fn: () => T): T {
    const start = performance.now();
    const result = fn();
    const end = performance.now();
    
    if (!this.metrics.has(name)) {
      this.metrics.set(name, []);
    }
    
    this.metrics.get(name)!.push(end - start);
    return result;
  }
  
  getAverageTime(name: string): number {
    const times = this.metrics.get(name) || [];
    return times.reduce((sum, time) => sum + time, 0) / times.length;
  }
  
  getPercentile(name: string, percentile: number): number {
    const times = this.metrics.get(name) || [];
    const sorted = times.sort((a, b) => a - b);
    const index = Math.floor(sorted.length * percentile / 100);
    return sorted[index];
  }
}

Real-World Performance Scenarios

Scenario 1: Optimized Data Grid

Build a high-performance data grid with 10,000+ rows:

import { customElement } from 'aurelia';

@customElement({
  name: 'data-grid',
  template: `
    <div class="grid-container">
      <div class="grid-header">
        <input type="text"
               value.bind="filterText & debounce:300"
               placeholder="Search...">
      </div>

      <div class="grid-body"
           virtual-repeat.for="row of filteredRows"
           item-height="40"
           buffer-size="20">
        <div class="grid-row">
          <span>\${row.id}</span>
          <span>\${row.name}</span>
          <span>\${row.email}</span>
        </div>
      </div>
    </div>
  `
})
export class DataGrid {
  rows: DataRow[] = [];
  filterText = '';

  @computed({ deps: ['rows', 'filterText'] })
  get filteredRows(): DataRow[] {
    if (!this.filterText) return this.rows;

    const search = this.filterText.toLowerCase();
    return this.rows.filter(row =>
      row.name.toLowerCase().includes(search) ||
      row.email.toLowerCase().includes(search)
    );
  }
}

Performance Features Used:

  • debounce prevents filtering on every keystroke

  • virtual-repeat renders only visible rows

  • @computed with explicit deps caches filter results

  • Fixed item-height enables optimal scrolling

Scenario 2: Real-Time Dashboard Updates

Handle high-frequency updates efficiently:

import { batch, observable, queueRecurringTask } from '@aurelia/runtime';
import { customElement, resolve } from 'aurelia';
import { PLATFORM } from 'aurelia';

@customElement({ name: 'live-dashboard' })
export class LiveDashboard {
  @observable metrics: DashboardMetrics = {
    activeUsers: 0,
    requestsPerSecond: 0,
    errorRate: 0,
    avgResponseTime: 0
  };

  private updateTask?: any;

  attaching(): void {
    // Batch multiple metric updates together
    this.updateTask = queueRecurringTask(() => {
      this.updateMetrics();
    }, { interval: 1000 });
  }

  detaching(): void {
    this.updateTask?.cancel();
  }

  private updateMetrics(): void {
    // Fetch latest metrics from API
    const newMetrics = this.fetchLatestMetrics();

    // Use batch to update all metrics at once
    batch(() => {
      this.metrics.activeUsers = newMetrics.activeUsers;
      this.metrics.requestsPerSecond = newMetrics.requestsPerSecond;
      this.metrics.errorRate = newMetrics.errorRate;
      this.metrics.avgResponseTime = newMetrics.avgResponseTime;
    });
  }

  private fetchLatestMetrics(): DashboardMetrics {
    // API call
    return {} as DashboardMetrics;
  }
}

Performance Features Used:

  • batch() combines multiple updates into one notification

  • Persistent task with delay for regular updates

  • Task cancellation on component detach prevents leaks

Optimize large image galleries:

<!-- image-gallery.html -->
<div class="gallery">
  <div virtual-repeat.for="image of images"
       variable-height="true"
       buffer-size="15"
       layout="horizontal">
    <img src.bind="image.thumbnail"
         loading="lazy"
         alt="\${image.title}">
  </div>
</div>
import { customElement, batch } from 'aurelia';

@customElement({ name: 'image-gallery' })
export class ImageGallery {
  images: GalleryImage[] = [];

  async attached(): Promise<void> {
    // Load images in chunks
    await this.loadImagesInChunks();
  }

  private async loadImagesInChunks(): Promise<void> {
    const chunkSize = 50;
    const allImages = await this.fetchAllImageMetadata();

    for (let i = 0; i < allImages.length; i += chunkSize) {
      const chunk = allImages.slice(i, i + chunkSize);

      // Use task queue to prevent blocking
      await new Promise<void>(resolve => {
        batch(() => {
          this.images.push(...chunk);
          resolve();
        });
      });
    }
  }

  private async fetchAllImageMetadata(): Promise<GalleryImage[]> {
    // Fetch from API
    return [] as GalleryImage[];
  }
}

Performance Features Used:

  • variable-height handles different aspect ratios

  • Native lazy loading with loading="lazy"

  • Task queue prevents UI blocking during data loading

  • Chunked loading for progressive rendering

Scenario 4: Complex Form with Validation

Optimize forms with many fields:

import { batch, observable } from '@aurelia/runtime';
import { customElement } from 'aurelia';

@customElement({ name: 'complex-form' })
export class ComplexForm {
  @observable formData: FormData = {
    personalInfo: {},
    address: {},
    preferences: {},
    settings: {}
  };

  // Debounce validation to avoid excessive checks
  template = `
    <form>
      <input value.bind="formData.personalInfo.firstName & debounce:200">
      <input value.bind="formData.personalInfo.lastName & debounce:200">
      <input value.bind="formData.personalInfo.email & debounce:300">
      <!-- More fields... -->
    </form>
  `;

  loadFormData(data: FormData): void {
    // Batch all field updates
    batch(() => {
      Object.assign(this.formData.personalInfo, data.personalInfo);
      Object.assign(this.formData.address, data.address);
      Object.assign(this.formData.preferences, data.preferences);
      Object.assign(this.formData.settings, data.settings);
    });
  }

  @computed({ flush: 'async' })
  get isFormValid(): boolean {
    // Expensive validation runs asynchronously
    return this.validateAllFields();
  }

  private validateAllFields(): boolean {
    // Validation logic
    return true;
  }
}

Performance Features Used:

  • debounce on inputs reduces validation frequency

  • batch() when loading initial form data

  • Async flush for validation computation

  • Object.assign for efficient property updates

Best Practices Summary

1. Framework Usage

  • Use batch for array mutation operations

  • Implement memoization with createStateMemoizer for expensive state computations

  • Choose appropriate flush modes (sync or async) for computed properties

  • Optimize watch expressions and prefer computed properties

  • Use throttle for continuous events, debounce for discrete user input

  • Enable deep observation only when needed for nested objects

2. Memory Management

  • Always clean up event listeners and subscriptions in detaching()

  • Use WeakMap for metadata storage that should be garbage collected

  • Avoid circular references or use explicit cleanup

  • Cancel persistent tasks when components detach

  • Monitor memory usage during development

3. Data Handling

  • Implement virtual-repeat for lists with 100+ items

  • Use fixed item-height for best virtual-repeat performance

  • Enable variable-height only when necessary

  • Use pagination or infinite scroll for large datasets

  • Process data in batches with batch() to avoid blocking the UI

  • Stream large datasets when possible using task queue

4. Binding Optimization

  • Use debounce on form inputs (300-500ms for text, 200ms for other fields)

  • Use throttle on scroll/resize/mousemove handlers (100-200ms)

  • Batch multiple observable changes with batch()

  • Prefer @computed with explicit deps over complex expressions in templates

  • Use flush: 'async' (default) unless immediate updates are critical

5. Build Optimization

  • Configure tree shaking properly in your bundler

  • Use code splitting for routes and large components

  • Optimize bundle size with selective imports

  • Implement service worker caching for production apps

  • Minify and compress assets

6. Performance Monitoring

  • Monitor component lifecycle performance

  • Track memory usage patterns with browser DevTools

  • Use performance metrics to identify bottlenecks

  • Test with realistic data volumes

These optimization techniques will help you build high-performance Aurelia applications that scale well and provide excellent user experiences.

Last updated

Was this helpful?