TL;DR We bundled an internal Azure Pipelines task extension into a single bundled JavaScript file using esbuild. The task package dropped from tens of megabytes and thousands of files to three files per task ( script.js , task.json , and icon.png ). The change took about 20 lines of build tooling. We measured the payoff across our production pipelines: […]
The post Shrinking Azure Pipeline task extensions using esbuild appeared first on Azure DevOps Blog.
We bundled an internal Azure Pipelines task extension into a single bundled JavaScript file using esbuild. The task package dropped from tens of megabytes and thousands of files to three files per task ( script.js , task.json , and icon.png ). The change took about 20 lines of build tooling. We measured the payoff across our production pipelines:
Spending less time downloading and extracting tasks means we can make more efficient use of our build infrastructure. If you publish a node-based Azure DevOps task extension that ships a large node_modules folder or thousands of small files, you can almost certainly benefit from this same change!
Every time a pipeline job runs your task, the agent does the following during ‘Initialize job’, before your task code ever executes:
This happens on every job, on every agent, for every task in the job. On ephemeral hosted agents (which start from a clean VM), there is no cache to save you from this startup cost.
Our task had grown the way node-based tasks tend to: the compiled TypeScript plus a full node_modules tree that our build copied into each task folder. The result, quoting our own build script:
// package huge (tens of MB and thousands of files per task).
Thousands of small files is the problem here. Task extensions are packaged in a vsix file which is really just a zip file that follows a specific packaging convention. Zipping and unzipping a package with thousands of files is costly. In this case it’s also completely unnecessary.
Bundling and pruning aren’t just for the browserBundlers and tree-shaking carry a reputation as front-end tools. We reach for them to ship less JavaScript to a browser over a slow network, and it’s easy to assume that a server-side or CLI-style program, where “it all runs on one machine anyway,” has nothing to gain.
A pipeline task is a distributable artifact that each build agent downloads and unpacks from scratch on every run, often thousands of times a day across many agents. That is the problem bundling helps to solve. Front-end developers are familiar with optimizing assets to minimize the cost of transferring and processing files. The same cost applies here; the difference is the build agents pay the cost instead of browsers.
The same logic applies to anything you distribute and load repeatedly: pipeline tasks, npm-published CLIs, serverless function packages, even container image layers. If your artifact drags an entire node_modules tree along for the ride, tree-shaking away the code you never call and collapsing what’s left into one file pays off wherever it lands. Treat your task like something you ship, not like a folder you develop in.
We added a single esbuild build step that bundles each task’s entry point, together with the shared Common code and all of its npm dependencies, into one bundled, tree-shaken script.js per task. The task’s VSIX then only needs to ship, per task:
Tasks/{taskname}/
script.js (the entire bundled task)
task.json (the task manifest)
icon.png
You no longer ship hundreds of transitive dependency files in multiple node_modules folders. You simply point task.json‘s execution target field at script.js, and that’s it.
{
//...
"execution": {
"Node20_1": {
"target": "script.js",
"workingDirectory": "$(currentDirectory)"
}
}
}
The essence of the build script:
import * as esbuild from "esbuild";
await esbuild.build({
entryPoints: ["Tasks/MyTask/index.ts"], // one per task
outfile: "Tasks/MyTask/script.js",
bundle: true,
treeShaking: true,
platform: "node",
target: "node20", // Target the lowest Node handler your task.json declares, or emit one bundle per handler
format: "cjs"
});
Two gotchas
We hit two subtle issues that other publishers might hit too:
Common/node_modules and each task’s own node_modules, esbuild can bundle two separate copies. For libraries that hold module-level state, that state splits across the copies and silently disappears (for example with azure-pipelines-task-lib, the internal \_vault that holds secrets). We wrote a small esbuild resolver plugin that forces bare-specifier imports to resolve to a single, canonical node_modules. Common moduleA.js moduleB.js Task1 script.js task.json distribution.json (custom file needed by the task) Task2 script.js task.json Task3
Bundling collapses the Tasks/{taskname}/Common/ subfolder, so the emitted script.js now lives one level up from where the source did. Update any runtime reads of sibling files ( task.json , distribution.json ) that use __dirname to drop the now-incorrect ../ prefix.
These changes took a couple iterations to fix, but knowing about them up front might save you a confusing debugging session.
How we measured the impact| Metric (per task, download + extract) | Before | After (bundled) | Change |
|---|---|---|---|
| Task1 | ~4.5s | ~0.25s | 🔻−94% |
| Task2 | ~4.6s | ~0.26s | 🔻−94% |
| Both tasks combined, per job | ~9.2s | ~0.5s | ~17x faster |
Because this task runs across a huge number of pipelines every day, the small per-job saving compounds dramatically. Overall, we’re making much more efficient use of our build agent infrastructure which means we can run more builds on the same overall CPU quota.
As a pipeline author, this change delivers real savings to your customers while requiring zero changes on the customer’s side.
Some CaveatsThere are some potential drawbacks here that are worth mentioning.
If you publish a Node-based Azure Pipelines task, you can very likely get the same benefit:
node_modules folder with hundreds or thousands of files? (Look at the .vsix contents by renaming it to .zip and extracting the contents) script.js per task with bundle and treeShaking enabled, targeting the Node version your task declares. (You could enabling minify too but that makes debugging more challenging as your stack traces will be unreadable unless you also output sourcemaps)task.json at the bundled entry file.__dirname -relative asset reads if bundling changes your output’s folder depth.It’s a small, self-contained change, and as we found, the payoff scales with how often your task runs.
Ask your coding agent to draft a PR and test the results.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | You can now use the Azure DevOps Service Connection instead of a PAT or Build Session token | 0 | 4.13 | 06-08-2026 |
| 2 | Temporary rollback: build identities can access Advanced Security: read alerts again | 0 | 7.17 | 11-03-2026 |
| 3 | [Free Tutorial] Master Building AI Agents | 0 | 6.66 | 13-07-2026 |
| 4 | Retirement of Azure DevOps issuer in Workload identity federation service connections | 0 | 5.68 | 22-06-2026 |
| 5 | jupyterlab-git: A Git Extension for JupyterLab | 0 | 26.67 | 30-01-2026 |
| 6 | Agent Plugins 1.0 in VS Code, Copilot CLI, and the Copilot app | 0 | 9.02 | 12-08-2026 |
| 7 | Save big on Visual Studio 2026 with a $35 lifetime developer license | 2 | 6 | 09-07-2026 |
| 8 | AI Agents Are Wasting Your Tokens, Here’s How to Fix Long Document Pipelines | -2 | 6 | 29-06-2026 |
| 9 | Operationalize Fabric workspaces with Azure DevOps using Fabric CLI and fabric-cicd | 0 | 5 | 18-06-2025 |
| 10 | Tame Dependabot: Group your updates, slow the cadence, keep security fast | 0 | 6.02 | 29-07-2026 |