Skip to content

How we made our blog fast: queues, caching and indexes

Background jobs, view counting, N+1 fixes, caching at several levels, frontend tweaks and database indexes: what we did to keep the blog quick as traffic grows.

AuthorIT Department
  • 5 min read
  • 210 views
How we made our blog fast: queues, caching and indexes

The earlier articles covered the architecture and the content management system. This one is about speed and preparing the blog to scale. A nice interface does not help if pages load slowly.

Asynchronous processing with jobs

One of the key decisions was moving heavy operations into background tasks. View tracking is a good example:

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

    protected string $ipAddress;
    protected Post $post;
    protected Content $content;

    public function __construct(string $ipAddress, Post $post, Content $content)
    {
        $this->ipAddress = $ipAddress;
        $this->post = $post;
        $this->content = $content;
    }

    public function handle()
    {
        // Check if there was already a view from this IP in the last 24 hours
        $recentView = View::where('post_id', $this->post->id)
            ->where('ip_address', $this->ipAddress)
            ->where('created_at', '>', now()->subDay())
            ->exists();

        if (!$recentView) {
            View::create([
                'post_id' => $this->post->id,
                'content_id' => $this->content->id,
                'ip_address' => $this->ipAddress,
            ]);
        }
    }
}

In the controller, we only dispatch the job to the queue:

public function show(string $language, string $slug)
{
    $content = Content::where('slug', $slug)
        ->where('language', $language)
        ->firstOrFail();
    
    $post = $content->post()
        ->with(['author', 'categories', 'tags'])
        ->first();
    
    // Asynchronous view registration
    PostViewTracker::dispatch(
        request()->ip(),
        $post,
        $content
    );
    
    return view('blog.show', compact('post', 'content'));
}

View counting

Instead of incrementing a counter directly, we use an event-driven model:

class View extends Model
{
    protected $table = 'admin_blog_posts_views';
    
    protected $fillable = [
        'uuid',
        'post_id',
        'content_id',
        'ip_address',
    ];

    public static function boot()
    {
        parent::boot();

        static::created(function (View $model) {
            // Update counter only after successful record creation
            $model->post->increment('initial_view_count');
            
            // Invalidate cache
            Cache::tags(['posts', "post-{$model->post_id}"])->flush();
        });
    }
}

Publication scheduler

Scheduled posts are published automatically by Laravel commands:

class PublishScheduledPosts extends Command
{
    protected $signature = 'blog:publish-scheduled {--dry-run}';

    public function handle(): int
    {
        $isDryRun = $this->option('dry-run');
        $now = now();
        
        $scheduledPosts = Post::whereNotNull('scheduled_at')
            ->whereNull('published_at')
            ->where('scheduled_at', '<=', $now)
            ->lockForUpdate() // Prevent race conditions
            ->get();

        if ($scheduledPosts->isEmpty()) {
            $this->info('No posts scheduled for publishing.');
            return self::SUCCESS;
        }

        $this->info("Found {$scheduledPosts->count()} post(s) ready for publishing:");

        foreach ($scheduledPosts as $post) {
            try {
                if (!$isDryRun) {
                    $post->update([
                        'published_at' => $now,
                        'scheduled_at' => null,
                    ]);
                    
                    // Send notifications to subscribers
                    PublishPostNotification::dispatch($post);
                    
                    Log::info("Published scheduled post", [
                        'post_id' => $post->id,
                        'scheduled_at' => $post->scheduled_at,
                    ]);
                }
                
                $this->info("✓ Published post ID: {$post->id}");
                
            } catch (Exception $e) {
                $this->error("✗ Failed to publish post {$post->id}: {$e->getMessage()}");
                Log::error("Failed to publish scheduled post", [
                    'post_id' => $post->id,
                    'error' => $e->getMessage(),
                ]);
            }
        }
        
        return self::SUCCESS;
    }
}

Registering the schedule:

// app/Providers/AppServiceProvider.php
$schedule->command(PublishScheduledPosts::class)
    ->everyMinute()
    ->withoutOverlapping()
    ->runInBackground();

Database query optimization

N+1 queries were one of our main performance problems. We fixed them with eager loading and optimized scopes:

// Bad - N+1 problem
$posts = Post::all();
foreach ($posts as $post) {
    echo $post->author->name; // Additional query for each post
}

// Good - eager loading
$posts = Post::with([
    'author',
    'categories',
    'tags',
    'contents' => function ($query) {
        $query->where('language', app()->getLocale());
    }
])->get();

Scopes for the more complex queries:

public function scopePublished(Builder $query): Builder
{
    return $query->whereNotNull('published_at')
        ->where('published_at', '<=', now())
        ->whereNull('archived_at');
}

public function scopeWithLocalContent(Builder $query, string $language = null): Builder
{
    $language = $language ?? app()->getLocale();
    
    return $query->with(['contents' => function ($q) use ($language) {
        $q->where('language', $language);
    }]);
}

// Usage
$posts = Post::published()
    ->withLocalContent()
    ->orderBy('published_at', 'desc')
    ->paginate(10);

Multi-level caching

Caching works at three levels:

1. Query caching

class PostRepository
{
    public function getPublishedPosts(int $page = 1, int $perPage = 10)
    {
        $cacheKey = "posts.published.{$page}.{$perPage}." . app()->getLocale();
        
        return Cache::tags(['posts', 'published'])
            ->remember($cacheKey, now()->addMinutes(10), function () use ($page, $perPage) {
                return Post::published()
                    ->withLocalContent()
                    ->with(['author', 'categories', 'tags'])
                    ->orderBy('published_at', 'desc')
                    ->paginate($perPage, ['*'], 'page', $page);
            });
    }
    
    public function clearCache(): void
    {
        Cache::tags(['posts'])->flush();
    }
}

2. HTTP-level caching

// Middleware for HTTP response caching
class CacheResponse
{
    public function handle($request, Closure $next)
    {
        // Cache only GET requests for guests
        if ($request->isMethod('GET') && !auth()->check()) {
            $key = 'response.' . md5($request->fullUrl());
            
            return Cache::remember($key, now()->addMinutes(5), function () use ($request, $next) {
                return $next($request);
            });
        }
        
        return $next($request);
    }
}

3. RSS feed caching

public static function getFeedItems()
{
    return Cache::remember('feed_items', now()->addMinutes(10), function () {
        return self::published()
            ->where(function(Builder $query) {
                $query->whereNull('is_generated')
                    ->orWhere('is_generated', false);
            })
            ->with(['author'])
            ->latest('published_at')
            ->take(10)
            ->get();
    });
}

Frontend optimization

1. Lazy component loading

// Instead of direct import
import PostEditor from '@/components/PostEditor.vue';

// Use dynamic import
const PostEditor = () => import('@/components/PostEditor.vue');

2. Virtualizing long lists

<template>
  <VirtualList
    :items="posts"
    :item-height="120"
    :buffer="5"
    v-slot="{ item }"
  >
    <PostCard :post="item" />
  </VirtualList>
</template>

<script setup>
import { VirtualList } from '@tanstack/vue-virtual';

// Component renders only visible elements
// saving memory and speeding up rendering
</script>

3. Search debouncing

const debouncedSearch = debounce(() => {
  performSearch();
}, 500);

watch(() => form.search, (newSearch) => {
  if (newSearch.length >= 3 || newSearch.length === 0) {
    debouncedSearch();
  }
});

Performance monitoring

We watch a few key metrics:

class PerformanceMonitor
{
    public static function trackPageLoad(string $page, float $loadTime)
    {
        DB::table('performance_metrics')->insert([
            'page' => $page,
            'load_time' => $loadTime,
            'memory_usage' => memory_get_peak_usage(true),
            'created_at' => now(),
        ]);
        
        // Alert on slow loading
        if ($loadTime > 2.0) {
            Log::warning("Slow page load detected", [
                'page' => $page,
                'load_time' => $loadTime,
            ]);
        }
    }
}

Database optimization

Proper indexes matter a great deal for performance:

Schema::table('admin_blog_posts', function (Blueprint $table) {
    // Composite index for frequent queries
    $table->index(['published_at', 'archived_at']);
    
    // Index for sorting
    $table->index('created_at');
});

Schema::table('admin_blog_posts_contents', function (Blueprint $table) {
    // Unique composite index
    $table->unique(['post_id', 'language']);
    
    // Index for slug search
    $table->index('slug');
});

Results

After all these changes, we measured:

  • Main page load time: under 200 ms

  • Post load time: under 150 ms

  • Throughput: over 10,000 requests per second

  • Memory usage: 60% lower thanks to caching

Analysis tools

For monitoring we use a few tools:

# Laravel Debugbar for development
composer require barryvdh/laravel-debugbar --dev

# Telescope for production monitoring
composer require laravel/telescope

# Query analysis
DB::enableQueryLog();
// ... code ...
dd(DB::getQueryLog());

Takeaways

There was no single big optimization. Speed came from many small improvements:

  • Heavy operations run asynchronously

  • Database indexes are used properly

  • Caching works at several levels

  • The frontend is optimized

  • Monitoring never stops

The next article is about user experience: the interface, the filtering system and the preview mode for authors.

To be continued.

Tags

Share this article

Back to blog

Cookies

We use essential cookies and one functional cookie, the latter only with your consent. Visit statistics are counted on our server, without cookies. Cookie Policy