Why I Analyzed 16,384 Bundle Combinations (And You Should Too)

I believe in radical transparency when it comes to bundle sizes. When developers are choosing a library, they deserve to know exactly what they're paying for in terms of bundle impact. That's why, when building neodrag v3, I decided to analyze every single possible plugin combination and report precise bundle sizes for each one. That meant analyzing 2^14 = 16,384 different combinations. Let me walk you through why I went to these lengths and how I tackled this challenge. What is Neodrag? Neodrag is a TypeScript drag-and-drop library that I've been working on for a few years now. Unlike other drag libraries that come as monolithic packages, I wanted to create something truly modular where developers only pay for what they use. The library lets you make any DOM element draggable with a simple API, but the real power comes from its plugin system. Want bounds checking? Add the bounds plugin. Need grid snapping? Include grid. Touch support? touchAction. Each plugin handles a specific piece of functionality, and they can all work together seamlessly. The v3 Architecture Challenge Neodrag v3 represents a complete rewrite with a plugin-first architecture. Instead of cramming everything into one bundle, I broke functionality into 14 discrete plugins: applyUserSelectHack - Prevents text selection during drag axis - Constrains movement to X or Y axis bounds - Keeps elements within boundaries controls - Defines drag handles and no-drag zones disabled - Programmatically disable dragging events - Emits drag lifecycle events grid - Snaps movement to a grid ignoreMultitouch - Handles multi-touch scenarios position - Programmatic position control scrollLock - Prevents page scroll during drag stateMarker - Adds CSS classes for styling threshold - Prevents accidental drags touchAction - Optimizes touch behavior transform - Handles DOM transformations The beauty of this approach is that a developer who just wants basic dragging can include only the essential plugins and get a tiny bundle. Someone building a complex interface can include everything they need without worrying about unused code. But here's the challenge: with 14 plugins, there are 2^14 = 16,384 possible combinations. How do I tell developers the exact bundle cost of their specific configuration? Why Bundle Size Transparency Matters I've always cared deeply about performance. In my previous projects like neodrag v2, neoconfetti, and neotraverse, I established a reliable bundle analysis pipeline: tsup for bundling with aggressive tree-shaking rollup under the hood for optimization terser for minification brotli-size to get the final compressed size This gives me the most realistic bundle size that users will actually download - compressed and optimized. But here's what bothers me about most libraries: they give you one number. "Our library is 15KB minified + gzipped!" But what if you're only using 20% of the features? Are you still paying for the full 15KB? With neodrag v3's modular architecture, I wanted to give developers precise numbers. If you use transform + bounds + threshold, I want to tell you exactly what that costs. Not an estimate, not a range - the actual bundled and compressed size. That's going the extra mile for transparency. The Technical Challenge: Understanding the Process Let me walk you through what analyzing a single bundle combination actually involves. It's more complex than you might think. For each combination, I need to: Generate a test file with the exact plugin imports Set up a temporary build environment with proper Node.js modules Run the full build pipeline (bundling, tree-shaking, minification) Measure the compressed result with brotli compression Clean up temporary files Here's what processing just one combination looks like: async function measureCombinationWithBuild(plugins, tempDir, baseSize) { // Step 1: Create a temporary workspace const measureDir = resolve(__dirname, 'temp', 'measure'); mkdirSync(measureDir, { recursive: true }); // Step 2: Copy core package to node_modules (for proper imports) const nodeModulesSource = join(tempDir, 'node_modules'); if (existsSync(nodeModulesSource)) { const nodeModulesTarget = join(measureDir, 'node_modules'); mkdirSync(nodeModulesTarget, { recursive: true }); copyRecursive(nodeModulesSource, nodeModulesTarget); } // Step 3: Generate test content for this specific combination const { actualImports } = getActualImportsForCombination(plugins); const testContent = ` import { DraggableFactory } from '@neodrag/core'; import { ${actualImports.join(', ')} } from '@neodrag/core/plugins'; export const factory = new DraggableFactory({ plugins: [${actualImports.join(', ')}] }); `; // Step 4: Write the test file and package.json const entryPath = join(measureDir, 'test.js'); writeFileSync(entryPath, testContent); const packageJson = { name: 'core-analysis', type:

Jun 14, 2025 - 13:50
 0
Why I Analyzed 16,384 Bundle Combinations (And You Should Too)

I believe in radical transparency when it comes to bundle sizes. When developers are choosing a library, they deserve to know exactly what they're paying for in terms of bundle impact. That's why, when building neodrag v3, I decided to analyze every single possible plugin combination and report precise bundle sizes for each one.

That meant analyzing 2^14 = 16,384 different combinations. Let me walk you through why I went to these lengths and how I tackled this challenge.

What is Neodrag?

Neodrag is a TypeScript drag-and-drop library that I've been working on for a few years now. Unlike other drag libraries that come as monolithic packages, I wanted to create something truly modular where developers only pay for what they use.

The library lets you make any DOM element draggable with a simple API, but the real power comes from its plugin system. Want bounds checking? Add the bounds plugin. Need grid snapping? Include grid. Touch support? touchAction. Each plugin handles a specific piece of functionality, and they can all work together seamlessly.

The v3 Architecture Challenge

Neodrag v3 represents a complete rewrite with a plugin-first architecture. Instead of cramming everything into one bundle, I broke functionality into 14 discrete plugins:

  • applyUserSelectHack - Prevents text selection during drag
  • axis - Constrains movement to X or Y axis
  • bounds - Keeps elements within boundaries
  • controls - Defines drag handles and no-drag zones
  • disabled - Programmatically disable dragging
  • events - Emits drag lifecycle events
  • grid - Snaps movement to a grid
  • ignoreMultitouch - Handles multi-touch scenarios
  • position - Programmatic position control
  • scrollLock - Prevents page scroll during drag
  • stateMarker - Adds CSS classes for styling
  • threshold - Prevents accidental drags
  • touchAction - Optimizes touch behavior
  • transform - Handles DOM transformations

The beauty of this approach is that a developer who just wants basic dragging can include only the essential plugins and get a tiny bundle. Someone building a complex interface can include everything they need without worrying about unused code.

But here's the challenge: with 14 plugins, there are 2^14 = 16,384 possible combinations. How do I tell developers the exact bundle cost of their specific configuration?

Why Bundle Size Transparency Matters

I've always cared deeply about performance. In my previous projects like neodrag v2, neoconfetti, and neotraverse, I established a reliable bundle analysis pipeline:

  1. tsup for bundling with aggressive tree-shaking
  2. rollup under the hood for optimization
  3. terser for minification
  4. brotli-size to get the final compressed size

This gives me the most realistic bundle size that users will actually download - compressed and optimized.

But here's what bothers me about most libraries: they give you one number. "Our library is 15KB minified + gzipped!" But what if you're only using 20% of the features? Are you still paying for the full 15KB?

With neodrag v3's modular architecture, I wanted to give developers precise numbers. If you use transform + bounds + threshold, I want to tell you exactly what that costs. Not an estimate, not a range - the actual bundled and compressed size.

That's going the extra mile for transparency.

The Technical Challenge: Understanding the Process

Let me walk you through what analyzing a single bundle combination actually involves. It's more complex than you might think.

For each combination, I need to:

  1. Generate a test file with the exact plugin imports
  2. Set up a temporary build environment with proper Node.js modules
  3. Run the full build pipeline (bundling, tree-shaking, minification)
  4. Measure the compressed result with brotli compression
  5. Clean up temporary files

Here's what processing just one combination looks like:

async function measureCombinationWithBuild(plugins, tempDir, baseSize) {
  // Step 1: Create a temporary workspace
  const measureDir = resolve(__dirname, 'temp', 'measure');
  mkdirSync(measureDir, { recursive: true });

  // Step 2: Copy core package to node_modules (for proper imports)
  const nodeModulesSource = join(tempDir, 'node_modules');
  if (existsSync(nodeModulesSource)) {
    const nodeModulesTarget = join(measureDir, 'node_modules');
    mkdirSync(nodeModulesTarget, { recursive: true });
    copyRecursive(nodeModulesSource, nodeModulesTarget);
  }

  // Step 3: Generate test content for this specific combination
  const { actualImports } = getActualImportsForCombination(plugins);
  const testContent = `
import { DraggableFactory } from '@neodrag/core';
import { ${actualImports.join(', ')} } from '@neodrag/core/plugins';

export const factory = new DraggableFactory({
    plugins: [${actualImports.join(', ')}]
});
`;

  // Step 4: Write the test file and package.json
  const entryPath = join(measureDir, 'test.js');
  writeFileSync(entryPath, testContent);

  const packageJson = { name: 'core-analysis', type: 'module' };
  writeFileSync(
    join(measureDir, 'package.json'),
    JSON.stringify(packageJson, null, 2),
  );

  try {
    // Step 5: Run the full build pipeline
    await build({
      entry: { [filename]: entryPath },
      format: ['esm'],
      outDir,
      bundle: true,
      target: 'es2020',
      treeshake: { preset: 'smallest', moduleSideEffects: false },
      minify: 'terser',
      terserOptions: {
        compress: { dead_code: true, drop_console: true, unused: true },
        mangle: { toplevel: true },
      },
      noExternal: ['@neodrag/core'],
    });

    // Step 6: Read and compress the result
    const outputPath = join(outDir, `${filename}.js`);
    const content = readFileSync(outputPath, 'utf-8');
    const compressedSize = sync(content); // brotli-size

    // Step 7: Cleanup
    rmSync(measureDir, { recursive: true, force: true });

    return compressedSize;
  } catch (error) {
    console.warn(`Build failed for [${plugins.join(', ')}]: ${error.message}`);
    return baseSize; // Fallback
  }
}

That's a lot of work for one combination. File system operations, Node.js module resolution, AST parsing, tree-shaking analysis, minification, compression... Each combination takes anywhere from 200+ milliseconds to process completely.

Now multiply that by 16,384.

Scaling to 16,384: The Combination Generator

Here's where things get interesting. I need to generate every possible subset of 14 plugins. Fortunately, this maps perfectly to binary representation:

function* generateAllCombinations(allPlugins) {
  const n = allPlugins.length; // 14 plugins

  // Generate all numbers from 0 to 2^14 - 1 (16,383)
  for (let i = 0; i < Math.pow(2, n); i++) {
    const combination = [];

    // Check each bit position
    for (let j = 0; j < n; j++) {
      if (i & (1 << j)) {
        combination.push(allPlugins[j]);
      }
    }

    yield combination;
  }
}

This elegantly generates:

  • [] (no plugins) for i = 0
  • ['applyUserSelectHack'] for i = 1
  • ['axis'] for i = 2
  • ['applyUserSelectHack', 'axis'] for i = 3
  • ... all the way up to all 14 plugins for i = 16,383

Then I run the full analysis:

async function main() {
  console.log('