Why We Added a Task Runner to Our Web App
I work on a web app written in React/TypeScript, with a Java backend. When I started working at this company, there was no overarching build system - we just spawned the backend via a Gradle command and the frontend via an npm script. This worked absolutely fine at the start, when we only really had one build of the app, but over time as the product grew, we ended up with several different builds of the app (OSS, proprietary, desktop, SaaS), which meant different npm scripts for the frontend builds and different flags for the Gradle backend. Later on, we added a Python server to the system as well, which was really the straw that broke the camel’s back for me. We had to do something to make it easier to manage these three different languages with their various settings and installation requirements.
In my previous job, we had a build system written in GNU Makefiles, which had worked well and that’s where my mind immediately went to. There are a few issues with Makefiles though that gave us pause:
- They don’t run (natively) on Windows.
- Many developers dislike the syntax.
- Their syntax is built around compiling files, which in modern web development systems is quite rare to do.
So, we had a look around for modern alternatives and came across Taskfile and were quickly impressed by it. It’s quick to set up, easy to install on all major platforms, and our team was already familiar with its syntax because it uses YAML for its config files.
So, we started to add it to our system to try it out and hopefully make it easier to develop our tool. We still use Gradle, npm and uv exactly how we did before; we just added a Taskfile layer on top of them. I was already pretty convinced I’d like using a system like this, but I was really surprised to hear how much the other developers on the team were liking Task as well. There are a few things that using a task runner enables that I hadn’t fully appreciated until we’d been using it for a while.
Command names can follow a scheme
Regardless of which area of the code you’re in, the commands can always be predictable. In order to run the client and server, you might currently need commands like:
./gradlew :my-app:bootRun
npm run dev
But by setting up tasks, we can change the commands to:
task backend:dev
task frontend:dev
And then similarly, you can have all of the other commands you need follow a naming scheme:
task backend:lint
task backend:format
task backend:test
task frontend:lint
task frontend:format
task frontend:test
And have higher-level commands which run both the frontend and backend commands:
task lint
task format
task test
Task dependencies
It’s quite common to have commands that are only valid to run after another command has been run first.
I used to find it really frustrating when I started a new branch from main to do some work in and the frontend dependencies had changed so my npm run typecheck command would fail (since part of the code was depending on some npm modules that I didn’t have installed yet).
So, I’d have to then run npm install manually and then run npm run typecheck a second time to see if my code actually compiles or not.
But, by setting our dependencies up correctly in Task, I now don’t even have to think about the installation.
All I do is run task frontend:typecheck, and the npm dependencies will be installed automatically but only if they actually need to.
If they haven’t been updated, Task automatically skips the npm install command, so my time isn’t wasted by npm install taking a second to figure out that all of the packages are already installed.
The snippet below shows how we have structured our frontend Taskfile.
The typecheck task depends on the install task, and that is set up to list the files that it depends on (package.json and package-lock.json).
Task now has enough information to decide whether npm install needs to be run based on whether either of those files have changed since the install task was last run.
tasks:
install:
desc: "Install dependencies"
run: once
cmds:
- npm install
sources:
- package-lock.json
- package.json
status:
- test -d node_modules
typecheck:
desc: "Type-check the frontend code"
deps: [install]
cmds:
- npx tsc --noEmit
Any other commands which depend on the install rule will also automatically skip the actual installation if the dependencies are unchanged.
It’s really easy to have the different tasks all intelligently respond to the state of the code just by defining the dependencies for each part once, making it completely effortless to run whichever command you need to.
Updating workflows in one place
One developer can easily update the workflow by adding, removing or changing the commands that everyone else will be using.
Because of that, as you change which developer tools you’re using, your commands can largely stay unchanged but perform different actions.
For example, we had our task frontend:check rule, which runs all of the checks that CI will use to decide whether a PR can be merged or not.
At some point, we decided to require an auto-formatter to be run over all of the frontend code before merge, so all we had to do was add in an extra sub-task to the frontend:check task, and everyone started using it automatically.
tasks:
typecheck: ...
lint: ...
+ format:check: ...
test: ...
check:
desc: "Run all code quality checks"
cmds:
- task: typecheck
- task: lint
+ - task: format:check
- task: test
To be fair, this was also possible via npm scripts and we did have some compound rules before we switched to using Task, but the syntax there is clunky and difficult to read.
{
"scripts": {
"typecheck": "...",
"lint": "...",
"format:check": "...",
"test": "...",
"check": "npm run typecheck && npm run lint && npm run format:check && npm run test"
}
}
Final thoughts
Adding a task runner to our system has been an overwhelmingly positive change for us. It has significantly improved the developer experience of working in the repo by increasing consistency, reducing the number of manual steps, and providing an excellent documentation point for developer scripts. Personally, I think Task is a particularly good implementation of a task runner, but any decent task runner should support the major things I’ve mentioned here.
I think that most repos would be improved by implementing a task runner if they’re currently just using bespoke commands for each of the things they need to run. I’ve started converting all of my personal repos to use Task as well, even if they just use a single language because the dependency management makes my life so much easier. Honestly, once you’ve experienced the system automatically installing dependencies when necessary, it feels ridiculous to have to manage them yourself.
If you’re working in a project which doesn’t have any sort of task runner, I’d urge you to experiment with one and see if you like it. It can be a purely additive and optional change to a repo; all the existing commands you already have will continue to work how they previously did. Adding a task runner really just makes your life easier if you use it, and stays out of your way if you don’t.