Tech Verse Logo
Enable dark mode
Laravel Job Batching: Progress, Cancellation & UI Polling

Laravel Job Batching: Progress, Cancellation & UI Polling

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

5 min read

When you dump 50,000 tasks onto a standard queue, trackability vanishes. You get single-job retry mechanisms and logging, but no native way to answer basic user questions like "How far along is my export?" or "Can I stop this import right now?" That's where Bus::batch() comes in.

Laravel job batching tracks a collection of queued jobs using a dedicated database table. It coordinates execution, captures failures, and exposes state metrics like total jobs, pending jobs, and completion percentage. Pair it with Next.js 16 on the frontend, and you can give your users live feedback with minimal overhead.

Database Setup and Job Preparation

Before dispatching anything, you need the batch database migration. If you haven't published it yet, run:

php artisan queue:batches-table
php artisan migrate

This creates the job_batches table. It holds metadata like total_jobs, pending_jobs, failed_jobs, failed_job_ids, and serialized callback payloads. It also tracks timestamps for cancelled_at and finished_at.

Next, your job classes must use the Illuminate\Bus\Batchable trait. Without this trait, your job won't have access to the underlying $this->batch() helper instance during runtime.

namespace App\Jobs;

use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Models\User;

class ProcessUserExport implements ShouldQueue
{
    use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(public User $user) {}

    public function handle(): void
    {
        // Exit early if the batch was cancelled while this job sat in Redis
        if ($this->batch()?->cancelled()) {
            return;
        }

        // Export logic here...
    }
}

Notice the explicit check for $this->batch()?->cancelled() at the start of handle(). When a user cancels a batch, Laravel marks the record in job_batches as cancelled, but it doesn't instantly yank queued payloads out of Redis queues. Inspecting cancelled() inside the job ensures you don't spend CPU cycles executing leftover jobs from an aborted batch.

Constructing Batches with Bus::batch

You dispatch batches using the Bus::batch() method. It accepts an array or an Enumerable collection of jobs, along with lifecycle callbacks: then(), catch(), and finally().

namespace App\Http\Controllers;

use App\Jobs\ProcessUserExport;
use App\Models\User;
use Illuminate\Bus\Batch;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Log;
use Throwable;

class ExportController extends Controller
{
    public function store(): JsonResponse
    {
        $jobs = User::query()
            ->lazyById(1000)
            ->map(fn (User $user) => new ProcessUserExport($user));

        $batch = Bus::batch($jobs)
            ->name('user-export-' . now()->timestamp)
            ->allowFailures()
            ->then(function (Batch $batch) {
                Log::info("Batch {$batch->id} completed successfully.");
            })
            ->catch(function (Batch $batch, Throwable $e) {
                Log::error("Batch {$batch->id} hit a failure: {$e->getMessage()}");
            })
            ->finally(function (Batch $batch) {
                Log::info("Batch {$batch->id} finished executing all jobs.");
            })
            ->dispatch();

        return response()->json([
            'batch_id' => $batch->id,
        ]);
    }
}

Understanding Canceled Batches and Failure Isolation

By default, if a single job inside a batch throws an uncaught exception, Laravel flags the batch as cancelled right away. No further jobs from that batch will execute if they check $batch->cancelled().

If you prefer the remaining jobs to finish regardless of individual exceptions, call allowFailures() during dispatch. This keeps the batch alive, incrementing failed_jobs while letting sibling jobs process normally. The catch() callback will still execute on the first exception encountered, while finally() triggers once every job has either succeeded or failed.

Building the Next.js 16 and React 19 Progress Monitor

Once your API controller returns the batch_id, the frontend needs to check progress. Rather than setting up complex WebSocket pipelines for short-lived tasks, simple HTTP polling against a thin Laravel status endpoint works cleanly.

First, create an endpoint in Laravel to expose batch statistics:

use Illuminate\Support\Facades\Bus;
use Illuminate\Http\JsonResponse;

public function show(string $id): JsonResponse
{
    $batch = Bus::findBatch($id);

    if (!$batch) {
        return response()->json(['error' => 'Batch not found'], 404);
    }

    return response()->json([
        'id' => $batch->id,
        'total_jobs' => $batch->totalJobs,
        'pending_jobs' => $batch->pendingJobs,
        'failed_jobs' => $batch->failedJobs,
        'progress' => $batch->progress(),
        'finished' => $batch->finished(),
        'cancelled' => $batch->cancelled(),
    ]);
}

Now build a React 19 component in Next.js 16 to poll this route until completion:

'use client';

import { useState, useEffect } from 'react';

interface BatchStatus {
  id: string;
  total_jobs: number;
  pending_jobs: number;
  failed_jobs: number;
  progress: number;
  finished: boolean;
  cancelled: boolean;
}

export default function BatchTracker({ batchId }: { batchId: string }) {
  const [status, setStatus] = useState<BatchStatus | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let timer: NodeJS.Timeout;

    const fetchStatus = async () => {
      try {
        const res = await fetch(`/api/exports/batch/${batchId}`);
        if (!res.ok) throw new Error('Failed to fetch batch status');
        
        const data: BatchStatus = await res.json();
        setStatus(data);

        if (!data.finished && !data.cancelled) {
          timer = setTimeout(fetchStatus, 2000);
        }
      } catch (err: any) {
        setError(err.message);
      }
    };

    fetchStatus();

    return () => clearTimeout(timer);
  }, [batchId]);

  const cancelBatch = async () => {
    await fetch(`/api/exports/batch/${batchId}/cancel`, { method: 'POST' });
  };

  if (error) return <div className="text-red-600">Error: {error}</div>;
  if (!status) return <div>Loading status...</div>;

  return (
    <div className="p-4 border rounded shadow-sm max-w-md">
      <div className="flex justify-between mb-2">
        <span className="font-bold">Export Progress</span>
        <span>{status.progress}%</span>
      </div>
      <div className="w-full bg-gray-200 h-4 rounded overflow-hidden mb-4">
        <div 
          className="bg-blue-600 h-full transition-all duration-300"
          style={{ width: `${status.progress}%` }}
        />
      </div>
      <div className="text-sm text-gray-600 mb-4">
        Pending: {status.pending_jobs} | Failed: {status.failed_jobs}
      </div>
      {!status.finished && !status.cancelled && (
        <button 
          onClick={cancelBatch}
          className="bg-red-500 text-white px-3 py-1 rounded text-sm hover:bg-red-600"
        >
          Cancel Export
        </button>
      )}
    </div>
  );
}

Production Traps: Memory, Race Conditions, and Redis

Batching works out of the box, but scaling it up hits real-world bottlenecks fast.

1. Memory exhaustion during dispatch

If you build an array of 200,000 job objects before calling Bus::batch(), PHP memory limits will crash your script. Use lazyById() or database cursor streaming to build collections lazily, or pass a generator function directly into Bus::batch().

2. The closure serialization problem

Laravel serializes your then(), catch(), and finally() closures directly into the job_batches table using Laravel\SerializableClosure. If you capture huge variables, models, or service instances inside those closures, your DB insert will fail or bloat dramatically. Keep closure scopes minimal. Pass IDs instead of whole model instances.

3. Race conditions on fast jobs

If your batch contains tiny jobs that complete in under 5 milliseconds, worker processes will finish jobs before Laravel finishes inserting the initial batch record into the database. To prevent this, use Bus::batch($jobs)->dispatchNextJobInEngine() or ensure you chain dispatch() after fully building the batch construct.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    Laravel Signed URLs and One-Time Download Links

    Laravel Signed URLs and One-Time Download Links

    Laravel Timezone Handling: UTC, Users, and DST Bugs

    Laravel Timezone Handling: UTC, Users, and DST Bugs

    Laravel Login Throttle: Rate Limiting and Credential Defense

    Laravel Login Throttle: Rate Limiting and Credential Defense

    Building Honest Health Check Endpoints in Laravel

    Building Honest Health Check Endpoints in Laravel

    Writing Production-Ready Laravel Artisan Commands

    Writing Production-Ready Laravel Artisan Commands

    Testing Mail in Laravel: Mailables and Assertions

    Testing Mail in Laravel: Mailables and Assertions

    Solving Low-Priority Queue Starvation in Laravel

    Solving Low-Priority Queue Starvation in Laravel

    Realistic Laravel Seeders with States and Relations

    Realistic Laravel Seeders with States and Relations

    Fixing Laravel Broadcasting Auth and 403 Errors

    Fixing Laravel Broadcasting Auth and 403 Errors

    Laravel Multi Tenancy: Single vs Multi Database

    Laravel Multi Tenancy: Single vs Multi Database