Performance
Hyper is designed for speed, but like any framework, understanding performance characteristics helps you build blazingly fast applications. This guide teaches you how to optimize signals, fragments, DOM updates, file uploads, and list rendering for maximum performance.
Performance Philosophy
Hyper's performance comes from doing less work intelligently. Instead of optimizing premature bottlenecks, focus on: small signals, targeted fragments, efficient morphing, and lazy loading.
Understanding Hyper's Performance Model
Hyper's performance depends on three key factors:
1. Signal Payload Size: Every Hyper request sends all signals to the server. Smaller signals = faster requests.
2. Fragment Granularity: Smaller, targeted fragments update faster than large page sections.
3. DOM Morphing Efficiency: The idiomorph algorithm is fast, but replacing huge DOM trees takes time.
The goal: Keep signals lean, fragments focused, and DOM updates surgical.
Signal Performance
Signal Payload Size
Every Hyper request serializes ALL signals as JSON and sends them to the server:
<div @signals(['name' => 'John', 'email' => 'john@example.com', 'age' => 30])>Request payload:
{
"signals": {
"name": "John",
"email": "john@example.com",
"age": 30
}
}Signal Size Matters
Large signals increase request size, slowing down every Hyper request. A 100KB signal payload adds 100KB to every request.
How Many Signals is Too Many?
Guidelines:
| Signal Count | Payload Size | Performance |
|---|---|---|
| 1-20 signals | <5KB | Excellent |
| 20-50 signals | 5-20KB | Good |
| 50-100 signals | 20-50KB | Acceptable |
| 100+ signals | >50KB | Slow - refactor needed |
Measuring your signal payload:
// In browser console
console.log(JSON.stringify(window.store?.value || {}).length / 1024, 'KB');Large Arrays in Signals
Arrays in signals are common, but large arrays hurt performance:
<!-- ❌ Poor - 1000 items in signal -->
<div @signals(['products' => Product::all()->toArray()])>
<template data-for="product in $products">
<div data-text="product.name"></div>
</template>
</div>Problems:
- Sends entire array on every request
- 1000 products = ~200KB payload
- Client-side memory overhead
- Serialization/deserialization cost
Solution: Pagination
<!-- ✅ Good - 20 items at a time -->
<div @signals(['products' => Product::paginate(20)->items()->toArray(), 'page' => 1])>
<template data-for="product in $products">
<div data-text="product.name"></div>
</template>
<button data-on:click="@get('/products?page=' + ($page + 1))">
Load More
</button>
</div>public function loadMore()
{
$page = signals('page', 1);
$products = Product::paginate(20, ['*'], 'page', $page + 1);
return hyper()->signals([
'products' => $products->items()->toArray(),
'page' => $page + 1
]);
}Benefits:
- Small initial payload (20 items vs 1000)
- Fast subsequent requests
- Better perceived performance
Nested Objects and Deep Nesting
Deeply nested objects increase serialization overhead:
<!-- ❌ Poor - Deep nesting -->
<div @signals([
'user' => [
'profile' => [
'address' => [
'street' => '123 Main St',
'city' => ['name' => 'NYC', 'state' => 'NY', 'zip' => '10001'],
'coordinates' => ['lat' => 40.7128, 'lng' => -74.0060]
],
'preferences' => [
'theme' => ['primary' => '#fff', 'secondary' => '#000'],
'notifications' => ['email' => true, 'sms' => false]
]
]
]
])>Problems:
- Harder to access:
$user.profile.address.city.name - Slower serialization
- Difficult to update partially
Solution: Flatten when possible
<!-- ✅ Better - Flat structure -->
<div @signals([
'userName' => $user->name,
'userCity' => $user->city,
'userTheme' => $user->theme,
'notifyEmail' => $user->notify_email
])>When nesting is necessary:
<!-- ✅ Acceptable - Logical grouping -->
<div @signals([
'user' => ['id' => 1, 'name' => 'John'],
'settings' => ['theme' => 'dark', 'lang' => 'en']
])>Local Signals for UI State
Use local signals (underscore prefix) for UI-only state that doesn't need server synchronization:
<!-- ❌ Poor - Sending UI state to server -->
<div @signals([
'products' => $products,
'sidebarOpen' => false,
'modalVisible' => false,
'tooltipText' => '',
'hoveredItem' => null
])>Every request sends UI state the server doesn't need.
✅ Better - Local signals for UI state
<div @signals([
'products' => $products,
'_sidebarOpen' => false,
'_modalVisible' => false,
'_tooltipText' => '',
'_hoveredItem' => null
])>Local signals stay in the browser, reducing payload by 50-80% in UI-heavy apps.
Signal Updates: Partial vs Full
Always update only what changed:
// ❌ Poor - Replacing entire array
public function toggleComplete($id)
{
$todos = Todo::all()->toArray();
$todos = array_map(function($todo) use ($id) {
if ($todo['id'] === $id) {
$todo['completed'] = !$todo['completed'];
}
return $todo;
}, $todos);
return hyper()->signals(['todos' => $todos]);
}
// ✅ Better - Fragment update (no signal replacement)
public function toggleComplete(Todo $todo)
{
$todo->update(['completed' => !$todo->completed]);
return hyper()->fragment('todos.item', 'todo-' . $todo->id, ['todo' => $todo]);
}Benefits:
- No signal payload overhead
- Faster DOM update (single element vs entire list)
- Less client-side reactivity work
Fragment Performance
Fragment Granularity
Fragment size affects update performance:
<!-- ❌ Poor - Updating entire page for small change -->
@fragment('dashboard')
<div id="dashboard">
<div id="stats">{{ $stats }}</div>
<div id="chart">{{ $chart }}</div>
<div id="activity">{{ $recentActivity }}</div>
<div id="notifications">{{ $notifications }}</div>
</div>
@endfragment// Updates everything when only stats changed
return hyper()->fragment('dashboard', 'dashboard', $data);✅ Better - Granular fragments
@fragment('stats')
<div id="stats">{{ $stats }}</div>
@endfragment
@fragment('chart')
<div id="chart">{{ $chart }}</div>
@endfragment
@fragment('activity')
<div id="activity">{{ $recentActivity }}</div>
@endfragment// Only updates what changed
return hyper()->fragment('dashboard', 'stats', ['stats' => $stats]);Performance impact:
| Fragment Size | Elements | Morph Time |
|---|---|---|
| Small (1-10) | 10 | <1ms |
| Medium (10-50) | 50 | 2-5ms |
| Large (50-200) | 200 | 10-20ms |
| Huge (200+) | 500+ | 50-100ms |
Multiple Fragment Updates
Update multiple fragments in one response instead of multiple requests:
// ❌ Poor - Multiple requests
public function dashboard()
{
// Client makes 3 requests
return hyper()->fragment('dashboard', 'stats', $stats);
}
public function chart()
{
return hyper()->fragment('dashboard', 'chart', $chart);
}
public function activity()
{
return hyper()->fragment('dashboard', 'activity', $activity);
}
// ✅ Better - Single request, multiple updates
public function dashboard()
{
return hyper()->fragments([
['view' => 'dashboard', 'fragment' => 'stats', 'data' => $stats],
['view' => 'dashboard', 'fragment' => 'chart', 'data' => $chart],
['view' => 'dashboard', 'fragment' => 'activity', 'data' => $activity]
]);
}Benefits:
- 1 HTTP request instead of 3
- 1 server roundtrip vs 3
- Atomic updates (all-or-nothing)
Fragment Caching
Cache expensive fragments to avoid re-rendering:
// ❌ Poor - Rendering on every request
public function products()
{
$products = Product::with('category', 'images')->get();
return hyper()->fragment('products.grid', 'products', [
'products' => $products
]);
}
// ✅ Better - Cache rendered HTML
public function products()
{
$html = Cache::remember('products-fragment', 60, function() {
$products = Product::with('category', 'images')->get();
return view('fragments.products-grid', ['products' => $products])->render();
});
return hyper()->patchElements($html, '#products');
}When to cache fragments:
- Expensive database queries
- Complex rendering logic
- Data that changes infrequently
- High-traffic pages
Cache invalidation:
// Invalidate on product update
public function updateProduct(Product $product)
{
$product->update($request->validated());
Cache::forget('products-fragment');
return hyper()->fragment('products.grid', 'products', [
'products' => Product::with('category', 'images')->get()
]);
}Lazy Loading Fragments
Load expensive fragments on-demand:
<!-- Initial load: Placeholder -->
<div id="analytics" data-on-intersect__once="@get('/analytics')">
<div class="skeleton-loader">Loading analytics...</div>
</div>public function analytics()
{
// Expensive query only runs when visible
$analytics = DB::table('page_views')
->selectRaw('DATE(created_at) as date, COUNT(*) as views')
->groupBy('date')
->get();
return hyper()->fragment('dashboard.analytics', 'analytics', [
'analytics' => $analytics
]);
}Benefits:
- Faster initial page load
- Only load what users see
- Better perceived performance
DOM Update Performance
Morphing vs Replacing
Morphing (default) is faster than replacing in most cases:
// ✅ Fast - Morphing (default)
return hyper()->patchElements($html, '#list', 'outer');
// ❌ Slower - Replacing
return hyper()->patchElements($html, '#list', 'replace');Benchmarks:
| Mode | 100 Elements | 500 Elements |
|---|---|---|
| Morphing (outer) | 2ms | 10ms |
| Replace | 5ms | 25ms |
Why morphing is faster:
- Reuses existing DOM nodes
- Only updates what changed
- Preserves element references
- No event listener re-attachment
When to use replace:
- Radical structure changes
- Third-party widget re-initialization
- Morphing causing visual glitches
Targeted Selectors
Use specific selectors to limit morphing scope:
<!-- ❌ Poor - Updating entire container -->
<div id="dashboard">
<div id="stats">Stats here</div>
<div id="users">1000 user rows here</div>
</div>// Updates entire dashboard (including 1000 user rows)
return hyper()->patchElements($newStats, '#dashboard', 'inner');
// ✅ Better - Target specific element
return hyper()->patchElements($newStats, '#stats', 'outer');Batch DOM Updates
Combine multiple updates into one:
// ❌ Poor - Multiple patch calls
return hyper()
->patchElements('<div>A</div>', '#a')
->patchElements('<div>B</div>', '#b')
->patchElements('<div>C</div>', '#c');
// ✅ Better - Single fragment with all updates
return hyper()->fragment('dashboard.all', 'container', $data);The browser batches DOM changes from a single response, but multiple responses cause multiple reflows.
Avoid Unnecessary Re-renders
Only update when data actually changed:
public function refresh()
{
$currentCount = signals('count');
$newCount = Order::count();
// ❌ Poor - Always updating
return hyper()->signals(['count' => $newCount]);
// ✅ Better - Only update if changed
if ($currentCount !== $newCount) {
return hyper()->signals(['count' => $newCount]);
}
return hyper()->js('console.log("No changes")');
}File Upload Performance
Base64 Overhead
File inputs are automatically base64-encoded, which adds ~33% overhead:
Original file: 1MBBase64 encoded: 1.33MB
For a 10MB image:
- Encoding time: ~50-100ms (client-side)
- Payload size: 13.3MB
- Upload time: Depends on connection
Guidelines:
| File Size | Base64 Size | Performance |
|---|---|---|
| <100KB | <133KB | Excellent |
| 100KB-1MB | 133KB-1.33MB | Good |
| 1-5MB | 1.33-6.65MB | Acceptable |
| 5-10MB | 6.65-13.3MB | Slow |
| 10MB+ | 13.3MB+ | Very slow - use direct upload |
Optimizing File Uploads
1. Client-side compression:
<input type="file" id="image" accept="image/*" />
<script>
document.getElementById('image').addEventListener('change', async (e) => {
const file = e.target.files[0];
// Compress image before base64 encoding
const compressed = await compressImage(file, {
maxWidth: 1920,
maxHeight: 1080,
quality: 0.8
});
// Now encode and set signal
const base64 = await fileToBase64(compressed);
store.set('image', base64);
});
</script>2. Lazy upload on submit:
Don't encode files immediately; wait until form submission:
<!-- ❌ Poor - Encoding on file selection -->
<input type="file" data-bind="avatar" />
<!-- ✅ Better - Manual encoding on submit -->
<input type="file" id="avatar" />
<button data-on:click="uploadAvatar()">Upload</button>
<script>
async function uploadAvatar() {
const file = document.getElementById('avatar').files[0];
const base64 = await fileToBase64(file);
store.set('avatar', base64);
// Now submit
await fetch('/upload-avatar', { method: 'POST', /* ... */ });
}
</script>3. Direct upload for large files:
For files >5MB, use direct upload instead of signals:
<form id="upload-form" enctype="multipart/form-data">
<input type="file" name="video" />
<button type="submit">Upload</button>
</form>
<script>
document.getElementById('upload-form').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
await fetch('/upload-video', {
method: 'POST',
body: formData,
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
}
});
});
</script>public function uploadVideo(Request $request)
{
$request->validate(['video' => 'required|file|max:102400']); // 100MB
$path = $request->file('video')->store('videos', 'public');
return hyper()->signals(['videoPath' => $path]);
}4. Progress indicators:
Show upload progress for better UX:
<input type="file" id="file" />
<div id="progress" style="display:none;">
<div data-text="$uploadProgress + '%'"></div>
<div class="progress-bar" data-attr:style="'width: ' + $uploadProgress + '%'"></div>
</div>
<button data-on:click="uploadFile()">Upload</button>async function uploadFile() {
const file = document.getElementById('file').files[0];
store.set('uploadProgress', 0);
document.getElementById('progress').style.display = 'block';
// Simulate progress (or use XHR for real progress)
const interval = setInterval(() => {
const current = store.get('uploadProgress');
if (current < 90) {
store.set('uploadProgress', current + 10);
}
}, 200);
const base64 = await fileToBase64(file);
store.set('file', base64);
await fetch('/upload', { /* ... */ });
clearInterval(interval);
store.set('uploadProgress', 100);
}List Rendering Performance
data-for with Large Lists
The data-for directive renders lists reactively:
<!-- ❌ Poor - 1000 items rendered immediately -->
<template data-for="item in $items" data-for__key="id">
<div data-text="item.name"></div>
</template>Problems with large lists:
- Initial render: 100ms for 1000 items
- Signal payload: Large array on every request
- Memory overhead: 1000 DOM nodes
Solution 1: Virtual scrolling
Render only visible items:
<div @signals(['allItems' => $items, 'visibleStart' => 0, 'visibleEnd' => 50])>
<template data-for="item in $allItems.slice($visibleStart, $visibleEnd)" data-for__key="id">
<div data-text="item.name"></div>
</template>
<button data-on:click="$visibleEnd = $visibleEnd + 50">Load More</button>
</div>Solution 2: Server-side pagination
Don't send all items to the client:
<div @signals(['items' => [], 'page' => 1, 'hasMore' => true])>
<template data-for="item in $items" data-for__key="id">
<div data-text="item.name"></div>
</template>
<button data-on:click="@get('/load-more')" data-show="$hasMore">
Load More
</button>
</div>public function loadMore()
{
$page = signals('page', 1);
$items = signals('items', []);
$newItems = Item::paginate(50, ['*'], 'page', $page + 1);
return hyper()->signals([
'items' => array_merge($items, $newItems->items()->toArray()),
'page' => $page + 1,
'hasMore' => $newItems->hasMorePages()
]);
}Solution 3: Fragment-based rendering
Use fragments instead of signals for lists:
<div id="items-list">
@foreach($items as $item)
<div id="item-{{ $item->id }}">{{ $item->name }}</div>
@endforeach
</div>
<button data-on:click="@get('/load-more')">Load More</button>public function loadMore()
{
$items = Item::paginate(50);
$html = view('partials.items', ['items' => $items])->render();
return hyper()->patchElements($html, '#items-list', 'append');
}Using Keys Effectively
Always provide keys for better performance:
<!-- ❌ Poor - No keys, index-based matching -->
<template data-for="item in $items">
<div data-text="item.name"></div>
</template>
<!-- ✅ Better - Keys for efficient updates -->
<template data-for="item in $items" data-for__key="id">
<div data-text="item.name"></div>
</template>Without keys, reordering 100 items causes 100 DOM replacements. With keys, reordering 100 items updates only attributes (no DOM recreation).
Debouncing and Throttling
Search Input Debouncing
Avoid making requests on every keystroke:
<!-- ❌ Poor - Request on every keystroke -->
<input data-bind="search" data-on:input="@get('/search')" />
<!-- ✅ Better - Debounced (300ms) -->
<input data-bind="search" data-on:input__debounce.300ms="@get('/search')" />Performance impact:
| Typing Speed | Without Debounce | With 300ms Debounce |
|---|---|---|
| Fast (10 chars/sec) | 10 requests | 1-2 requests |
| Normal (5 chars/sec) | 5 requests | 1 request |
| Slow (2 chars/sec) | 2 requests | 1 request |
Scroll and Resize Throttling
Limit how often expensive handlers run:
<!-- ❌ Poor - Running on every scroll event (60fps = 60 calls/sec) -->
<div data-on:scroll="@get('/update-position')">
<!-- ✅ Better - Throttled to max 10 calls/sec -->
<div data-on:scroll__throttle.100ms="@get('/update-position')">Choosing Debounce vs Throttle
Debounce: Wait for user to stop typing/scrolling
<input data-on:input__debounce.300ms="@get('/search')" />Use for:
- Search inputs
- Form validation
- Auto-save functionality
Throttle: Limit execution frequency, but still execute during action
<div data-on:scroll__throttle.100ms="handleScroll()">Use for:
- Scroll position updates
- Window resize handlers
- Infinite scroll loading
Caching Strategies
Fragment Response Caching
Cache entire HTTP responses:
public function dashboard()
{
return Cache::remember('dashboard-response', 60, function() {
$stats = $this->getStats();
return hyper()->fragment('dashboard.stats', 'stats', ['stats' => $stats]);
});
}Gotcha: Cached responses include signals from the cached request. Use carefully.
View Caching
Cache Blade views separately:
public function products()
{
$html = Cache::remember('products-html', 3600, function() {
return view('products.grid', ['products' => Product::all()])->render();
});
return hyper()->patchElements($html, '#products');
}Signal Computation Caching
Cache expensive signal calculations:
public function analytics()
{
$userId = signals('userId_');
$analytics = Cache::remember("analytics-{$userId}", 300, function() use ($userId) {
return DB::table('events')
->where('user_id', $userId)
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupBy('date')
->get()
->toArray();
});
return hyper()->signals(['analytics' => $analytics]);
}Cache Invalidation Patterns
Time-based expiration:
Cache::remember('key', 60, fn() => /* expensive operation */);Event-based invalidation:
public function updateProduct(Product $product)
{
$product->update($data);
Cache::forget('products-html');
Cache::forget('product-' . $product->id);
return hyper()->fragment('products.grid', 'products', ['products' => Product::all()]);
}Tag-based invalidation:
// Cache with tags
Cache::tags(['products', 'featured'])->put('featured-products', $products, 3600);
// Invalidate all product caches
Cache::tags(['products'])->flush();Profiling and Debugging
Browser DevTools Performance Tab
Record Hyper interactions:
- Open DevTools → Performance
- Click Record
- Trigger Hyper action (click button, type in input)
- Stop recording
- Analyze:
- Scripting time: JavaScript execution
- Rendering time: DOM morphing
- Painting time: Browser paint
Look for:
- Long tasks (>50ms)
- Layout thrashing
- Excessive paint operations
Network Tab Analysis
Monitor request/response sizes:
- Open DevTools → Network
- Filter by XHR
- Trigger Hyper request
- Analyze:
- Request payload: Signal size
- Response size: HTML fragment size
- Time: Server processing + network
Benchmarks:
| Metric | Good | Acceptable | Slow |
|---|---|---|---|
| Request payload | <5KB | 5-20KB | >20KB |
| Response size | <10KB | 10-50KB | >50KB |
| Total time | <100ms | 100-500ms | >500ms |
Laravel Telescope
Monitor server-side performance:
// Install Telescope
composer require laravel/telescope
php artisan telescope:install
php artisan migrateView Hyper requests:
- Navigate to
/telescope/requests - Filter by
Datastar-Requestheader - Analyze:
- Duration: Controller execution time
- Memory: Peak memory usage
- Queries: Database query count
Optimization targets:
- Duration <100ms
- Memory <10MB
- Queries <10 per request
Custom Performance Logging
Log Hyper-specific metrics:
public function search(Request $request)
{
$start = microtime(true);
$query = signals('search', '');
$results = Product::where('name', 'like', "%{$query}%")->get();
$duration = (microtime(true) - $start) * 1000;
Log::info('Hyper search performance', [
'query' => $query,
'results_count' => $results->count(),
'duration_ms' => round($duration, 2),
'signal_payload_size' => strlen(json_encode(signals()->all()))
]);
return hyper()->fragment('products.results', 'results', ['results' => $results]);
}Client-Side Performance Monitoring
Track frontend performance:
// Add to your app
document.addEventListener('datastar-request-start', (e) => {
console.time('hyper-request');
});
document.addEventListener('datastar-request-end', (e) => {
console.timeEnd('hyper-request');
const payloadSize = JSON.stringify(window.store?.value || {}).length;
console.log('Signal payload size:', (payloadSize / 1024).toFixed(2), 'KB');
});Performance Checklist
Before deploying, verify:
- Total signal payload <20KB
- Largest fragment <50KB
- No lists >100 items in signals
- File uploads <5MB or using direct upload
- Search inputs debounced
- Scroll handlers throttled
- Expensive fragments cached
- Database queries <10 per request
- Server response time <200ms
- No JavaScript errors in console
- Tested on slow 3G connection
Learn More
- Signals - Understanding signal fundamentals
- Fragments - Fragment rendering strategies
- DOM Morphing - Morphing performance characteristics
- Lists & Loops - Efficient list rendering with data-for
- File Uploads - Base64 upload optimization
- Laravel Telescope - Server-side profiling

