Hey everyone! I’ve been learning more deeply about Node.js recently, and instead of studying it in isolation, I wanted to use it to solve a real problem inside the project that I've been working on.
One of the functions that I need to build is an image compression. But these functions are not developed 100% in Node.js; instead, they are inside my Laravel app.
One issue kept showing up: image uploads are huge, and processing them directly during a normal Laravel request quickly becomes expensive. So I tried a hybrid approach. Laravel for the application flow, and Node.js with Sharp for the heavy image work.
This setup ended up being one of the most practical architecture experiments I’ve done in a while.
The real problem
In many Laravel apps, users upload images straight from their phones or from Designers. Those files can easily be several megabytes each, and once you start resizing, converting, and compressing them, the request can become much heavier than it looks.
The first version of this problem is simple: the upload works, but the response gets slower. The second version is worse: if several users upload at the same time, the application starts spending too much time on image processing instead of serving the rest of the app.
That was the point where I stopped asking, “How do I make PHP do this?” and started asking, “What should Laravel do, and what should it delegate?”
While I'm studying Node.js, out of the blue, I'm thinking... "Why don't I pass the heavy work to compress the image by using Node.js?"
And I've made it. I create the logic and execute it.
Why I moved it to a queue
The biggest improvement was not Node.js alone. It was moving the image optimization work into a queued job.
Laravel queues are designed for time-intensive tasks, so the application can return a faster response while the heavy work continues in the background. In other words, the controller no longer needs to wait around doing expensive image work before responding to the user.
That changed the architecture completely:
- Laravel handles validation, persistence, and job dispatching.
- The queue handles background execution.
- Node.js with Sharp handles image transformation.
That separation feels much more natural than trying to do everything inside one request.
Why Sharp
I chose Sharp because it is a high-speed Node.js image processing library that uses libvips under the hood, and it supports common web image formats like JPEG, PNG, WebP, GIF, and AVIF. Its installation guide also provides prebuilt binaries for many common platforms, which makes it practical to use in real deployments instead of feeling like a purely experimental tool.
For this use case, Sharp gave me exactly what I needed:
- Resize oversized uploads.
- Convert to WebP.
- Lower storage usage.
- Keep the worker focused on one job only.
The architecture
I now think about this flow in three layers:
- Laravel controller receives the upload and stores the original file temporarily.
- Laravel queue job gets dispatched for background processing.
- Node.js script runs Sharp to resize and convert the image.
So Laravel still owns the application flow, but it no longer performs the expensive image manipulation inside the request itself. That is the part that made the solution feel production-friendly.
Why I kept Node.js isolated
I also kept the Node.js worker code inside its own scripts/ directory with its own package.json.
Could Sharp be installed at the Laravel root? Sometimes yes. But I preferred isolation because the root package.json usually belongs to frontend tooling such as Vite, Tailwind, Vue, or React, while this worker is backend-oriented and has a different responsibility.
Keeping the worker separate gave me three benefits:
- Clearer ownership of dependencies.
- Cleaner deployment steps.
- Less mental overhead when maintaining the project later.
Instead of mixing everything together, the project now has a small, focused Node environment whose only job is image optimization.
Project structure
laravel-project/
├── app/Http/Controllers/ImageController.php
├── app/Jobs/OptimizeImageJob.php
├── scripts/
│ ├── optimize.js
│ ├── package.json
│ └── .gitignore
└── storage/app/public/optimized/
Controller flow
The controller became much simpler. Its job is no longer to optimize the file immediately. It only stores what is needed and dispatches the background job.
public function store(StoreImageRequest $request)
{
$request->validated();
$path = $request->file('image')->store('private/uploads');
// You can extract this to services later.
$image = Image::create([
'folder' => 'senior-leadership',
'original_path' => $path,
'status' => 'queued',
]);
OptimizeImageJob::dispatch($image, 'senior-leadership');
return response()->json([
'message' => 'Image uploaded successfully and queued for optimization.',
'status' => 'queued',
], 202);
}
That felt much better architecturally. The request stays focused on application flow, and the expensive work happens later.
The queued job
This is where Laravel and Node.js meet.
Laravel jobs can run asynchronously through the queue system, and Laravel also supports passing serialized model data into jobs cleanly. That makes the job layer a natural place to hand the file over to an external worker process.
<?php
namespace App\Jobs;
use App\Models\Image;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Symfony\Component\Process\Process;
class OptimizeImageJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public Image $image,
public string $folder
) {}
public function handle(): void
{
$inputPath = storage_path('app/' . $this->image->original_path);
$outputFolder = storage_path('app/public/optimized/' . $this->folder);
if (! is_dir($outputFolder)) {
mkdir($outputFolder, 0755, true);
}
$process = new Process([
'node',
base_path('scripts/optimize.js'),
$inputPath,
$outputFolder,
]);
$process->run();
if (! $process->isSuccessful()) {
$this->image->update([
'status' => 'failed',
'error_message' => $process->getErrorOutput() ?: $process->getOutput(),
]);
throw new \RuntimeException('Image optimization failed.');
}
$result = json_decode($process->getOutput(), true);
$this->image->update([
'status' => 'optimized',
'optimized_path' => 'optimized/' . $this->folder . '/' . $result['filename'],
]);
}
}
I prefer this over doing the work directly in the controller because it is easier to retry, easier to monitor, and easier to scale later.
The Node.js worker
The Node.js side stays intentionally small.
const sharp = require('sharp');
const path = require('path');
const fs = require('fs');
const [,, inputPath, outputFolder] = process.argv;
async function compressImage() {
try {
const filename = path.basename(inputPath, path.extname(inputPath));
const outputPath = path.join(outputFolder, `${filename}.webp`);
await sharp(inputPath)
.resize({ width: 1200, withoutEnlargement: true })
.webp({ quality: 80 })
.toFile(outputPath);
if (fs.existsSync(outputPath)) {
fs.unlinkSync(inputPath);
}
console.log(JSON.stringify({
success: true,
filename: `${filename}.webp`
}));
} catch (error) {
console.error(JSON.stringify({
success: false,
error: error.message
}));
process.exit(1);
}
}
compressImage();
I like this part because it does one thing only: optimize the image and return a machine-readable result.
What improved
After switching to this design, I got improvements in the areas that actually mattered:
- Better request responsiveness because the optimization no longer runs during the normal HTTP request path.
- Cleaner separation of concerns because Laravel owns app logic and the worker owns image processing.
- Lower storage usage because the output is resized and converted to WebP.
- Safer failure handling because a failed optimization becomes a job failure, not a broken user request.
Trade-offs
This approach is not free of trade-offs.
You are now maintaining two runtimes in one project: PHP and Node.js. You also need queue workers running properly in each environment, and Laravel supports multiple queue backends like database, Redis, and SQS, depending on how you want to operate the system.
So I would not use this pattern for every app. But for applications that deal with large uploads or frequent image processing, it feels like a very practical split.
Final thought
As someone still learning Node.js, this was a great reminder that learning sticks better when it solves a real problem.
Laravel remained the application brain. The queue became the handoff layer. Node.js with Sharp became the specialist worker.
And honestly, that combination felt much more scalable than forcing the whole workflow into a single request.
Top comments (1)
Someone shared this PHP package with me (github.com/Intervention/image-driv...). Before I use NodeJS Slash, I'm thinking about the PHP Native image built-in function (ImageMagick). I expect the performance is not on par with the Slash. Since Slash mentioned in their documentation that they outperform ImageMagick 4-5x times.
After I look at the intervention docs, I think I will do some research and testing.
But overall, my sharing is not about "NodeJS vs PHP".. Or "NodeJS beats PHP". It's just who can give me the best compression with the best performance.