Gulp incremental watch builds with since and lastRun
gulp.lastRun with the since option can cut watch‑mode rebuilds down to changed files only — if you respect its mtime and per‑process limits.
12 Jul 2025, 03:31 UTC

The problem: watch mode without full rebuilds
A typical asset pipeline watches src/**/*.js and runs the full transform chain on every save. On a project with a few hundred files, saving one file reprocesses all of them. The build is still correct, but the feedback loop gets slow enough that people quietly stop using watch mode.
The usual fix is to bolt on a caching plugin. Gulp 4 ships a smaller, built‑in alternative: filter the source glob by the task’s own last successful run.
Version note: this assumes Gulp 4.x, where tasks are plain functions and series/parallel replaced Gulp 3’s dependency arrays. Run npx gulp --version to confirm what you have installed; on Gulp 3 neither lastRun nor the function‑based task model applies.
The decision: pass since: lastRun(task) to gulp.src
gulp.lastRun(task) returns the timestamp of the last time that task completed successfully in the current Node process, or 0 if it has never run. gulp.src accepts a since option and keeps only files whose modification time (mtime) is newer than the supplied timestamp.
Combined, the task reads as: “process only the files that changed since I last finished.”
A worked gulpfile
Run these from the project root. You need write access to the project directory and a local Gulp install.
npm install --save-dev gulp
npx gulp --version # confirm the local CLI and version before relying on lastRun
// gulpfile.js
const { src, dest, watch, series, lastRun } = require('gulp');
const SRC = 'src/**/*.js';
const DEST = 'dist';
function scripts() {
return src(SRC, { since: lastRun(scripts) })
.pipe(/* your transform: bundler, minifier, transpiler */)
.on('error', onError)
.pipe(dest(DEST));
}
function onError(err) {
console.error('[scripts]', err.message);
this.emit('end'); // keeps watch alive; see the caveat below
}
function watchFiles() {
watch(SRC, { delay: 100 }, series(scripts));
}
exports.build = scripts;
exports.watch = series(scripts, watchFiles);
Replace the commented .pipe() with whatever transform you already use. The since option is independent of the plugin, so this change is additive rather than a rewrite.
Note the two entry points. npx gulp build runs once and exits. npx gulp watch runs the task once, then keeps the process alive so lastRun has something to compare against on the next save.
What lastRun actually measures
Two properties matter more than the API shape:
- It is per‑process. The timestamp lives in memory. A fresh
npx gulp buildstarts withlastRunat0, so the first run always processes every file. Incremental skipping only happens inside a long‑runninggulp watchsession. - It compares mtimes, not content. A file is skipped when its mtime is not newer than the recorded timestamp. No content hashing is involved.
The second point is where most surprises come from. A build cache restored with preserved timestamps, an rsync -t, or a cp -p can leave a genuinely changed file with an old mtime, and it will be skipped without any warning.
Trade‑offs and failure modes
| Behaviour | Consequence | Mitigation |
|---|---|---|
| Deleted source files | Stale output stays in dist/; since filters inputs only | Clean the destination on a full build, or handle deletions in the watch handler |
| Shared inputs (partials, config, shared modules) | Editing a shared file does not match the entry glob, so nothing rebuilds | Add shared files to the watch glob and force a full run, or include them in the source glob |
this.emit('end') in the error handler | Watch survives, but a failed transform yields a partial build with a zero exit code | Log loudly; in CI, let the process fail instead of swallowing the error |
| Duplicate watch events on one save | The task may run twice for a single edit | watch(..., { delay: 100 }) coalesces events |
The honest summary: this is a good default for local development and a poor fit for CI, where a cold process rebuilds everything anyway and correctness matters more than a few seconds.
How to check it is actually skipping
- Run
npx gulp watchand let the first full build finish. - Record output timestamps:
ls -l --time-style=full-iso diston GNU coreutils, orstat -f '%N %m' dist/*on macOS. - In a second terminal, touch one source file:
touch src/one.js. On PowerShell,(Get-Item src\\one.js).LastWriteTime = Get-Date. - Expect only the output for
one.jsto get a new mtime. If every output file updates,lastRunis returning0— usually because the value passed tolastRunis not the same function reference that actually runs. - Now touch a shared file that is not covered by
SRC. Nothing rebuilds. That is the limitation, reproduced deliberately.
If step 4 fails, check that you pass the function itself (lastRun(scripts)) rather than a string that does not match the registered task name.
Where to apply it
Add { since: lastRun(task) } to the source step of the slowest task in your watch loop first — usually the one running a bundler or transpiler — and leave the rest unchanged. Verify with the timestamp check above before extending it to other tasks, and keep one clean full build in CI so stale output never reaches a release.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.