Why Async Request Handling Matters
I spent three years building REST APIs before I truly understood the cost of synchronous request handling. At CodeBrew Labs, we had a notification service that was blocking user requests for 2–3 seconds while sending emails to 10K+ users. Our API response times tanked, and so did user experience.
That's when I realized: async request handling isn't optional—it's foundational to scalable full-stack development. Whether you're using Node.js backend or Laravel, processing heavy workloads asynchronously can transform your API performance from sluggish to blazing fast.
In this post, I'll share what I've learned from building production REST APIs across both ecosystems. We'll cover practical patterns, queue systems, and the exact decisions you need to make when choosing between Node.js and Laravel for async-heavy workloads.
Node.js Async Request Patterns
Node.js is inherently asynchronous—it's in the DNA. But knowing how to leverage that asynchronicity for REST API design is a different beast altogether.
Promise-Based Queuing with Bull
When I built the AudioBook AI platform (50K+ users), we needed to process PDF-to-audio conversions without blocking the upload API. I chose Bull, a Redis-backed job queue for Node.js, and it changed everything.
Here's why Bull works so well:
- Built on Redis, so it's fast and reliable
- Automatic retries with exponential backoff
- Job progress tracking and completion events
- Scales horizontally across worker processes
The pattern is simple: accept the request, queue the job, return immediately. Process later.
import Queue from 'bull';
import Redis from 'ioredis';
const redis = new Redis();
const audioConversionQueue = new Queue('audio-conversion', { redis });
// Process jobs in background
audioConversionQueue.process(5, async (job) => {
const { pdfUrl, userId } = job.data;
console.log(`Processing PDF for user ${userId}`);
try {
const audioBuffer = await convertPdfToAudio(pdfUrl);
await saveAudioToStorage(userId, audioBuffer);
// Notify user via WebSocket or webhook
await notifyUserCompletion(userId);
return { success: true, audioUrl: `${CDN_URL}/${userId}/audio.mp3` };
} catch (error) {
throw error; // Bull handles retries
}
});
// In your Express route
app.post('/api/convert-pdf', async (req, res) => {
const { pdfUrl } = req.body;
const userId = req.user.id;
// Queue the job, don't wait for it
const job = await audioConversionQueue.add(
{ pdfUrl, userId },
{
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000
},
removeOnComplete: true
}
);
// Return immediately
res.status(202).json({
message: 'Conversion started',
jobId: job.id,
statusUrl: `/api/conversion-status/${job.id}`
});
});
// Expose job status endpoint
app.get('/api/conversion-status/:jobId', async (req, res) => {
const job = await audioConversionQueue.getJob(req.params.jobId);
if (!job) {
return res.status(404).json({ error: 'Job not found' });
}
const state = await job.getState();
const progress = job._progress;
res.json({ state, progress, jobId: job.id });
});This pattern returns a 202 (Accepted) status immediately while the actual work happens in the background. Users can poll the status endpoint, or better yet, we notify them via WebSockets when the job completes.
Worker Thread Pools for CPU-Intensive Work
Bull is great for I/O-bound tasks (database writes, API calls, file uploads), but for CPU-intensive work—like image processing or data aggregation—you need worker threads.
Node.js's worker_threads module lets you spawn separate threads that don't block the event loop. I've used this for generating reports on large datasets without freezing the API.
Laravel Queue System for Heavy Workloads
Laravel's queue system is a masterclass in elegant design. When I switched to Laravel at Raybit Technologies, I was impressed by how intuitive it made async request handling in REST API design.
Jobs and Queues Workflow
Here's the architecture:
- Job Class: Defines what work to do
- Queue Driver: Where jobs wait (Redis, database, SQS, etc.)
- Queue Worker: Daemon that processes jobs
- Failed Job Handler: Retries or logs failures
Let me show you a real example from the EmpSuite ERP platform, where we needed to generate and email monthly reports without blocking the API request:
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use App\Models\Report;
use App\Mail\ReportReady;
use Mail;
class GenerateMonthlyReport implements ShouldQueue
{
use Dispatchable, Queueable;
protected $userId;
protected $month;
public function __construct($userId, $month)
{
$this->userId = $userId;
$this->month = $month;
}
public function handle()
{
// This runs in the queue worker, not blocking the web request
$reportData = $this->aggregateData($this->month);
$report = Report::create([
'user_id' => $this->userId,
'data' => json_encode($reportData),
'month' => $this->month,
]);
// Send email
Mail::to($this->getUser()->email)
->send(new ReportReady($report));
}
protected function aggregateData($month)
{
// Complex aggregation logic here
return [
'sales' => $this->calculateSales($month),
'expenses' => $this->calculateExpenses($month),
];
}
public function failed(Exception $exception)
{
// Handle job failure
Log::error('Report generation failed', [
'user_id' => $this->userId,
'error' => $exception->getMessage(),
]);
}
}
// In your controller:
class ReportController extends Controller
{
public function generate(Request $request)
{
$month = $request->input('month');
// Dispatch job immediately, return response right away
GenerateMonthlyReport::dispatch(
auth()->id(),
$month
)->onQueue('reports');
return response()->json([
'message' => 'Report generation started',
'status_url' => route('api.report.status', ['month' => $month])
], 202);
}
}
?>The magic: one line (GenerateMonthlyReport::dispatch(...)) queues the entire job. The request returns instantly. In production, a queue worker running as a separate daemon process handles the actual work.
Why Laravel Queues Excel at API Performance
Laravel's job system is tightly integrated with the framework:
- Automatic retry logic with configurable delays
- Failed job storage for debugging
- Rate limiting on queues
- Job batching for coordinating multiple jobs
- Webhook notifications when jobs complete
Node.js vs Laravel: Practical Comparison
Both frameworks handle async request processing beautifully, but they make different tradeoffs.
Node.js Strengths
- Single language (JavaScript) across frontend and backend—mental context switching is minimal
- Faster startup times for worker processes
- Native event-driven architecture means less boilerplate
- Better for real-time features (WebSockets, streaming)
Laravel Strengths
- Built-in job retry, failed job handling, and monitoring
- Tinker REPL for debugging jobs in production
- Better for teams unfamiliar with async/Promise patterns
- Excellent documentation and community packages
At Raybit Technologies, we use both: Node.js for real-time services and API performance-critical paths, Laravel for business logic and reporting jobs. It's not either/or—it's choosing the right tool for each job.
Real-World Implementation Example
Let me walk you through a complete scenario: processing bulk user imports with progress tracking.
The Problem
A client uploads a CSV with 50,000 users. Validating, importing, and assigning them to teams takes 30+ seconds. The API can't block for that long.
The Solution (Node.js + Bull)
// Step 1: Accept upload, queue the job
app.post('/api/users/bulk-import', upload.single('csv'), async (req, res) => {
const filePath = req.file.path;
const job = await bulkImportQueue.add(
{ filePath, userId: req.user.id },
{
attempts: 1,
progress: true,
removeOnComplete: { age: 3600 } // Keep for 1 hour
}
);
res.status(202).json({ jobId: job.id });
});
// Step 2: Process in worker
bulkImportQueue.process(2, async (job) => {
const { filePath, userId } = job.data;
const rows = await csv().file(filePath);
const total = rows.length;
for (let i = 0; i < total; i++) {
const row = rows[i];
// Validate and import
await User.create({
email: row.email,
name: row.name,
team_id: row.team_id,
imported_by: userId,
});
// Update progress (emits to client via WebSocket)
job.progress(((i + 1) / total) * 100);
}
return { imported: total };
});
// Step 3: Client polls progress
app.get('/api/import/:jobId/progress', async (req, res) => {
const job = await bulkImportQueue.getJob(req.params.jobId);
res.json({
progress: job._progress,
state: await job.getState()
});
});With this setup, the user uploads a file, gets a job ID in 50ms, and can monitor progress in real-time while their browser polls the progress endpoint every second.
💡 Pro Tip
For the best user experience, combine polling with WebSockets. Send a real-time update when the job completes instead of forcing the client to keep polling.
Key Takeaways
- Async request handling is essential for REST API design at scale. Synchronous processing of heavy workloads kills API performance and user experience.
- Use Bull (Node.js) or Laravel Queues to decouple request handling from job processing. Accept the request, queue the work, return a 202 status immediately.
- Choose your tool wisely: Node.js for real-time and event-driven systems, Laravel for business logic and standard async workflows.
- Monitor and retry intelligently. Exponential backoff, configurable retries, and failed job logging prevent cascading failures in production.
- Track progress for long-running jobs. Give users visibility into what's happening. Polling or WebSockets—choose based on your architecture, but always communicate status.