Incremental Gulp Builds with `since` and `gulp.lastRun`: Only Rebuild What Changed
Use Gulp's `since` option with `gulp.lastRun` to filter `src()` to only changed files, cutting watch-mode rebuilds to the files that actually changed — plus the deletion and mtime pitfalls to handle.
28 Aug 2025, 09:18 UTC

If your Gulp pipeline re-minifies every image or re-transpiles every script on each save, the fix is one option: pass since: gulp.lastRun(taskFn) to src(). Gulp then filters the file stream to only files whose modification time is newer than the last successful run of that task, so unchanged files never reach your expensive plugins. On a watch-driven workflow this typically turns a multi-second rebuild into a near-instant one.
How the filter works
Gulp pipelines stream vinyl file objects. Most build time is spent in downstream plugins — image compression, transpilation, minification — so the cheapest place to drop unchanged files is at the source, before any plugin touches them. That is exactly what the since option does: src() compares each matched file's mtime against the timestamp you provide and only emits newer files.
gulp.lastRun(taskFn) returns the timestamp captured when that task last completed successfully. Two details matter:
- On the very first run,
lastRunreturns no usable timestamp, sosrc()processes the full set of files. That is the correct bootstrap behavior — nothing is skipped before a baseline exists. - The timestamp is keyed to the task function identity, not its name string. If you wrap the task in an anonymous function or rename it, the key changes, the timestamp resets, and you get a full rebuild.
A worked configuration
The canonical pattern pairs a since-filtered task with watch(). This targets Gulp 4 or 5 (check with gulp --version; Gulp 3 has no lastRun):
const { src, dest, watch, series } = require('gulp');
const imagemin = require('gulp-imagemin');
function images() {
return src('src/img/**/*', { since: lastRun(images) })
.pipe(imagemin())
.pipe(dest('dist/img'));
}
// lastRun must be imported from gulp:
const { lastRun } = require('gulp');
exports.images = images;
exports.default = series(images, function watchImages() {
watch('src/img/**/*', images);
});Run this from your project root with npx gulp. The first execution compresses everything under src/img. Save one file, and only that file flows through imagemin.
The same pattern works inside composed tasks. Because lastRun is keyed per function, reuse the same named reference everywhere:
const { series, parallel } = require('gulp');
exports.build = series(clean, parallel(images, scripts));
// watch() still calls the same `images` function,
// so its lastRun timestamp is shared and correct.Verifying it actually filters
Do not trust the pattern blind — confirm the stream is empty on a no-change run. gulp-debug makes this visible:
const debug = require('gulp-debug');
function images() {
return src('src/img/**/*', { since: lastRun(images) })
.pipe(debug({ title: 'processing:' }))
.pipe(imagemin())
.pipe(dest('dist/img'));
}Run the task twice with no edits: the second run should log zero files. Then touch one source file, re-run, and confirm exactly one line appears. If the second run still processes everything, the usual cause is that the watched task is a different function reference than the one passed to lastRun.
Limits and common mistakes
Deleted files leave stale output
since only filters input. If you delete src/img/logo.png, nothing flows through the pipeline, and dist/img/logo.png stays behind forever. Incremental pipelines need an explicit cleanup step — a clean task that empties the destination before a full build, run on demand rather than on every watch tick:
const { rm } = require('fs/promises');
function clean() {
return rm('dist', { recursive: true, force: true });
}Test this directly: delete a source file, rebuild, and check whether the stale artifact remains in dist. If it does, your release build must start with clean.
mtime is not content
The filter trusts file modification times. Anything that rewrites a file without changing content — touch, some code formatters, certain editors — triggers a needless rebuild. Worse, the opposite case exists too: files restored from backups, checked out from version control, or generated with fixed timestamps can carry old mtimes and be silently skipped even though their content differs from what was last built. If your workflow involves any of those, mtime filtering is unreliable; a content-hashing approach (or a cache plugin that hashes file contents) is the safer basis.
Multi-file transforms still need caches
since decides which files enter the stream, but some plugins transform across files — concatenation, for example, needs all inputs even when only one changed. For those, per-file filtering at src() is the wrong tool; plugins like gulp-cached and gulp-remember maintain an in-memory file set so downstream steps still see the complete stream while unchanged files skip reprocessing.
Task identity traps
Two recurring bugs:
- Passing
lastRun('images')(a string) instead oflastRun(images)(the function). The lookup is by function reference. - Registering
watch(glob, () => images())or otherwise wrapping the task. The wrapper becomes the task Gulp tracks, and yourlastRun(images)timestamp never updates.
When to use it
Use since plus lastRun for watch-driven, independent per-file transforms — images, styles, individual script bundles — where mtimes are trustworthy and a periodic clean build handles deletions. Skip it for concatenating pipelines, content-addressed workflows, or anywhere files arrive with unreliable timestamps. The verification steps above take two minutes and tell you definitively which situation you are in.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.