Skip to content

Conversation

pearmini
Copy link
Member

@pearmini pearmini commented Jun 7, 2025

Overview

This PR proposes a method to optimize the bundle size of ML5 applications by making each model independently importable. Although it may not be merged immediately due to the potential trade-offs with beginner-friendliness, it serves as a proof of concept that could be useful in the future. Beyond the bundle size, this PR also implements a more modern way of exporting objects.

Issues

Currently, the bundle size of ml5 is at least about 4.3 MB, with two main issues:

  • All models are bundled into the final application even if only one is used.
  • The size will continue to grow as we add more models, especially those using transformers.js.

There are three potential directions for reducing the bundle size:

  • Reduce the total bundle size by excluding unused modules.
  • Allow importing individual models on demand.
  • Provide a service that supports downloading different ml5 bundles dynamically.

This PR explores the first two directions. Before going further, let’s look at how ml5 is typically used.

Using ml5 in vanilla HTML

The most common way to use ml5 is to load it from a CDN or download it locally:

<html>
  <script src="https://unpkg.com/ml5@1/dist/ml5.min.js"></script>
</html>

To reduce the total bundle size or allow loading models individually, we could provide separate bundle entries for different models. For example, to use BodyPose only:

<html>
  <script src="https://unpkg.com/ml5@1/dist/ml5-body-pose.min.js"></script>
</html>

This would require building and distributing different submodules for each model.

Using ml5 with bundlers

Another common usage is with modern bundlers:

import ml5 from "ml5";

const bodyPose = ml5.bodyPose();

Currently, this syntax does not support tree-shaking, so bundlers cannot remove unused models automatically. However, if we support on-demand bundling, the final bundle would include only the models that are actually imported.

Bundle Analysis

First, let’s analyze the dependencies in the ml5 bundle to understand where most of the size comes from. Using webpack-bundle-analyzer, we get the following result:

image

From this treemap, we can see that the size mainly comes from TensorFlow.js and Vega (used in NeuralNetworkVis via tfvis). There isn’t much room for direct size reduction here because these modules are required by ml5’s functionality.

However, I noticed that we currently use namespace imports for TensorFlow:

import * as tf from "@tensorflow/tfjs";

This imports all modules from TensorFlow.js, even if only a few functions are used. Using named imports might help.

Named Imports

This PR (pearmini#1) experiments with using named imports to reduce bundle size by replacing namespace imports with named ones:

// Before
import * as tf from "@tensorflow/tfjs";

// After
import { ready, nextFrame } from "@tensorflow/tfjs";

Unfortunately, this change did not reduce the bundle size. This shows that ml5 depends on nearly all modules in TensorFlow.js and related packages (e.g., @tensorflow-models/body-segmentation).

Based on this, there’s a clear limit to how much we can reduce the bundle size by changing imports alone. So I switched to focusing on making each model importable independently.

Independently Importable Models

Currently, it’s not possible to import each model separately because all models are combined into a single ml5 object:

// src/index.js 
const withPreload = {
  bodyPose,
  bodySegmentation,
  // ....
};

const ml5 = Object.assign({ p5Utils }, withPreload, {
  tf,
  tfvis,
  // ...
});

p5Utils.shouldPreload(ml5, Object.keys(withPreload));

export default ml5;

There are two issues here:

  • Properties of an object cannot be imported individually.
  • shouldPreload processes all models together instead of one by one.

To solve this, I created a new method to handle the post-processing step for individual modules:

// src/BodyPose/index.js
export const bodyPose = p5Utils.maybeRegisterPreload((...inputs) => {
  const { string, options = {}, callback } = handleArguments(...inputs);
  return new BodyPose(string, options, callback);
});

With this, we can switch to named exports:

// src/index.js
export {
  bodyPose,
  bodySegmentation,
  // ...
};

Now, tree-shaking works in ESM environments:

// After
import {bodyPose} from "ml5";

const bodyPose = bodyPose();

// Before
import ml5 from "ml5";

const bodyPose = ml.bodyPose();

This also enables loading models individually without using a bundler.

Model-Level Bundles

Now that each model can be processed individually, we can treat them as separate entry points. For example, by modifying webpack.config.js:

// webpack.config.js
const productionConfig = {
  mode: "production",
  entry: {
    "ml5.min": "./src/index.js",
    "ml5-body-pose.min": "./src/BodyPose/index.js",
     // ...
  }
}

This creates separate bundles for each model:

<script src="https://unpkg.com/ml5@1/dist/ml5-body-pose.min.js"></script>

The bundle size is significantly smaller if only one model is used:

image

However, this introduces two new challenges:

  • Longer build time: Bundling all modules separately takes nearly a minute.
  • Larger total bundle size for multiple models: Some dependencies, like TensorFlow.js, are included multiple times. For example:
image

To solve the long build time, we could try Rspack, a Rust-based bundler compatible with our configuration. To solve the duplicate dependencies, I explored externalizing TensorFlow.js.

Externalizing TensorFlow.js

The idea is to remove TensorFlow from each model bundle and load it only once:

<script src="ml5-tensorflow.min.js"></script>
<script src="ml5-body-pose.min.js"></script>
<script src="ml5-hand-pose.min.js"></script>

I explored this in PR #2 with these results:

Without Externals With Externals
image image

Each model bundle is now much smaller and can be composed with ml5-tensorflow.min.js. However, this approach increases complexity for users since they need to manually manage which core libraries to load.

Discussions

Based on these explorations, there is limited room to directly optimize the total bundle size. The only practical approach is to make models independently importable.

However, this introduces complexity: users must know exactly which bundles to load for their use case. This conflicts somewhat with ml5’s mission of being out-of-the-box and beginner-friendly.

The biggest benefit of this PR is the move to named exports, which aligns with modern JavaScript best practices and creates more room for future improvements.

Future Explorations

So far, these explorations focus on task-level optimization. We should also consider model-level optimization as mentioned by @shiffman:

// This model is from tensorflow.js
let depthEstimator = await ml5.depthEstimation("ARPortraitDepth");

// This model is from transformers.js
let depthEstimator = await ml5.depthEstimation("depth-anything-v2-small");

If the model is chosen using a string, both dependencies must be bundled:

import {modelA} from "transformers.js";
import {modelB} from "tensorflow.js";

function depthEstimation(name) {
  return name === "ARPortraitDepth" ? modelA: modelB
}

A possible solution is to make the parameter an explicit variable:

// This model is from tensorflow.js
let depthEstimator = await ml5.depthEstimation(ml.ARPortraitDepth);

// This model is from transformers.js
let depthEstimator = await ml5.depthEstimation(ml.depthAnythingV2Small);

Or expose separate top-level APIs:

// This model is from tensorflow.js
let depthEstimator = await ml5.depthEstimationTensorflow();

// This model is from transformers.js
let depthEstimator = await ml5.depthEstimationTransformers();

But both approaches increase the API surface and introduce additional complexity for beginners.

Summary

If we prioritize ease of use and out-of-the-box functionality, there’s limited room for bundle size optimization.

However, it’s still possible to find a balance — keeping the default ml5.min.js for beginners and providing clear documentation for advanced users who care about bundle size.

For example, the main reference pages could continue using ml5.min.js by default, while we maintain a dedicated “Optimize Bundle Size” guide for advanced users.

@pearmini pearmini marked this pull request as draft June 7, 2025 14:12
@pearmini pearmini force-pushed the bundle-size-optimization branch from bc3f155 to d305fd3 Compare June 7, 2025 14:12
@pearmini pearmini force-pushed the bundle-size-optimization branch from d305fd3 to 94a7912 Compare June 7, 2025 14:13
@pearmini pearmini changed the title Change to name exports Optimize the bundle size of ML5 applications Jun 7, 2025
@shiffman
Copy link
Member

Hi @pearmini, thank you for working on this, super exciting! Is the main reason for the large size the "bundling" of tensorflow.js within ml5.js? Is that something we should reconsider or discuss or does ml5.js perhaps not require a full tensorflow.js bundle? I ask in anticipation of the possibility of including transformers.js models as well in the future?

@pearmini
Copy link
Member Author

pearmini commented Jun 15, 2025

Hi @shiffman,

I believe the large bundle size mainly comes from tensorflow.js and aslo vega (from tfvis used in NeuralNetworkVis).

image

That said, after tree-shaking support is introduced in this PR, bundling in a Node environment has become much more efficient. If users use modern bundlers like Rollup, Webpack, or esbuild, only the models they actually use will be included in the final output. So in those cases, including transformers.js models shouldn’t be a major concern for bundle size.

However, for the browser environment, things are more challenging. The current ml5.min.js includes full tensorflow.js, which is likely required (I'll check it!) and limits our ability to reduce size significantly. One feasible solution might be to modularize the library (like we do in this PR) — allowing users to load models via separate bundles and treating tensorflow.js and transformers.js as external dependencies.

<script src="tensorflow.min.js"></script>
<script src="transformers.min.js"></script>
<script src="ml5-body-pose.min.js"></script>
<script src="ml5-hand-pose.min.js"></script>
<script src="ml5-transformer-model.min.js"></script>

This would keep the core bundles lean and give users more flexibility in managing what they load.

I’ll take a closer look at the dependency graph to pinpoint where the size is coming from. Definitely looking forward to discussing this more with you!

@limzykenneth
Copy link

Nice to see the move towards individual model being independently importable here!

A couple suggestion, for p5.js 2.0 we have switched to Rollup (from browserify which was very slow) as they default to ESM for all input files which makes things more semantic overall, but another reason Rollup may be a good choice is Rolldown which is a reimplementation of Rollup in Rust that improves build speed while largely having compatibility. I'm planning to switch p5.js over to Rolldown once they have a full stable release which hopefully can give good gains in build performance.

I think sticking with Webpack is fine as well though if it works for your case. There is technically Rspack that does similar with Rollup/Rolldown but I've not used it yet and not sure how well it plays with more complex Webpack config.

From trying to reduce bundle size of p5.js for 2.0, my two main methods are first to rely on treeshaking as much as possible, assuming the dependencies are set up well to treeshake and I import modules in a treeshakeable manner; and secondly to reduce the number of dependencies by relying on as many native API as possible without polyfill, not sure how relevant these will be for you though since it looks like tensorflow alone is taking up most of the bundle size here.

@gohai
Copy link
Member

gohai commented Jun 20, 2025

Posting a thought here (before it escapes me): in previous conversations, @shiffman had imagined a scenario where we might switch between tensorflow.js and transformers.js-backed pretrained models by passing different values to the constructor... e.g. we have already BodyPose("MoveNet") and BodyPose("BlazePose"), and there might be more (possibly built with transformers). Just wondering if/how this could mesh with your proposal here @pearmini? e.g. is there a conceivable way how this wouldn't pull in all the dependencies regardless? (I don't think we're very set on this functionality either, but it was something that we once imagined - perhaps worth exploring as you weigh the cost/benefits of various directions...)

@pearmini
Copy link
Member Author

@limzykenneth Thanks so much for the valuable suggestions!

ml5 is facing a similar issue as p5.js—bundling all the modules takes nearly a minute! Switching to Rollup or Rolldown could be a great improvement, as both significantly reduce bundle time and maybe a little bit bundle size. In that case, migrating to Vite for development might also make sense.

However, for now, Rspack seems like an easier solution since it's more stable and compatible with the current configuration.

I'll experiment with these options if we need them and dive deeper into the dependencies to explore further optimizations!

@pearmini
Copy link
Member Author

is there a conceivable way how this wouldn't pull in all the dependencies regardless

@gohai This is definitely possible! We could introduce an explicit API pattern like the following:

import {faceMesh, tensorFlow} from "ml5";

const faceMesh = await faceMesh({
  runtime: tensorFlow()
});
import {faceMesh, transformer} from "ml5";

const faceMesh = await faceMesh({
  runtime: transformer()
});

The key idea is that we pass the runtime engine explicitly to the model constructor, which allows bundlers to include only the engine actually used. However, this also means the runtime is no longer optional—otherwise, the default would be bundled regardless:

import {faceMesh, tensorFlow} from "ml5";

const faceMesh = await faceMesh(); // This will not work as expected.

@shiffman
Copy link
Member

Thanks all for the wonderful thoughts on this thread! I think there is one missing piece to this discussion. There is both the concept of a "task" in ml5.js as well as a "model" selection for that task.

I believe it makes the most sense to have smaller ml5.js bundles on the "task" level but we could consider the model level. For some tasks there is is only one model , but others might have multiple options.

The selection of tensorflow.js or transformers.js, however, happens at the model level. So let's take the case of depth estimation from #248, for example, we might have:

// This model is from tensorflow.js
let depthEstimator = await ml5.depthEstimation("ARPortraitDepth");
// This model is from transformers.js
let depthEstimator = await ml5.depthEstimation("depth-anything-v2-small");

It may simplify things, however, to restrict a task to only one back-end. For depth estimation and object detection, the approach depends on the research of @nasif-co and @yiyujin happening now in parallel!

In the case of, say, bringing in ml5.speechToText() we would load the whisper model with transformers.js and there would be no equivalent tensorflow.js option.

I'd be curious what the bundle size would be including both tensorflow.js and transformers.js? I worry about requiring the beginner to both add an ml5.js script tag along with the correct tensorflow.js or transformer.js version. That may be confusing and prone to error?

Adding @xenova who may have some thoughts or advice from the transformers.js side!

@pearmini
Copy link
Member Author

pearmini commented Jul 2, 2025

@shiffman

For model selection, we can reduce the bundle size by changing the model parameter from a string to a variable:

// This model is from tensorflow.js
let depthEstimator = await ml5.depthEstimation(ml5.ARPortraitDepth);
// This model is from transformers.js
let depthEstimator = await ml5.depthEstimation(ml5.depthAnythingV2Small);

This way, the bundler will only include the dependencies required for the specific model!

A simple implementation may look like this:

class DepthEstimation {
  constructor(model) {
    this._model = new Model();
  }
  detect() {
    this._model.detect();
  }
}

class DepthAnythingV2Small {
  detect() {
    console.log("Detect by ARPortraitDepth");
  }
}

class ARPortraitDepth {
  detect() {
    console.log("Detect by Depth Anything V2 Small");
  }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants