Gulp 4's series() and parallel(): Trading Implicit Dependencies for Readable Pipelines
Gulp 4 replaced implicit task dependencies with explicit series() and parallel() composition. Here's what changed, why tasks hang after migration, and how to verify your build graph.
18 Nov 2025, 16:37 UTC

If you ever debugged a Gulp 3 build where a shared dependency ran twice, or ran in an order nobody could explain, you already know the problem Gulp 4's composition API solves. The old pattern — gulp.task('build', ['clean', 'scripts']) — resolved dependencies at runtime, which meant the execution order was an emergent property of your whole gulpfile. Gulp 4 replaces that with two explicit functions, gulp.series() and gulp.parallel(), and the result is a build pipeline you can read top to bottom like a table of contents.
What actually changed
In Gulp 3, every task declared its own dependencies inline, and the orchestrator figured out the graph while running. Tasks with overlapping dependencies could execute more than once, and "did clean finish before scripts started?" was sometimes a genuine mystery.
Gulp 4 builds the dependency graph up front, at registration time, using a library called Undertaker. Tasks are plain named functions, and composition happens in one place:
const { series, parallel, src, dest } = require('gulp');
function clean(cb) {
// delete ./dist here, then:
cb();
}
function scripts() {
return src('src/js/**/*.js')
.pipe(dest('dist/js'));
}
function styles() {
return src('src/scss/**/*.scss')
.pipe(dest('dist/css'));
}
function html() {
return src('src/*.html')
.pipe(dest('dist'));
}
exports.build = series(clean, parallel(scripts, styles), html);Read that last line aloud: clean first, then scripts and styles at the same time, then html. That is the entire pipeline, declared once, with no hidden ordering logic scattered across task definitions. Run gulp --tasks (or gulp --tasks-json for the full tree) in your project directory to see the registered graph and confirm it matches what you intended.
The completion signal is now your responsibility
The single most common Gulp 4 migration bug is a task that hangs forever with no error. Gulp needs to know when an async task is done, and it accepts exactly these signals:
- Calling the callback:
function clean(cb) { ...; cb(); } - Returning a stream (as
scripts()does above) - Returning a Promise
- Returning an Observable or EventEmitter
If you write an async function that awaits some work, that works too — an async function returns a Promise. But a task that does asynchronous work and signals none of these will sit silently at "Starting 'taskname'..." until you kill it. When migrating a Gulp 3 gulpfile, audit every task for its completion signal before debugging anything else; this one check resolves most "Gulp 4 hangs" reports.
Concurrency is a dial, not just a default
parallel() runs its tasks concurrently, bounded by a default limit based on your CPU count. On a CI runner with fast CPUs and a slow disk, that default can cause I/O thrashing — several plugin-heavy tasks competing to read and write at once. You can tune it per task:
const { parallel, task } = require('gulp');
task('heavy-images', { concurrency: 2 }, images);Two practical notes here. First, concurrency limits apply to how many tasks run at once, not how many files flow through a stream — a single task still processes its whole glob. Second, error handling gets trickier in parallel branches: an unhandled stream error in one branch can crash the process while siblings are mid-flight. Attach .on('error', ...) handlers to each stream, or wrap pipelines with a utility like pump, so failures surface with context instead of a bare stack trace.
The trade-off: verbosity and plugin rot
The explicit model costs you boilerplate. A three-task project that was three lines in Gulp 3 becomes a dozen lines of named functions plus composition. For trivial builds, that overhead is real, and it's fair to ask whether the control is worth it.
The bigger migration risk is the plugin ecosystem. Gulp 4 pairs with vinyl 2.x (vinyl is the virtual file object that flows through every stream, carrying a file's path, contents, and metadata), and many older plugins were written against vinyl 1.x internals. A plugin that reads file.relative or mutates paths the old way can break subtly — wrong output paths, silently dropped files — rather than failing loudly. Before migrating, list your plugins and check each one's repository for Gulp 4 / vinyl 2.x support. A quick smoke test: run your real build with Gulp 4 in a branch and diff the dist/ output against your Gulp 3 output. Identical trees are strong evidence the migration is safe; differences tell you exactly which plugin to investigate.
Also note the floor: Gulp 4 requires Node.js 8 or later. Any current Node LTS is fine, but if you're pinned to Node 6 somewhere, that's a separate problem to solve first.
Where to start
Pick one real build from a Gulp 3 project and rewrite it as composed functions. Verify three things: gulp --tasks-json shows the graph you expect, every task has an explicit completion signal (temporarily delete one callback and watch the hang — it's instructive), and the output directory matches your old build byte-for-byte. Once that passes, the mental model shift is done, and the readability payoff compounds every time someone new opens the gulpfile.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.