Skip to content

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:

blade
<div @signals(['name' => 'John', 'email' => 'john@example.com', 'age' => 30])>

Request payload:

json
{
  "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 CountPayload SizePerformance
1-20 signals<5KBExcellent
20-50 signals5-20KBGood
50-100 signals20-50KBAcceptable
100+ signals>50KBSlow - refactor needed

Measuring your signal payload:

javascript
// 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:

blade
<!-- ❌ 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

blade
<!-- ✅ 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>
php
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:

blade
<!-- ❌ 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

blade
<!-- ✅ Better - Flat structure -->
<div @signals([
    'userName' => $user->name,
    'userCity' => $user->city,
    'userTheme' => $user->theme,
    'notifyEmail' => $user->notify_email
])>

When nesting is necessary:

blade
<!-- ✅ 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:

blade
<!-- ❌ 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

blade
<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:

php
// ❌ 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:

blade
<!-- ❌ 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
php
// Updates everything when only stats changed
return hyper()->fragment('dashboard', 'dashboard', $data);

✅ Better - Granular fragments

blade
@fragment('stats')
<div id="stats">{{ $stats }}</div>
@endfragment

@fragment('chart')
<div id="chart">{{ $chart }}</div>
@endfragment

@fragment('activity')
<div id="activity">{{ $recentActivity }}</div>
@endfragment
php
// Only updates what changed
return hyper()->fragment('dashboard', 'stats', ['stats' => $stats]);

Performance impact:

Fragment SizeElementsMorph Time
Small (1-10)10<1ms
Medium (10-50)502-5ms
Large (50-200)20010-20ms
Huge (200+)500+50-100ms

Multiple Fragment Updates

Update multiple fragments in one response instead of multiple requests:

php
// ❌ 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:

php
// ❌ 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:

php
// 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:

blade
<!-- Initial load: Placeholder -->
<div id="analytics" data-on-intersect__once="@get('/analytics')">
    <div class="skeleton-loader">Loading analytics...</div>
</div>
php
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:

php
// ✅ Fast - Morphing (default)
return hyper()->patchElements($html, '#list', 'outer');

// ❌ Slower - Replacing
return hyper()->patchElements($html, '#list', 'replace');

Benchmarks:

Mode100 Elements500 Elements
Morphing (outer)2ms10ms
Replace5ms25ms

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:

blade
<!-- ❌ Poor - Updating entire container -->
<div id="dashboard">
    <div id="stats">Stats here</div>
    <div id="users">1000 user rows here</div>
</div>
php
// 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:

php
// ❌ 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:

php
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 SizeBase64 SizePerformance
<100KB<133KBExcellent
100KB-1MB133KB-1.33MBGood
1-5MB1.33-6.65MBAcceptable
5-10MB6.65-13.3MBSlow
10MB+13.3MB+Very slow - use direct upload

Optimizing File Uploads

1. Client-side compression:

blade
<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:

blade
<!-- ❌ 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:

blade
<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>
php
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:

blade
<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>
javascript
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:

blade
<!-- ❌ 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:

blade
<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:

blade
<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>
php
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:

blade
<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>
php
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:

blade
<!-- ❌ 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:

blade
<!-- ❌ 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 SpeedWithout DebounceWith 300ms Debounce
Fast (10 chars/sec)10 requests1-2 requests
Normal (5 chars/sec)5 requests1 request
Slow (2 chars/sec)2 requests1 request

Scroll and Resize Throttling

Limit how often expensive handlers run:

blade
<!-- ❌ 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

blade
<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

blade
<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:

php
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:

php
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:

php
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:

php
Cache::remember('key', 60, fn() => /* expensive operation */);

Event-based invalidation:

php
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:

php
// 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:

  1. Open DevTools → Performance
  2. Click Record
  3. Trigger Hyper action (click button, type in input)
  4. Stop recording
  5. 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:

  1. Open DevTools → Network
  2. Filter by XHR
  3. Trigger Hyper request
  4. Analyze:
    • Request payload: Signal size
    • Response size: HTML fragment size
    • Time: Server processing + network

Benchmarks:

MetricGoodAcceptableSlow
Request payload<5KB5-20KB>20KB
Response size<10KB10-50KB>50KB
Total time<100ms100-500ms>500ms

Laravel Telescope

Monitor server-side performance:

php
// Install Telescope
composer require laravel/telescope

php artisan telescope:install
php artisan migrate

View Hyper requests:

  1. Navigate to /telescope/requests
  2. Filter by Datastar-Request header
  3. 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:

php
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:

javascript
// 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