Cron jobs work fine for simple scheduled tasks. The moment you need
reliability, retry logic, and observability in a high-traffic
application, they become a liability. Laravel Queues with Redis
solve all three problems — and the migration is simpler than most
developers assume. This article documents a specific refactor I did
for a fintech client in Delhi NCR where a cron job was causing
40-second page timeouts at peak hours.
The Problem With Cron Jobs in Laravel Applications
I inherited a Laravel codebase from a fintech startup last year.
Their daily transaction reconciliation ran as a cron job every
5 minutes via Laravel's scheduler. On low-traffic days: fine.
On month-end when 8,000 transactions needed reconciling: the
web workers would freeze, queue up, and the admin panel would
time out for 40 seconds.
The root cause is simple: cron jobs in Laravel run synchronously
on the web server process. They consume the same PHP-FPM workers
that serve HTTP requests.
// What we had — runs synchronously on web process
// In App\Console\Kernel.php
$schedule->command('reconcile:transactions')->everyFiveMinutes();
When reconcile:transactions took 35+ seconds, users hitting
the application during that window waited for the same worker.
The Laravel Queue Solution — How It Works
Laravel's Queue system decouples the work from the web process.
Instead of executing the task inline, you dispatch a Job to
a queue driver (Redis, in our case) and a separate worker
process picks it up and runs it in the background.
// The Job class
// app/Jobs/ReconcileTransactions.php
class ReconcileTransactions implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 60; // seconds between retries
public function handle(TransactionService $service): void
{
$service->reconcileAll();
}
public function failed(Throwable $exception): void
{
// Alert via Slack or email — never silent failure
Notification::send(
User::admins(),
new ReconciliationFailed($exception->getMessage())
);
}
}
// Dispatching the job (from scheduler or manually)
ReconcileTransactions::dispatch()
->onQueue('critical')
->delay(now()->addSeconds(5));
Three things this gives you that cron cannot:
- Retry logic — if the job fails, Laravel retries it $tries times
- Failure handling — the failed() method fires on final failure
- Queue priority — 'critical' queue gets workers before 'default'
Horizon — The Missing Observability Layer
The real reason to use Laravel Horizon alongside queues is visibility.
Without it, you are flying blind — you don't know if a job is stuck,
how long jobs are taking, or whether your worker count is sufficient.
composer require laravel/horizon
php artisan horizon:install
php artisan horizon
After installation, Horizon gives you a dashboard at /horizon that shows:
- Jobs processed per minute (throughput)
- Jobs failed and their exception traces
- Queue lengths in real time
- Worker process count and memory usage
For the fintech client above, Horizon immediately revealed that the
reconciliation job was holding a database connection for the entire
35 seconds — an issue invisible with cron. We fixed it by chunking
the transaction processing into 500-row batches using cursor().
// Before: loads all transactions into memory
$transactions = Transaction::pending()->get();
// After: processes in memory-safe 500-row cursor chunks
Transaction::pending()->cursor()->each(function ($transaction) {
$this->reconcileSingle($transaction);
});
Result: peak memory dropped from 380MB to 42MB. Job runtime
from 35 seconds to 4.2 seconds. Zero web worker timeouts since.
When Should You Still Use Cron?
Not every scheduled task needs a queue. Cron is appropriate when:
- The task takes under 2 seconds
- Failure is acceptable without notification
- You don't need retry logic
- The task is purely internal (log cleanup, cache warming)
Cron is the wrong choice when:
- Processing external API calls (which can hang)
- Handling user data where failure must be audited
- The task duration varies based on data volume
- You need to scale workers during peak load
Production Configuration — What I Use
// config/horizon.php — production-ready configuration
'environments' => [
'production' => [
'supervisor-1' => [
'maxProcesses' => 10,
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
'queues' => ['critical', 'default', 'low'],
],
],
],
Supervisor manages the Horizon process in production. If the
worker dies, Supervisor restarts it — zero manual intervention.
; /etc/supervisor/conf.d/horizon.conf
[program:horizon]
process_name=%(program_name)s
command=php /var/www/aruntyagi.com/artisan horizon
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/horizon.log
Cron vs Queue — Decision Table
| Criteria | Cron / Scheduler | Laravel Queue + Redis |
|---|---|---|
| Retry on failure | No | Yes (configurable) |
| Web server impact | Blocks workers | Zero impact |
| Failure notification | Manual | Built-in failed() |
| Dashboard visibility | None | Horizon dashboard |
| Horizontal scaling | Not possible | Add workers on demand |
| Peak load handling | Cannot scale | Auto-balance queues |
| Implementation time | Minutes | 2-4 hours |
Wrapping Up
Cron jobs are not wrong — they are the right tool for simple,
low-stakes scheduled tasks. The mistake is using them for anything
that touches user data, external APIs, or variable-duration workloads.
The fintech project this was based on has processed 2.3 million
queue jobs since the refactor. Failure rate: 0.003%. All failures
notified and resolved within minutes.
I work on these kinds of architectural problems as a freelance
Laravel developer in India. If you're dealing with a similar
performance issue or planning a SaaS application architecture,
feel free to explore my Laravel development services or connect for a
technical consultation on LinkedIn.
— Arun Tyagi | aruntyagi.com | Noida, India
Top comments (0)