Editor’s note: This post was updated on 2 December 2021 to include a more robust comparison of task runners and module bundlers, as the latter have risen significantly in usage and popularity since this post’s original publication in 2019. We have also updated several sections to include new information about recent versions of the libraries and tools discussed in this post.
This post will cover the following topics:
The tooling ecosystem for JavaScript is incredible. You’ll be hard pressed to find any other language with as much tooling or as many different users contributing to this tooling. From its humble beginnings as a language originally designed in 10 days to its C10K-achieving foothold in the server space, you will not find another language as malleable as this one.
Node.js, the popular server-side implementation of JavaScript, was first introduced in 2009. This platform allowed frontend developers to quickly become viable as backend developers nearly overnight, unblocking frontend teams everywhere. Its success warranted a tool for easily distributing source code and, in 2010, this need was satisfied by npm.
Node.js is heralded as being fast, approachable, and — perhaps most alluring of all — simple. It quickly began siphoning users from other platforms like PHP, a language created to generate dynamic websites. PHP has perhaps thousands of global functions available at any time and requires a stack of configuration files.
Node.js allowed developers to migrate to the platform and get a fresh start. Being so new, it hadn’t yet developed the “batteries included” frameworks of other languages. One of the guiding principles of Node.js is to keep the core simple. You won’t find inbuilt tools for connecting to MySQL, generating a UUID, or calculating Levenshtein distance.
At this same time, the JavaScript language was transforming as well. Some features are backwards compatible thanks to user-land “polyfills”, but in order for a language to advance, it simply must add the occasional new syntax. Developers yearn for new syntax, yet old browsers are the reality, which led to the development of transpilers.
The simplicity of working with Node.js was eventually dwarfed in importance by the fact that code is written in JavaScript, the lingua franca of the web. Node.js gained more and more traction as a tool for transforming frontend assets from one representation, such as ES7 or SASS, to another representation, such as ES5 or CSS.
There was just one catch, though: JavaScript engineers typically want to keep writing JavaScript. This led to the development of task runners, specialized Node.js tools designed to run other tools.
As we know, there are essentially three technologies required to construct a website, each of which is consumed directly by the browser:
For simpler websites or small teams, working with these languages directly is usually a fine approach. However, with complex websites or apps that run on the browser that are built by teams of engineers — each of whom have their own specializations — working directly with these basic languages can start to fall short.
Consider, for example, when the branding for a corporate website changes. A hexadecimal color code used in several different style files may need to be changed. With raw CSS, this operation would require orchestrated changes across a few teams. With SASS, such a change could be made in a single line.
Similar concepts apply to HTML, where we generate markup using templating tools like Mustache or virtual DOMs like React. They also apply to JavaScript, where an engineer may write code using the async/await ES2017 syntax that then gets transpiled into a complex ES5 switch statement with callbacks.
At this point, we may have a site which needs to have SASS compiled into CSS, ES2015 code which needs to be transpiled into ES5, and React/JSX templates that need to be converted into raw JavaScript. Other operations are also beneficial, such as minifying compiled code and compressing PNG images into their smallest representation.
Each one of these tasks needs to be run in a particular order when a website is being built. Depending on the context of a particular website build  —  such as it being built for development/debugging purposes or production — some tasks must be altered or skipped entirely. Such complexity has inspired the creation of task runner tools.
Two popular Node.js task runners came to the rescue. The first is Grunt, with a first commit made in September 2011. This tool takes an imperative approach to configuring different tasks, building out deeply nested objects and calling a few methods.
The second one is Gulp, having an initial commit in July 2013. This tool takes a different approach, more functional in nature, piping the output of one function into the input of another function, streaming the results around.
Let’s consider a simple web application we’d like to mockup using a subset of these technologies. This application depends on multiple SASS and JS files. We’d like to convert the SASS files into CSS, concatenating the result.
For sake of brevity, we’ll also simply concatenate the JS files together, and assume the module pattern, instead of using CommonJS require
statements. Let’s see how such a configuration might look using these different task runners.
This approach requires the following modules be installed:
grunt
grunt-contrib-sass
grunt-contrib-concat
grunt-contrib-clean
With this approach, we can run grunt style
, grunt script
, or grunt build
to do the work of both.
const grunt = require('grunt'); grunt.initConfig({ sass: { dist: { files: [{ expand: true, cwd: './src/styles', src: ['*.scss'], dest: './temp', ext: '.css' }] } }, concat: { styles: { src: ['./temp/*.css'], dest: 'public/dist.css', }, scripts: { src: ['./src/scripts/*.js'], dest: 'public/dist.js', } }, clean: { temp: ['./temp/*.css'] } }); grunt.loadNpmTasks('grunt-contrib-sass'); grunt.loadNpmTasks('grunt-contrib-concat'); grunt.loadNpmTasks('grunt-contrib-clean'); grunt.registerTask('style', ['sass', 'concat:styles', 'clean:temp']); grunt.registerTask('script', ['concat:scripts']); grunt.registerTask('build', ['style', 'script']);
The equivalent Gulp version of the previous example is as follows. This requires we have gulp
, gulp-sass
, gulp-concat
, and node-sass
installed. With this approach, we can run gulp style
, gulp script
, or gulp build
to do the work of both.
const gulp = require('gulp'); const sass = require('gulp-sass'); const concat = require('gulp-concat'); sass.compiler = require('node-sass'); gulp.task('style', function () { return gulp.src('./src/styles/*.scss') .pipe(sass().on('error', sass.logError)) .pipe(concat('dist.css')) .pipe(gulp.dest('./public/')); }); gulp.task('script', function () { return gulp.src('./src/scripts/*.js') .pipe(concat('dist.js')) .pipe(gulp.dest('./public/')); }); gulp.task('build', gulp.series('style', 'script'));
As you can see, the Gulp example is a little more terse than the Grunt example because configuring a Gulp file can take comparatively less time than configuring a Grunt file. But, Grunt files are more readable. So, it depends on you which approach you want to take.
Philosophically, the two tools take different approaches to implementing runnable tasks, but ultimately they allow you to do similar things. Again, Grunt was introduced before Gulp. They’ve both have had comparable popularity throughout their lifespans:
Both projects are highly modular, allowing developers to create specialized plugins. These plugins allow an external tool, such as eslint
, sass
, or browserify
to easily integrate into the task runner.
We actually have an example of this in the code we looked at earlier: the popular SASS tool has both a grunt-contrib-sass
module, and a gulp-sass
module available.
These two tools may be essentially “done.” As of this writing, Grunt’s last publish was made in May 2021, and Gulp’s was last released in 2019.
What does it mean to be “done,” a word that is both literally and figuratively a four-letter word in the JavaScript community? Well, in this case, it probably means the core task runner modules do everything they need to do and that any additional functionality can be added via plugin.
In the 2021 frontend development ecosystem, task runners are getting less popular, and module bundlers are taking their place.
A module bundler is a tool for frontend developers to bundle all the JavaScript modules into a single JavaScript file that can be executed in the browser. The biggest names in module bundlers are webpack, Rollup, and Parcel. We’ll talk about each of them below.
The web.dev blog has created a website called Tooling.Report, where they compare the best bundlers available, and currently, only four module bundlers are compared by them: webpack, Browserify, Parcel, and Rollup. According to their results, in November 2021, Parcel passed the most amount of tests (93 percent). Webpack comes in second, with a passing mark of 90 percent.
Webpack is a newer tool that can also be used to take source files, combine them in various ways, and output them into single files. Webpack can be configured in a manner similar to Grunt and Gulp using a file called webpack.config.js
.
It is also highly modular and we can achieve similar results using plugins like sass-loader
. It has its own philosophical differences from Grunt and Gulp, but, it’s still similar in the sense that a Node-based process ultimately transforms assets and is configured via a JavaScript file.
However, it’s different enough that it wouldn’t be fair to compare it directly against Grunt and Gulp. It is primarily a tool for transforming JavaScript, based on require
statements and a hierarchy of dependencies — it’s not essentially a task runner. It is a module bundler.
The first commit to webpack happened in March 2012, between the first commits to Grunt and Gulp, and it is still under active development, as it is used heavily around the world. It is the most popular module bundler, with over 15 million weekly downloads.
Whereas Grunt and Gulp help perform many types of generic tasks, webpack is specifically more interested in building frontend assets. But, it is not only limited to that — webpack can also be useful in Node.js development as well.
Rollup is another module bundler for JavaScript that compiles small pieces of code into more extensive and complex code. Rollup’s configuration is similar to webpack’s, and those who are familiar with webpack will find Rollup’s configuration similar and easy to set up.
Developers often like Rollup because of their more straightforward API and design, making writing plugins easy. The Rollup documentation is also excellent.
Parcel is gaining massive popularity because of its zero-configuration approach. It is also very lightweight, and adding Parcel to a project is straightforward.
Parcel doesn’t use any config files; passing the entry file through the CLI is enough to get started with it. It does the rest of the work itself. Though, now, webpack 4 also has a zero-configuration approach, it is limited to the features compared to what Parcel offers.
Parcel can be an excellent option to start when building a small to medium-sized application.
Browserify has always been a close competitor to webpack, but in recent years, webpack has grown a lot faster than Browserify.
Browserify is not a module bundler. Instead, it brings the power of Node.js to the browser. This package lets you use many Node.js packages in the browser for your frontend packages.
For the most complex of build systems, it makes total sense to use a Node.js task runner. There’s a tipping point where it just doesn’t make sense to maintain the build process in a language other than the one the application is written in because it’s become so complex.
However, for many projects, these task runners end up being overkill. They are an additional tool that we need to add to a project and keep up-to-date. The complexity of task runners is easy to overlook when they’re so readily available via npm install
.
With the previous examples we looked at, we needed 32MB of disk space to use Grunt and 40MB of space to use Gulp. These simple build commands — concatenate two JavaScript files and compile/concatenate two SASS files — takes 250ms with Grunt and 370ms with Gulp.
Gulp’s approach of taking outputs from one operation and piping them into another operation should sound familiar. The same piping system is also available to us via the command line, which we can automate by use of Bash scripts. Such scripting features are already available to users of macOS and Linux computers (WSL can help with Windows).
We can use the following three Bash scripts to achieve what our Grunt and Gulp examples are doing:
### style.sh #!/usr/bin/env bash cat ./src/styles/*.scss | sass > ./public/dist.css ### script.sh #!/usr/bin/env bash cat ./src/scripts/*.js > ./public/dist.js ### build.sh #!/usr/bin/env bash ./style.sh ./script.sh
When we use this approach, we’ll only need a 2.5MB sass
binary (executable). The time it takes to perform the entire build operation is also lessened: on my machine the operation only takes 25ms. This means we’re using about ~1/12 the disk space to run 10x faster. The difference will likely be even higher with more complex build steps.
This approach can even be inlined inside of your package.json
file. Then, commands can be executed via npm run style
, npm run script
, and npm run build
.
{ "scripts": { "style": "cat ./src/styles/*.scss | sass > ./public/dist.css", "script": "cat ./src/scripts/*.js > ./public/dist.js", "build": "npm run style && npm run script" } }
This is, of course, a trade-off. The biggest difference is that Bash is a shell scripting language with a syntax completely unlike JavaScript. It may be difficult for some engineers who work on a JavaScript project to write the appropriate scripts required to build a complex application.
Another shortcoming is that Bash scripts require that some sort of executable is available for each operation we want to incorporate. Luckily for us, they usually are.
Browserify and Babel, the go-to transpiler, each offers an executable. Sass, Less, Coffeescript, and JSX also each has an executable available. If one isn’t available, we can write it ourselves; however, once we reach that point, we might want to consider just using a task runner.
The command line scripting capabilities of our machines are very powerful. It’s easy to overlook them, especially when we spend so much time in a higher level language like JavaScript.
As we’ve seen, they are often powerful enough to complete many of our frontend asset-building tasks and can often do so faster. Consider using these tools when you start your next project, and only switch to a heavier solution like a task runner if you reach a limitation with Bash scripting.
Deploying a Node-based web app or website is the easy part. Making sure your Node instance continues to serve resources to your app is where things get tougher. If you’re interested in ensuring requests to the backend or third-party services are successful, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens while a user interacts with your app. Instead of guessing why problems happen, you can aggregate and report on problematic network requests to quickly understand the root cause.
LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. Start monitoring for free.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.