{"id":4566,"date":"2026-07-10T14:45:56","date_gmt":"2026-07-10T14:45:56","guid":{"rendered":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/2026\/07\/10\/shrinking-azure-pipeline-task-extensions-using-esbuild\/"},"modified":"2026-07-10T14:45:56","modified_gmt":"2026-07-10T14:45:56","slug":"shrinking-azure-pipeline-task-extensions-using-esbuild","status":"publish","type":"post","link":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/2026\/07\/10\/shrinking-azure-pipeline-task-extensions-using-esbuild\/","title":{"rendered":"Shrinking Azure Pipeline task extensions using esbuild"},"content":{"rendered":"<h2>TL;DR<\/h2>\n<p>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 (\u00a0<code>script.js<\/code>\u00a0, \u00a0<code>task.json<\/code>\u00a0, and \u00a0<code>icon.png<\/code>\u00a0). The change took about 20 lines of build tooling. We measured the payoff across our production pipelines:<\/p>\n<ul>\n<li>Per-task download + extract on the agent: ~4.5 s to ~0.25 s (about 17x faster)<\/li>\n<li>Downloads taking longer than 10 seconds: down ~98%<\/li>\n<\/ul>\n<p>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 <code>node_modules<\/code> folder or thousands of small files, you can almost certainly benefit from this same change!<\/p>\n<h2>Why task package size matters more than you think<\/h2>\n<p>Every time a pipeline job runs your task, the agent does the following during \u2018Initialize job\u2019, before your task code ever executes:<\/p>\n<ol>\n<li>Downloads the task\u2019s content zip<\/li>\n<li>Extracts it to disk.<\/li>\n<\/ol>\n<p>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.<\/p>\n<p>Our task had grown the way node-based tasks tend to: the compiled TypeScript plus a full\u00a0<code>node_modules<\/code>\u00a0tree that our build copied into each task folder. The result, quoting our own build script:<\/p>\n<p><code>\/\/ package huge (tens of MB and thousands of files per task).<\/code><\/p>\n<p>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\u2019s also completely unnecessary.<\/p>\n<h2>Bundling and pruning aren\u2019t just for the browser<\/h2>\n<p>Bundlers 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\u2019s easy to assume that a server-side or CLI-style program, where \u201cit all runs on one machine anyway,\u201d has nothing to gain.<\/p>\n<p>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.<\/p>\n<p>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 <code>node_modules<\/code> tree along for the ride, tree-shaking away the code you never call and collapsing what\u2019s left into one file pays off wherever it lands. Treat your task like something you ship, not like a folder you develop in.<\/p>\n<h2>The fix: bundle everything into one file<\/h2>\n<p>We added a single esbuild build step that bundles each task\u2019s entry point, together with the shared Common\u00a0code and all of its npm dependencies, into one bundled, tree-shaken <code>script.js<\/code> per task. The task\u2019s VSIX then only needs to ship, per task:<\/p>\n<pre>Tasks\/{taskname}\/\n  script.js     (the entire bundled task)\n  task.json     (the task manifest)\n  icon.png<\/pre>\n<p>You no longer ship hundreds of transitive dependency files in multiple <code>node_modules<\/code> folders. You simply point <code>task.json<\/code>\u2018s execution target field at <code>script.js<\/code>, and that\u2019s it.<\/p>\n<pre lang=\"json\">{\n  \/\/...\n    \"execution\": {\n        \"Node20_1\": {\n            \"target\": \"script.js\",\n            \"workingDirectory\": \"$(currentDirectory)\"\n        }\n    }\n}\n<\/pre>\n<p>The essence of the build script:<\/p>\n<pre lang=\"js\">import * as esbuild from \"esbuild\";\n\nawait esbuild.build({\n  entryPoints: [\"Tasks\/MyTask\/index.ts\"], \/\/ one per task\n  outfile:     \"Tasks\/MyTask\/script.js\",\n  bundle:      true,\n  treeShaking: true,\n  platform:    \"node\",\n  target:      \"node20\",  \/\/ Target the lowest Node handler your task.json declares, or emit one bundle per handler\n  format: \"cjs\"\n});<\/pre>\n<h2>Two gotchas<\/h2>\n<p>We hit two subtle issues that other publishers might hit too:<\/p>\n<ol>\n<li>Deduplicate stateful shared modules. If you install the same package (for example\u00a0azure-pipelines-task-lib) in both a shared\u00a0<code>Common\/node_modules<\/code>\u00a0and each task\u2019s own\u00a0<code>node_modules<\/code>, 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 <code>azure-pipelines-task-lib<\/code>, the internal\u00a0<code>_vault<\/code> that holds secrets). We wrote a small esbuild resolver plugin that forces bare-specifier imports to resolve to a single, canonical <code>node_modules<\/code>. <\/li>\n<li>Fix sibling-asset paths if your extension contains multiple tasks and they share common code. For example:<\/li>\n<\/ol>\n<pre lang=\"text\">Common\n  moduleA.js\n  moduleB.js\nTask1\n   script.js\n   task.json\n   distribution.json (custom file needed by the task)\nTask2\n  script.js\n  task.json\nTask3\n<\/pre>\n<p>Bundling collapses the <code>Tasks\/{taskname}\/Common\/<\/code>\u00a0subfolder, so the emitted <code>script.js<\/code> now lives one level up from where the source did. Update any runtime reads of sibling files (\u00a0task.json\u00a0, \u00a0distribution.json\u00a0) that use \u00a0<code>__dirname<\/code>\u00a0 to drop the now-incorrect \u00a0<code>..\/<\/code>\u00a0 prefix.<\/p>\n<p>These changes took a couple iterations to fix, but knowing about them up front might save you a confusing debugging session.<\/p>\n<h2>How we measured the impact<\/h2>\n<ol>\n<li>Task file transfer time measures how long the Azure DevOps service spends streaming each task\u2019s zip to the agent. Across all downloads, the average dropped from ~1.35 s to ~0.23 s, and downloads taking more than 10 seconds (slow network) fell by ~98%. Here we already saw a big improvement.<\/li>\n<li>Agent-side download and package extraction time. This is the number that matters to customers because it means more efficient use of build agent compute.<\/li>\n<\/ol>\n<table>\n<tr>\n<th>\n      Metric (per task, download + extract)\n    <\/th>\n<th>\n      Before\n    <\/th>\n<th>\n      After (bundled)\n    <\/th>\n<th>\n      Change\n    <\/th>\n<\/tr>\n<tr>\n<td>\n      Task1\n    <\/td>\n<td>\n      ~4.5s\n    <\/td>\n<td>\n      ~0.25s\n    <\/td>\n<td>\n      <strong><img data-opt-id=1851927463  fetchpriority=\"high\" decoding=\"async\" src=\"https:\/\/s.w.org\/images\/core\/emoji\/17.0.2\/72x72\/1f53b.png\" alt=\"\ud83d\udd3b\" class=\"wp-smiley\" \/>\u221294%<\/strong>\n    <\/td>\n<\/tr>\n<tr>\n<td>\n      Task2\n    <\/td>\n<td>\n      ~4.6s\n    <\/td>\n<td>\n      ~0.26s\n    <\/td>\n<td>\n      <strong><img data-opt-id=1851927463  fetchpriority=\"high\" decoding=\"async\" src=\"https:\/\/s.w.org\/images\/core\/emoji\/17.0.2\/72x72\/1f53b.png\" alt=\"\ud83d\udd3b\" class=\"wp-smiley\" \/>\u221294%<\/strong>\n    <\/td>\n<\/tr>\n<tr>\n<td>\n      Both tasks combined, per job\n    <\/td>\n<td>\n      ~9.2s\n    <\/td>\n<td>\n      ~0.5s\n    <\/td>\n<td>\n      <strong>~17x faster<\/strong>\n    <\/td>\n<\/tr>\n<\/table>\n<p>Because this task runs across a huge number of pipelines every day, the small per-job saving compounds dramatically. Overall, we\u2019re making much more efficient use of our build agent infrastructure which means we can run more builds on the same overall CPU quota.<\/p>\n<p>As a pipeline author, this change delivers real savings to your customers while requiring zero changes on the customer\u2019s side.<\/p>\n<h2>Some Caveats<\/h2>\n<p>There are some potential drawbacks here that are worth mentioning.<\/p>\n<ul>\n<li>Bundled files might make debugging more challenging since your stack traces won\u2019t point you to the original source locations. (You can output <a href=\"https:\/\/esbuild.github.io\/api\/#sourcemap\" target=\"_blank\">sourcemaps<\/a> to help with this.)<\/li>\n<li>esbuild and other static bundlers can break dynamic requires. Make sure you thoroughly test your tasks after bundling. (You may need to use the <a href=\"https:\/\/github.com\/microsoft\/azure-pipelines-task-lib\/issues\/942#issuecomment-1967020944\" target=\"_blank\">external option for some dependencies<\/a> <\/li>\n<li>Bundling is a tradeoff that can result in higher memory usage. With a single large, bundled JS file, the V8 engine now needs to load the entire file into memory at startup instead of loading smaller files as they are needed. If this is a concern, you could experiment with <a href=\"http:\/\/splitting\/\" target=\"_blank\">https:\/\/esbuild.github.io\/api\/#splitting<\/a>.<\/li>\n<\/ul>\n<h2>Should you do this? (A checklist for task publishers)<\/h2>\n<p>If you publish a Node-based Azure Pipelines task, you can very likely get the same benefit:<\/p>\n<ul>\n<li>Check your package. Does your published task ship a <code>node_modules<\/code> folder with hundreds or thousands of files? (Look at the .vsix\u00a0contents by renaming it to .zip and extracting the contents) <\/li>\n<li>Add a bundler (esbuild, ncc, or webpack) that emits a single <code>script.js<\/code> per task with bundle and treeShaking\u00a0enabled, 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)<\/li>\n<li>Point <code>task.json<\/code> at the bundled entry file.<\/li>\n<li>Watch for duplicated stateful modules (especially\u00a0azure-pipelines-task-lib) and deduplicate to a single instance.<\/li>\n<li>Fix any \u00a0<code>__dirname<\/code>\u00a0-relative asset reads if bundling changes your output\u2019s folder depth.<\/li>\n<li>Verify the task still runs, then compare your Initialize job log timestamps before and after.<\/li>\n<\/ul>\n<p>It\u2019s a small, self-contained change, and as we found, the payoff scales with how often your task runs.<\/p>\n<p>Ask your coding agent to draft a PR and test the results.<\/p>\n<p>The post <a href=\"https:\/\/devblogs.microsoft.com\/devops\/shrinking-azure-pipeline-task-extensions-using-esbuild\/\">Shrinking Azure Pipeline task extensions using esbuild<\/a> appeared first on <a href=\"https:\/\/devblogs.microsoft.com\/devops\">Azure DevOps Blog<\/a>.<\/p>","protected":false},"excerpt":{"rendered":"<p>TL;DR We bundled an internal Azure Pipelines task extension into a single bundled JavaScript file using esbuild. The task package [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":94,"comment_status":"","ping_status":"","sticky":false,"template":"","format":"standard","meta":{"site-sidebar-layout":"default","site-content-layout":"","ast-site-content-layout":"default","site-content-style":"default","site-sidebar-style":"default","ast-global-header-display":"","ast-banner-title-visibility":"","ast-main-header-display":"","ast-hfb-above-header-display":"","ast-hfb-below-header-display":"","ast-hfb-mobile-header-display":"","site-post-title":"","ast-breadcrumbs-content":"","ast-featured-img":"","footer-sml-layout":"","ast-disable-related-posts":"","theme-transparent-header-meta":"","adv-header-id-meta":"","stick-header-meta":"","header-above-stick-meta":"","header-main-stick-meta":"","header-below-stick-meta":"","astra-migrate-meta-layouts":"default","ast-page-background-enabled":"default","ast-page-background-meta":{"desktop":{"background-color":"var(--ast-global-color-4)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"tablet":{"background-color":"","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"mobile":{"background-color":"","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""}},"ast-content-background-meta":{"desktop":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"tablet":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"mobile":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""}},"footnotes":""},"categories":[3],"tags":[],"class_list":["post-4566","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-azure"],"_links":{"self":[{"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/posts\/4566","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/comments?post=4566"}],"version-history":[{"count":0,"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/posts\/4566\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/media\/94"}],"wp:attachment":[{"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/media?parent=4566"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/categories?post=4566"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/tags?post=4566"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}