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:
debounceprevents filtering on every keystrokevirtual-repeatrenders only visible rows@computedwith explicit deps caches filter resultsFixed
item-heightenables 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 notificationPersistent task with delay for regular updates
Task cancellation on component detach prevents leaks
Scenario 3: Image Gallery with Lazy Loading
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-heighthandles different aspect ratiosNative 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:
debounceon inputs reduces validation frequencybatch()when loading initial form dataAsync flush for validation computation
Object.assign for efficient property updates
Best Practices Summary
1. Framework Usage
Use
batchfor array mutation operationsImplement memoization with
createStateMemoizerfor expensive state computationsChoose appropriate flush modes (
syncorasync) for computed propertiesOptimize watch expressions and prefer computed properties
Use
throttlefor continuous events,debouncefor discrete user inputEnable
deepobservation 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-heightfor best virtual-repeat performanceEnable
variable-heightonly when necessaryUse pagination or infinite scroll for large datasets
Process data in batches with
batch()to avoid blocking the UIStream large datasets when possible using task queue
4. Binding Optimization
Use
debounceon form inputs (300-500ms for text, 200ms for other fields)Use
throttleon scroll/resize/mousemove handlers (100-200ms)Batch multiple observable changes with
batch()Prefer
@computedwith explicitdepsover complex expressions in templatesUse
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?