Npm Scripts vs Task Runners

Static image

There were some posts a while back that talked about using npm scripts to handle most of your mundane tasks over using a task manager. I only recently got around to diving into this concept and wanted to share some of my discoveries.

Why use task runners at all?

This is an easy one. In development, there are things that have to get run and rerun constantly. Make a change to your javascript? Recompile (if you’re using webpack, browserify, babel, etc.). Make a change to your sass/less? Recompile. Want to release your code? Bundle, minify, copy assets, distribute. Lint your code? Run eslint or jshint or jscs.

For example, to compile some code via webpack for development could look like webpack -d --progress --colors. Granted, it could be just webpack -d but I like the extra niceties. More realistically, you might have multiple webpack config files which would take a command like webpack -d --config webpack-custom.config.js. In both examples, these commands are time-consuming to type out and are typo-prone due to their length.

However, after setting up a task runner I could simply type gulp webpack:custom and it handles all the configuration for me. Typing in these commands with all of their unique options and parameters can be quite time consuming. That’s where task runners like grunt and gulp come in. You configure these tasks with all your needed options, and then simply call the runner. That’s basically the entire reason task runners exist, hence the name. Nothing special here.

Is npm a task runner?

No, It is a package manager. However it does have functionality that allows it to run arbitrary scripts. You can write any command as an npm script, and then run npm run mytask and it will run it. Continuing my webpack example, you could write

  "scripts": {
    "webpack:custom": "webpack -d --progress --colors --config webpack-custom.config.js"

Then running npm run webpack:custom would achieve the same as gulp webpack:custom.

What’s wrong with dedicated task runners?

Task runners cause a step of indirection1 between the runner and the actual tool. These are usually called “plugins”. With our webpack example, there is a gulp-webpack plugin available to make using webpack with gulp easy and straightforward. This indirection causes a problem, though, namely staying up-to-date. And this is one of my main takeaways from my investigation. The usual execution path is task runner -> plugin -> tool. The plugin pulls in the tool as a dependency and uses it.

But what if a new version of that tool comes out with features you want to take advantage of? You have to wait for the plugin to get updated to use the new version. Hopefully the plugin maintainers are on top of it and the turnaround time is low, but you are still left to the mercy of them to get the new functionality.

Can I not just fork the plugin and use my version? Yes, but I don’t think this is a maintainable solution because you could in theory end up forking every plugin you use leaving you with the burden of keeping all of them up-to-date. What about “peerDependencies” which allow me to specify what version of the tool the plugin should use? I found the same problem here. If the api of the tool changes, the plugin maintainers will still need to update the plugin to use the new api, and you are stuck in the same problem as before.

Where do npm scripts fit in?

Npm scripts offer a more direct solution. There are no plugins needed, and there is no step of indirection between you and the tool. Basically, npm script -> tool. This means tool versioning is entirely in your control. If a new version of webpack comes out with breaking api changes, no sweat. Update the dependency and your npm script with the new options, and you are using the new features.

So why not always use npm scripts?

The answer to this question comes down to preference. Some developers want granular control over what version of tool they are using. Npm scripts allow for this. However, npm scripts are very primitive; they just run the commands you tell it to run. That means complex tasks that do multiple things can get out of hand very quickly.

Task runners offer a great amount of convenience along with some nice features. Debugging gulp task code can be much easier than debugging a long npm script. Gulp (possibly others) also allows for parallel task execution.


Task runners are very nice at making mundane, repeating tasks quick to execute, but they create a layer of indirection that can cause issues. Npm scripts avoid this indirection but were not designed with complex, multi-step tasks in mind. I land somewhere in the middle. For some tasks like copying files around, concatenating files, renaming files, etc., I enjoy the convenience of a task runner. These tasks are simple for task runners to accomplish, and the tools used don’t have a lot of churn or new features that I’ll want. However, there are some tasks that I do like granular control over versioning, namely my javascipt and style build processes. If a new release candidate of webpack comes out, I want to be able to check it out immediately. If babel makes a huge change (i.e. v6.0.0) I don’t want to wait on plugin maintainers before I can start using the new features. I don’t think there’s a one-answer-to-rule-them-all in this situation. You simply need to evaluate what level of control you want as a developer and go that direction.


1 Gulp does allow for using tools directly in its tasks but often still requires a plugin to play nice with other plugins if you are doing a multi-step task