DEV Community

A0mineTV
A0mineTV

Posted on

Mastering Async Cache in Laravel 12: Building Non-Blocking Applications

Laravel 12 introduces a game-changing feature: asynchronous caching with Cache::asyncRemember(). This powerful addition allows you to delegate expensive cache generation to background jobs, dramatically improving response times and user experience. Let's dive deep into this feature and learn how to implement it effectively.

Understanding Async Cache

Traditional caching in Laravel works synchronously - when a cache miss occurs, the application blocks while generating the cached value:

// Traditional synchronous caching
$data = Cache::remember('expensive-data', 3600, function () {
    // This blocks the request for 5+ seconds
    return $this->performExpensiveOperation();
});
Enter fullscreen mode Exit fullscreen mode

With async caching, Laravel immediately returns a placeholder or cached fallback while generating the fresh data in the background:

// New asynchronous caching
$data = Cache::asyncRemember('expensive-data', 3600, function () {
    // This runs in a background job
    return $this->performExpensiveOperation();
});
Enter fullscreen mode Exit fullscreen mode

 How Async Cache Works

  1. Cache Hit: Returns cached data immediately
  2. Cache Miss:
  • Dispatches background job to generate data
  • Returns fallback value or triggers appropriate response
  • Updates cache when job completes

 Complete Implementation Example

Let's build a real-world example: an analytics dashboard that aggregates expensive data.

1- The Expensive Service

First, create a service that performs costly operations:

<?php

namespace App\\Services;

use App\\Models\\Order;
use App\\Models\\User;
use Illuminate\\Support\\Facades\\DB;

class AnalyticsService
{
    public function generateDashboardData(): array
    {
        // Simulate expensive operations
        sleep(5); // Remove in production

        return [
            'total_revenue' => $this->calculateTotalRevenue(),
            'user_growth' => $this->calculateUserGrowth(),
            'top_products' => $this->getTopProducts(),
            'conversion_rates' => $this->calculateConversionRates(),
            'generated_at' => now()->toISOString(),
        ];
    }

    private function calculateTotalRevenue(): float
    {
        return Order::where('created_at', '>=', now()->subDays(30))
            ->sum('total_amount');
    }

    private function calculateUserGrowth(): array
    {
        return DB::table('users')
            ->select(
                DB::raw('DATE(created_at) as date'),
                DB::raw('COUNT(*) as count')
            )
            ->where('created_at', '>=', now()->subDays(30))
            ->groupBy('date')
            ->orderBy('date')
            ->get()
            ->toArray();
    }

    private function getTopProducts(): array
    {
        return DB::table('order_items')
            ->join('products', 'order_items.product_id', '=', 'products.id')
            ->select('products.name', DB::raw('SUM(order_items.quantity) as total_sold'))
            ->groupBy('products.id', 'products.name')
            ->orderByDesc('total_sold')
            ->limit(10)
            ->get()
            ->toArray();
    }

    private function calculateConversionRates(): array
    {
        $visitors = DB::table('page_views')
            ->where('created_at', '>=', now()->subDays(30))
            ->distinct('session_id')
            ->count();

        $orders = Order::where('created_at', '>=', now()->subDays(30))->count();

        return [
            'visitors' => $visitors,
            'orders' => $orders,
            'rate' => $visitors > 0 ? round(($orders / $visitors) * 100, 2) : 0,
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

2- The Background Job

Create a job to handle async cache generation:

<?php

namespace App\\Jobs;

use App\\Services\\AnalyticsService;
use Illuminate\\Bus\\Queueable;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
use Illuminate\\Foundation\\Bus\\Dispatchable;
use Illuminate\\Queue\\InteractsWithQueue;
use Illuminate\\Queue\\SerializesModels;
use Illuminate\\Support\\Facades\\Cache;
use Illuminate\\Support\\Facades\\Log;

class GenerateAnalyticsCacheJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        private string $cacheKey,
        private int $ttl
    ) {}

    public function handle(AnalyticsService $analyticsService): void
    {
        try {
            Log::info("Starting analytics cache generation for key: {$this->cacheKey}");

            $data = $analyticsService->generateDashboardData();

            Cache::put($this->cacheKey, $data, $this->ttl);

            Log::info("Analytics cache generated successfully for key: {$this->cacheKey}");

            // Optionally broadcast completion event
            broadcast(new \\App\\Events\\CacheGenerationCompleted($this->cacheKey, $data));

        } catch (\\Exception $e) {
            Log::error("Failed to generate analytics cache: " . $e->getMessage());
            throw $e;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

3- The Controller with Async Cache

<?php

namespace App\\Http\\Controllers;

use App\\Jobs\\GenerateAnalyticsCacheJob;
use App\\Services\\AnalyticsService;
use Illuminate\\Http\\JsonResponse;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\Cache;

class AnalyticsController extends Controller
{
    public function __construct(
        private AnalyticsService $analyticsService
    ) {}

    public function dashboard(Request $request): JsonResponse
    {
        $cacheKey = 'analytics.dashboard.v1';
        $ttl = 3600; // 1 hour

        // Try to get cached data
        $cachedData = Cache::get($cacheKey);

        if ($cachedData) {
            return response()->json([
                'status' => 'success',
                'data' => $cachedData,
                'cached' => true,
                'cache_age' => now()->diffInSeconds($cachedData['generated_at'] ?? now()),
            ]);
        }

        // Check if generation is already in progress
        $generationKey = "{$cacheKey}.generating";

        if (!Cache::get($generationKey)) {
            // Mark as generating to prevent duplicate jobs
            Cache::put($generationKey, true, 300); // 5 minutes lock

            // Dispatch background job
            GenerateAnalyticsCacheJob::dispatch($cacheKey, $ttl)
                ->onQueue('analytics');
        }

        // Return 202 Accepted with fallback data
        return response()->json([
            'status' => 'generating',
            'message' => 'Analytics data is being generated. Please check back in a moment.',
            'data' => $this->getFallbackData(),
            'retry_after' => 30, // Suggest retry after 30 seconds
        ], 202);
    }

    public function status(Request $request): JsonResponse
    {
        $cacheKey = 'analytics.dashboard.v1';
        $generationKey = "{$cacheKey}.generating";

        $isGenerating = Cache::get($generationKey, false);
        $hasData = Cache::has($cacheKey);

        return response()->json([
            'is_generating' => $isGenerating,
            'has_data' => $hasData,
            'estimated_completion' => $isGenerating ? now()->addSeconds(30) : null,
        ]);
    }

    private function getFallbackData(): array
    {
        return [
            'total_revenue' => 0,
            'user_growth' => [],
            'top_products' => [],
            'conversion_rates' => ['visitors' => 0, 'orders' => 0, 'rate' => 0],
            'generated_at' => now()->toISOString(),
            'is_fallback' => true,
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

4- Queue Configuration

Configure Redis and Horizon for optimal performance:

config/queue.php:

'connections' => [
    'redis' => [
        'driver' => 'redis',
        'connection' => 'default',
        'queue' => env('REDIS_QUEUE', 'default'),
        'retry_after' => 90,
        'block_for' => null,
    ],
],

'failed' => [
    'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
    'database' => env('DB_CONNECTION', 'mysql'),
    'table' => 'failed_jobs',
],
Enter fullscreen mode Exit fullscreen mode

config/horizon.php:

'environments' => [
    'production' => [
        'supervisor-1' => [
            'connection' => 'redis',
            'queue' => ['default', 'analytics'],
            'balance' => 'auto',
            'processes' => 10,
            'tries' => 3,
            'nice' => 0,
        ],
    ],

    'local' => [
        'supervisor-1' => [
            'connection' => 'redis',
            'queue' => ['default', 'analytics'],
            'balance' => 'simple',
            'processes' => 3,
            'tries' => 3,
            'nice' => 0,
        ],
    ],
],
Enter fullscreen mode Exit fullscreen mode

5- Routes

// routes/api.php
Route::prefix('analytics')->group(function () {
    Route::get('/dashboard', [AnalyticsController::class, 'dashboard']);
    Route::get('/status', [AnalyticsController::class, 'status']);
});
Enter fullscreen mode Exit fullscreen mode

 UX/REST Best Practices

1- HTTP Status Codes

Use appropriate status codes to communicate cache state:

// Data available immediately
return response()->json($data, 200);

// Generation in progress
return response()->json([
    'message' => 'Data is being generated',
    'retry_after' => 30
], 202); // Accepted

// Generation failed
return response()->json([
    'error' => 'Failed to generate data'
], 500);
Enter fullscreen mode Exit fullscreen mode

2- Client-Side Polling

Implement smart polling on the frontend:

class AsyncCacheClient {
    constructor(baseUrl) {
        this.baseUrl = baseUrl;
        this.maxRetries = 10;
        this.baseDelay = 1000; // 1 second
    }

    async fetchDashboard() {
        let retries = 0;

        while (retries < this.maxRetries) {
            try {
                const response = await fetch(`${this.baseUrl}/analytics/dashboard`);
                const data = await response.json();

                if (response.status === 200) {
                    return { success: true, data: data.data };
                }

                if (response.status === 202) {
                    // Exponential backoff
                    const delay = Math.min(
                        this.baseDelay * Math.pow(2, retries),
                        30000 // Max 30 seconds
                    );

                    await this.sleep(delay);
                    retries++;
                    continue;
                }

                throw new Error(`HTTP ${response.status}: ${data.message}`);

            } catch (error) {
                if (retries === this.maxRetries - 1) {
                    throw error;
                }
                retries++;
                await this.sleep(this.baseDelay * retries);
            }
        }
    }

    async checkStatus() {
        const response = await fetch(`${this.baseUrl}/analytics/status`);
        return response.json();
    }

    sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }
}

// Usage
const client = new AsyncCacheClient('/api');

client.fetchDashboard()
    .then(result => {
        console.log('Dashboard data:', result.data);
    })
    .catch(error => {
        console.error('Failed to fetch dashboard:', error);
    });
Enter fullscreen mode Exit fullscreen mode

3- Real-time Notifications

Use WebSockets or Server-Sent Events for real-time updates:

// Event class
<?php

namespace App\\Events;

use Illuminate\\Broadcasting\\Channel;
use Illuminate\\Broadcasting\\InteractsWithSockets;
use Illuminate\\Contracts\\Broadcasting\\ShouldBroadcast;
use Illuminate\\Foundation\\Events\\Dispatchable;
use Illuminate\\Queue\\SerializesModels;

class CacheGenerationCompleted implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(
        public string $cacheKey,
        public array $data
    ) {}

    public function broadcastOn(): Channel
    {
        return new Channel('cache-updates');
    }

    public function broadcastWith(): array
    {
        return [
            'cache_key' => $this->cacheKey,
            'completed_at' => now()->toISOString(),
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode
// Frontend WebSocket listener
const echo = new Echo({
    broadcaster: 'pusher',
    key: process.env.MIX_PUSHER_APP_KEY,
    cluster: process.env.MIX_PUSHER_APP_CLUSTER,
});

echo.channel('cache-updates')
    .listen('CacheGenerationCompleted', (e) => {
        if (e.cache_key === 'analytics.dashboard.v1') {
            // Refresh dashboard data
            this.refreshDashboard();
        }
    });
Enter fullscreen mode Exit fullscreen mode

Performance Monitoring

Monitor your async cache performance:

// Add to your job
public function handle(AnalyticsService $analyticsService): void
{
    $startTime = microtime(true);

    try {
        $data = $analyticsService->generateDashboardData();
        Cache::put($this->cacheKey, $data, $this->ttl);

        $duration = microtime(true) - $startTime;

        // Log performance metrics
        Log::info('Cache generation completed', [
            'cache_key' => $this->cacheKey,
            'duration_seconds' => round($duration, 2),
            'memory_peak_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 2),
        ]);

    } catch (\\Exception $e) {
        Log::error('Cache generation failed', [
            'cache_key' => $this->cacheKey,
            'error' => $e->getMessage(),
            'duration_seconds' => round(microtime(true) - $startTime, 2),
        ]);
        throw $e;
    }
}
Enter fullscreen mode Exit fullscreen mode

Best Practices Summary

1- Cache Strategy

  • Use meaningful cache keys with versioning
  • Set appropriate TTL values
  • Implement cache warming strategies

2- Error Handling

  • Always provide fallback data
  • Implement retry mechanisms
  • Log failures for monitoring

3- User Experience

  • Use 202 status codes appropriately
  • Provide estimated completion times
  • Implement progressive loading states

4- Performance

  • Use dedicated queues for cache jobs
  • Monitor job execution times
  • Implement circuit breakers for failing operations

Conclusion

Laravel 12's async caching with Cache::asyncRemember() revolutionizes how we handle expensive operations. By moving cache generation to background jobs, we can:

  • Improve response times - No more blocking requests
  • Enhance user experience - Immediate responses with progressive loading
  • Scale better - Distribute load across queue workers
  • Handle failures gracefully - Fallback data and retry mechanisms

The key to success is implementing proper UX patterns, monitoring performance, and following REST best practices. Start small with one expensive operation and gradually expand to more complex scenarios.

Top comments (0)