Implementing Progress Bar for Long Background Tasks on Website
When user launches task taking more than 2–3 seconds, interface must show progress. Otherwise — repeated clicks, page closing, support calls. Task technically non-trivial: progress formed on server, need to transmit to browser without polling every 500ms.
Architecture
Scheme works like this: client launches task → gets job_id → subscribes to updates via SSE or WebSocket → backend processes task in queue → worker periodically publishes progress to Redis → SSE/WebSocket server delivers updates to client.
Polling (requests every N seconds) works, but creates unnecessary load and jerky UX. SSE — right choice for one-directional progress stream.
Backend: Laravel + Redis
// app/Jobs/ProcessImportJob.php
class ProcessImportJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
private readonly string $jobId,
private readonly int $userId,
private readonly string $filePath
) {}
public function handle(): void
{
$rows = $this->parseFile($this->filePath);
$total = count($rows);
foreach ($rows as $index => $row) {
$this->processRow($row);
// Publish progress every 10 records
if ($index % 10 === 0 || $index === $total - 1) {
$progress = (int)(($index + 1) / $total * 100);
$this->publishProgress($progress, $index + 1, $total);
}
}
$this->publishProgress(100, $total, $total, 'completed');
}
private function publishProgress(
int $percent,
int $processed,
int $total,
string $status = 'running'
): void
{
$payload = json_encode([
'jobId' => $this->jobId,
'percent' => $percent,
'processed' => $processed,
'total' => $total,
'status' => $status,
'ts' => microtime(true),
]);
// Publish to Redis Pub/Sub
Redis::publish("job-progress:{$this->userId}", $payload);
// Save last state for reconnects
Redis::setex("job-progress-state:{$this->jobId}", 3600, $payload);
}
}
// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
Route::post('/jobs/import', function (Request $request) {
$jobId = Str::uuid()->toString();
ProcessImportJob::dispatch($jobId, Auth::id(), $request->file('csv')->store('imports'));
return response()->json(['jobId' => $jobId]);
});
Route::get('/jobs/{jobId}/progress-stream', function (string $jobId) {
// Check job ownership
abort_unless(JobOwnership::check($jobId, Auth::id()), 403);
return response()->stream(function () use ($jobId) {
// Return last known state immediately
$lastState = Redis::get("job-progress-state:{$jobId}");
if ($lastState) {
echo "data: {$lastState}\n\n";
ob_flush();
flush();
}
$redis = new \Redis();
$redis->connect(config('database.redis.default.host'));
$redis->subscribe(["job-progress:" . Auth::id()], function ($redis, $channel, $message) use ($jobId) {
$data = json_decode($message, true);
if ($data['jobId'] !== $jobId) return; // filter by jobId
echo "data: {$message}\n\n";
ob_flush();
flush();
if ($data['status'] === 'completed' || $data['status'] === 'failed') {
$redis->unsubscribe();
}
});
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'X-Accel-Buffering' => 'no', // disable Nginx buffering
'Connection' => 'keep-alive',
]);
});
});
Frontend: React Component
import { useState, useEffect, useRef } from 'react';
interface JobProgress {
jobId: string;
percent: number;
processed: number;
total: number;
status: 'running' | 'completed' | 'failed';
}
function useJobProgress(jobId: string | null) {
const [progress, setProgress] = useState<JobProgress | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
useEffect(() => {
if (!jobId) return;
const es = new EventSource(`/api/jobs/${jobId}/progress-stream`, {
withCredentials: true,
});
es.onmessage = (event) => {
const data: JobProgress = JSON.parse(event.data);
setProgress(data);
if (data.status === 'completed' || data.status === 'failed') {
es.close();
}
};
es.onerror = () => {
// SSE auto-reconnects on error
// but on 403/404 — no, so check status
es.close();
};
eventSourceRef.current = es;
return () => es.close();
}, [jobId]);
return progress;
}
interface ProgressBarProps {
percent: number;
status: JobProgress['status'];
processed: number;
total: number;
}
function ProgressBar({ percent, status, processed, total }: ProgressBarProps) {
return (
<div className="w-full">
<div className="flex justify-between text-sm text-gray-600 mb-1">
<span>
{status === 'completed' ? 'Done' : `Processed ${processed} of ${total}`}
</span>
<span>{percent}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className={`h-2.5 rounded-full transition-all duration-300 ${
status === 'failed' ? 'bg-red-500' :
status === 'completed' ? 'bg-green-500' :
'bg-blue-600'
}`}
style={{ width: `${percent}%` }}
/>
</div>
</div>
);
}
export function ImportWithProgress() {
const [jobId, setJobId] = useState<string | null>(null);
const progress = useJobProgress(jobId);
async function handleFileUpload(file: File) {
const formData = new FormData();
formData.append('csv', file);
const res = await fetch('/api/jobs/import', {
method: 'POST',
body: formData,
});
const { jobId } = await res.json();
setJobId(jobId);
}
return (
<div>
{!jobId && (
<input
type="file"
accept=".csv"
onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0])}
/>
)}
{progress && (
<ProgressBar
percent={progress.percent}
status={progress.status}
processed={progress.processed}
total={progress.total}
/>
)}
{progress?.status === 'completed' && (
<p className="text-green-600 mt-2">Import completed successfully</p>
)}
</div>
);
}
Nginx Configuration
SSE requires disabling buffering at proxy level:
location /api/jobs/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s; // long tasks
chunked_transfer_encoding on;
}
Handling Worker Crash
If worker crashes mid-task, progress hangs. Need timeout:
// In SSE controller after subscription
$timeout = 0;
$maxWait = 300; // 5 minutes
// Periodically check if task died
while ($timeout < $maxWait) {
sleep(5);
$timeout += 5;
$state = Redis::get("job-progress-state:{$jobId}");
if (!$state) {
echo "data: " . json_encode(['status' => 'failed', 'error' => 'timeout']) . "\n\n";
flush();
break;
}
}
Timeline
Progress bar for one task type with SSE — 1–2 days. Universal system with multiple task types, error handling, user task history and monitoring stalled jobs — 4–5 days.







