DEV Community

Muhamad Sulaiman
Muhamad Sulaiman

Posted on

How I Smashed Image Compression in Laravel Using Node.js

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:

  1. Laravel controller receives the upload and stores the original file temporarily.
  2. Laravel queue job gets dispatched for background processing.
  3. 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/
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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'],
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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 (11)

Collapse
 
leob profile image
leob

Did exactly the same thing for a project of mine - backend application written in Laravel (running on AWS Lambda), image compression by a separate Lambda function written in JS, using Sharp (triggered via an S3 image upload event) ... !

What a coincidence :-)

Collapse
 
msulaimanmisri profile image
Muhamad Sulaiman

What a small world! Great minds think alike. Lambdas for image processing are just so efficient and cost-effective. How’s the performance of Sharp treating you @leob ? I’ve been loving how fast it handles the compression.

Collapse
 
leob profile image
leob

Small world indeed - and yes, Sharp works well !

Collapse
 
sadiqsalau profile image
Sadiq Salau

I always love this kind of development, it's not a A vs B, but A + B. Understanding the strength and weakness of any tool makes you a great engineer. I have a similar case too, not related to image compression though.

It was an async heavy operation, I had to move the feature into nodejs, the laravel and nodejs communicates seemlessly. We can also see that in things like broadcasting with Pusher.

Laravel code organization and maintainability is what keeps making me to use it.

Collapse
 
msulaimanmisri profile image
Muhamad Sulaiman

Exactly @sadiqsalau! Choosing the right tool for the right job is key. I completely agree with the 'A + B' mindset. Laravel’s DX (Developer Experience) and structure are unmatched, but knowing when to delegate makes all the difference.

Collapse
 
xwero profile image
david duymelinck • Edited

This post reads to me like a case of I want to apply my new knowledge as soon as possible. While at the base it is a good reaction. The most important question you should ask yourself is, does the application needs this?

image uploads are huge

This is the initial problem. You mentioned in the post that one of the main things is resizing the images. I assume because huge images are not needed.
So instead of allowing huge images, why not halt images that are too big from being uploaded?
This saves resources on the server with a minimum amount of code changes.

Why I moved it to a queue

My question here is, when will the user that uploaded the image will be able to see or use it?
If it is an image that is a part of something, a gallery for example, are you going to keep that thing unpublished until the image has gone through the queue?
Having async flows has effects on the user experience, server resources are not the main driver of an application.

It is good you mention the trade-offs, there are consequences to every decision.
The main thing I want to communicate is apply new knowledge when it is the best solution for the application. Not because it is fresh in your mind. It is a trap I caught myself in more than a few times. The method that works for me not falling into the trap, is building a few experiments and then move on.

Collapse
 
msulaimanmisri profile image
Muhamad Sulaiman • Edited

Hi @xwero . Thank you for asking. Great questions. Allow me to answer.

  1. Why not halt images that are too big from being uploaded - I agree with you. And of course, I already put some validation here. However, because multiple images in one post might take more space, this is where the problem happens. Even if I limit the image uploading to 1MB per image, and one post needs 20 to 30 images, it could take 20 to 30MB. We have thousands of articles, posts, and other things that need a gallery upload. Therefore, image compression size is needed here. At the same time, we keep the quality of the images. I'm converting all image formats to WebP and keeping 80% of the image quality.

  2. When will the user who uploaded the image be able to see or use it - They can see it right away. What I do for this part is, first, we keep all the original images. Then, in the background, we are queuing the image compression. After finishing, we remove the original image and replace it with the compressed version. Before I implement this method, I put a skeleton image, but it seems not user-friendly. Admin wanted to see it right away. So this is what I do. The process is lighthing fast.

What about yours? What is the trap you're facing, and did you solve it? I also wanted to learn from your experience.

Collapse
 
xwero profile image
david duymelinck

Even if I limit the image uploading to 1MB per image, and one post needs 20 to 30 images, it could take 20 to 30MB. We have thousands of articles, posts, and other things that need a gallery upload.

Now you are just changing the parameters of the use case. If you have a 1 MB upper limit, why would you need to resize the images? In your example the resizing is done by setting a maximum width, and that width guarantees a filesize you can live with to serve the users of the application.

When you already set that size at 1 MB, it doesn't matter if a gallery or a post has one image or thirty. The filesize of the images it not going to change that much by resizing because that is the median filesize you know the files are after resizing to the preferred maximum width.

Sure setting the image quality and converting the image to a more suitable webformat helps bringing down the filesize, but they are secondary performance tweaks.

They can see it right away.

I guess this answer is related to the 1 MB upload limit. Because if it isn't, do you really want to serve 30 - 50 MB images on admin pages?

If you want a more performant way than Imagick there is php-vips which is based on the same binary as Sharp. But the question here is do you use Shap because you want to use a Node library or are the PHP solutions really the bottleneck?
Sharp is only the best solution when it is the latter case.

Thread Thread
 
msulaimanmisri profile image
Muhamad Sulaiman
When you already set that size at 1 MB, it doesn't matter if a gallery or a post has one image or thirty. The filesize of the images it not going to change that much by resizing because that is the median filesize you know the files are after resizing to the preferred maximum width.
Enter fullscreen mode Exit fullscreen mode

You know how compression works, right?

A 1 MB image can be reduced to less than 200 KB. Has that not changed much? One post has 30 images. I have 1000 posts. Calculate for me how much space I save by compressing the images. Not to mention the bandwidth saved when people visit our website.

And why limit the option to do the compression to PHP when I can do it better with NodeJS? Of course, I chose Node.js because of the bottleneck. Otherwise, I can use another PHP or Laravel package to solve this.

Again, what is your trap, and what is your solution for your trap? I am waiting for your cases for me to understand also. I might use your solution if it solves my problem, which is to reduce the filesize minimum as we can and to handle the bottleneck.

Thread Thread
 
xwero profile image
david duymelinck • Edited

A 1 MB image can be reduced to less than 200 KB.

The final reduction was not the point of my questions. What is the reason that made you choose 1 MB as the upload limit? In the post you are mentioning phone and design images, those are not 1 MB. So they already went through a compression process that was executed by the uploader.

And why limit the option to do the compression to PHP when I can do it better with NodeJS? Of course, I chose Node.js because of the bottleneck.

Are you sure? Did you know about the library I linked in my comment? From another comment you made it seems the only verification you did is reading the Sharp documentation.

The trap is not exploring all the possible options. I found the PHP libvips library by clicking on the libvips link in the Sharp documentation. And in the libvips repository README I found a list of language libraries.
Sharp is not even on that list. That is why I question if the path you choose is the best one.
I don't comment to pick a fight, I comment because those are the questions I would ask myself when I need to do find a solution.

Collapse
 
msulaimanmisri profile image
Muhamad Sulaiman

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.