The HeartBeat Job — Knowing Your Queue Is Actually Running

The HeartBeat Job — Knowing Your Queue Is Actually Running

Reading Time: 3 minutes

Series: Every Laravel Project Should Have These Building Blocks 
Part: 18 of 35 Level: Intermediate Prerequisites: Jobs and Queues


What You’ll Learn

  • Why queue workers die silently and why that’s dangerous
  • The HeartBeat pattern: a scheduled job that proves the queue is alive
  • Building the HeartBeat model and HeartBeatJob
  • Scheduling it and wiring alerts
  • Connecting it to the Status Page

The Problem: Silent Queue Death

Queue workers are long-running processes. They can die for many reasons:

  • PHP out-of-memory error
  • Database connection timeout
  • Server restart
  • Supervisor misconfiguration
  • OOM killer on low-memory servers

When a worker dies, jobs accumulate in the queue silently. No errors appear in logs. No exception emails fire. Users submit forms, click buttons, and nothing happens on the other side — emails don’t send, orders don’t fulfill, invoices don’t generate. And no one knows until a user complains.

The HeartBeat pattern solves this with a simple idea: schedule a job to write a timestamp to the database every minute. If the timestamp isn’t being updated, the queue is dead.


The heartbeats Table

// database/migrations/create_heartbeats_table.php
Schema::create('heartbeats', function (Blueprint $table): void {
    $table->id();
    $table->string('name')->default('default'); // support multiple queues
    $table->timestamp('last_beat_at')->nullable();
    $table->timestamps();
});

The HeartBeat model:

// app/Models/HeartBeat.php
class HeartBeat extends Model
{
    protected $fillable = ['name', 'last_beat_at'];

    protected function casts(): array
    {
        return [
            'last_beat_at' => 'datetime',
        ];
    }
}

The HeartBeatJob

// app/Jobs/HeartBeatJob.php
<?php

declare(strict_types=1);

namespace App\Jobs;

use App\Models\HeartBeat;
use App\Services\Core\ErrorReporter;
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\Log;
use Throwable;

final class HeartBeatJob implements ShouldQueue
{
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    public int $tries = 1;      // No retries — it either ran or it didn't
    public int $timeout = 30;   // Should complete well within 30 seconds

    public function handle(): void
    {
        HeartBeat::updateOrCreate(
            ['name' => $this->queue ?? 'default'],
            ['last_beat_at' => now()]
        );

        Log::channel('heartbeat')->info('HeartBeat OK', [
            'queue'     => $this->queue ?? 'default',
            'timestamp' => now()->toISOString(),
            'memory_mb' => round(memory_get_usage(true) / 1_048_576, 1),
        ]);
    }

    public function failed(Throwable $e): void
    {
        Log::channel('heartbeat')->error('HeartBeat FAILED', [
            'queue' => $this->queue ?? 'default',
            'error' => $e->getMessage(),
        ]);

        ErrorReporter::report($e);
    }
}

Three things to notice:

  • $tries = 1 — no retries. A heartbeat that fails once is already a signal.
  • It logs memory usage — memory leaks in workers show up as a slow climb over time.
  • failed() calls ErrorReporter::report() — if the heartbeat itself fails, you get an email.

Scheduling the HeartBeat

In routes/console.php (Laravel 11+):

use App\Jobs\HeartBeatJob;
use Illuminate\Support\Facades\Schedule;

// Every minute — tight enough to detect failures quickly
Schedule::job(new HeartBeatJob)->everyMinute()->onQueue('default');

// If you have multiple queues, beat on each one:
Schedule::job((new HeartBeatJob)->onQueue('high'))->everyMinute();
Schedule::job((new HeartBeatJob)->onQueue('notifications'))->everyMinute();

For the scheduler to dispatch the job, the cron must be running:

# crontab -e
* * * * * cd /path/to/project && php artisan schedule:run >> /dev/null 2>&1

Checking Queue Health

The HeartBeat feeds directly into the Status Page’s QueueCheck (see Article 17):

final class QueueCheck implements StatusCheckInterface
{
    public function run(): ServiceCheckResult
    {
        $heartbeat = HeartBeat::where('name', 'default')->first();

        if ($heartbeat === null) {
            return new ServiceCheckResult(
                name: 'Queue',
                icon: 'fa-list-check',
                status: ServiceStatus::Outage,
                message: 'No heartbeat ever recorded',
            );
        }

        $ageSeconds = (int) $heartbeat->last_beat_at->diffInSeconds(now());

        return match(true) {
            $ageSeconds > 300 => new ServiceCheckResult(  // > 5 minutes
                name: 'Queue',
                icon: 'fa-list-check',
                status: ServiceStatus::Outage,
                message: "Queue down — no beat for {$ageSeconds}s",
            ),
            $ageSeconds > 90 => new ServiceCheckResult(   // > 90 seconds
                name: 'Queue',
                icon: 'fa-list-check',
                status: ServiceStatus::Degraded,
                message: "Queue slow — last beat {$ageSeconds}s ago",
            ),
            default => new ServiceCheckResult(
                name: 'Queue',
                icon: 'fa-list-check',
                status: ServiceStatus::Operational,
                message: "Healthy — beat {$ageSeconds}s ago",
            ),
        };
    }
}

Pairing with External Uptime Monitors

Connect the /status endpoint to an external monitor (UptimeRobot, Better Uptime, Freshping) and configure it to alert when the queue check returns non-operational. This gives you:

  • Internal detection — the Status Page knows the queue is dead
  • External alerting — you get a text/email if the web process itself is down too

The two-layer monitoring means you’re notified whether it’s the worker that died or the whole server.


Key Takeaways

  • Queue workers die silently. Without a heartbeat, you won’t know until users complain.
  • HeartBeatJob writes a timestamp every minute via the scheduler. If it stops writing, the queue is dead.
  • $tries = 1 — a heartbeat that needs retrying has already failed its purpose.
  • Log memory usage in handle() to detect worker memory leaks over time.
  • Feed HeartBeat::last_beat_at into the QueueCheck on your Status Page.
  • Wire the Status Page to an external uptime monitor for two-layer alerting.

Tips and Gotchas

⚠️ Warning: Set $tries = 1 on the HeartBeat job. If it fails and retries, a failing queue will repeatedly attempt retries that never succeed, obscuring the real problem. One try, fail fast, alert clearly.

💡 Tip: Monitor HeartBeat::where('name', 'default')->value('last_beat_at') in your Status Page (see Article 17) as a StatusCheckInterface implementation. This gives you one unified health check UI for queue liveness.

🔥 Expert Note: Run the HeartBeat on every named queue, not just default. Schedule separate instances: ->everyMinute()->onQueue('default') and ->everyMinute()->onQueue('emails'). A broken emails queue won’t show up in a default-only heartbeat.

Further Reading


← Caching Strategy | Next: Structured Logging →

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

One Comment