Skip to main content

Tree-Shaking

info

The examples in this page use TypeScript; adapt them if you write JavaScript.

Introduction

maxGraph is a fork of mxGraph, which did not support tree-shaking efficiently. Regardless of which parts of mxGraph were used, its contribution to the application size was around 820 KB (minified). Since its inception, one of maxGraph's goals has been to provide better tree-shaking support.

This page explains what tree-shaking is, what maxGraph does to support it, and above all what your application must do to benefit from it. The short version: use BaseGraph and register only the features you actually use, through the registries and configuration objects listed in the Global Configuration page.

Tree-shaking is an ongoing effort tracked in issue #665. See Going further for the current limitations.

What is Tree-Shaking?

Tree-shaking is a form of dead code elimination performed at bundling time. Bundle size reduction, dead code elimination, code shrinking and optimization all refer to the same goal: removing from the final bundle the code that the application never runs. The bundler analyzes the dependency graph of the application, starting from its entry points, and keeps only the code that is actually reachable.

The name comes from the tree analogy: the dependency graph is a tree, and shaking it makes the dead branches fall, leaving only the parts of the codebase the application needs.

It differs from traditional dead code elimination on one important point: dead code elimination works within a single module, tree-shaking works across the whole bundle. Rather than analyzing files in isolation, it traces dependencies from the entry points through the complete dependency graph. It is sometimes described as live code inclusion rather than dead code removal.

Why ES modules matter

Tree-shaking relies on the static structure of the ES module syntax. Because import and export declarations can be analyzed without executing the code, and because imports can be done at the granularity of a single named export, the bundler can determine at build time which exports are used and which can be dropped. This is not possible with CommonJS, whose require calls are ordinary function calls resolved at runtime.

Why side effects matter

A module has a side effect when importing it does something beyond exposing its exports, for instance mutating a global, or injecting a stylesheet. A bundler cannot drop a module with side effects, even when none of its exports are used, because the specification requires the side effect to be evaluated.

Library authors declare this with the sideEffects field of package.json. Marking a package as side-effect free lets bundlers skip whole modules and their subtrees, which is far more effective than the export-level analysis alone. Individual expressions can also be annotated with /*#__PURE__*/ or /*#__NO_SIDE_EFFECTS__*/ so that the bundler knows a call can be removed when its result is unused.

Further reading

  • Tree shaking on Wikipedia, for the definition and the history of the technique
  • Tree shaking in the MDN glossary
  • Tree Shaking in the Webpack guides, which details the sideEffects flag and the usedExports optimization
  • Tree-shaking in the tsdown documentation
  • Dead Code Elimination in the Rolldown documentation, which details pure annotations and module-level analysis

Not All Bundlers Behave the Same

Bundlers and build tools do not all implement tree-shaking the same way, and some are more effective than others:

  • Webpack and Rspack work at three levels: module, export and code (minification). They favor correctness and rely on static analysis.
  • esbuild splits modules into top-level statements and analyzes each independently.
  • Rollup and Rolldown perform the finest-grained analysis, at the AST node level, with context-aware side-effect detection. They generally produce the smallest output for libraries.
  • tsdown enables tree-shaking by default.

The consequence is practical: the result depends on your own toolchain, so the same application code can produce noticeably different bundle sizes depending on the bundler and its configuration. Understand how tree-shaking works in your specific environment, and measure there rather than relying on figures obtained elsewhere.

For a detailed comparison of the strategies used by the various bundlers, see the Tree Shaking discussion in the web-infra-dev organization.

What maxGraph Provides

maxGraph is packaged so that bundlers can do their job:

  • The package ships an ESM build (lib/esm) alongside a CommonJS one, selected through the exports conditions of package.json. Bundlers pick the ESM build automatically. Do not force the CommonJS build, it cannot be tree-shaken.
  • The package is declared free of side effects except for its CSS files ("sideEffects": ["**/*.css"]), so bundlers may drop entire unused modules.
  • Built-in elements are registered explicitly rather than implicitly, through registration functions the application calls, which is the mechanism the rest of this page describes.

This is a long-running effort. The main milestones so far:

VersionImprovement
0.6.0Codecs are no longer registered by default
0.11.0MaxLog and MaxWindow are no longer called from within maxGraph, avoiding their transitive inclusion
0.12.0The npm package is declared without side effects
0.14.0Graph mixins start being converted into optional plugins
0.18.0BaseGraph is introduced, along with the registerDefault* functions
0.20.0Dedicated registries replace the monolithic StyleRegistry and CellRenderer registration
0.24.0A dedicated registration helper per built-in EdgeStyle, and the image bundle feature moves to a plugin
0.25.0The cell handlers move from AbstractGraph to the SelectionCellsHandler plugin, so an application that does not register that plugin no longer bundles VertexHandler, EdgeHandler, ElbowEdgeHandler and EdgeSegmentHandler

The impact of these changes is measured on the example applications and communicated in the release notes. See for instance the 0.18.0 and 0.24.0 releases, which both report the bundle size of each example before and after the changes. New features are assessed the same way.

note

The approach is not specific to maxGraph. Other libraries facing the same problem expose a comparable opt-in mechanism, for example Apache ECharts, where charts, components and renderers are imported and passed to a use() function instead of being loaded by default.

The Starting Point: Use BaseGraph

maxGraph provides two concrete graph classes, and the choice between them is the single most important decision for your bundle size. See the Graph page for the full comparison.

  • Graph is the ready-to-use class. It wires a container, a default model and view, registers all built-in shapes, edge styles, perimeters and markers, and loads the default plugin set. It is the historical implementation, the direct descendant of the original mxGraph class, and it is meant for prototyping and evaluation. Because it needs no configuration, it is also what most of this documentation, most example applications and most Storybook stories use, so that each of them focuses on the feature it illustrates rather than on registration boilerplate. Do not read that ubiquity as a recommendation for production.
  • BaseGraph exposes the minimal, tree-shakeable graph skeleton for production builds, where you opt into specific plugins and style elements yourself.

Instantiating Graph pulls the following into your bundle, whether the application uses them or not:

Loaded by GraphContent
registerDefaultShapes()16 built-in shapes
registerDefaultEdgeStyles()8 built-in edge styles
registerDefaultPerimeters()5 built-in perimeters
registerDefaultEdgeMarkers()9 built-in edge markers
getDefaultPlugins()9 plugins, and transitively the cell handlers they instantiate

BaseGraph registers none of them. Nothing is loaded that you did not ask for:

import { BaseGraph } from '@maxgraph/core';

const graph = new BaseGraph({ container });

Such a graph renders vertices and edges, and nothing else. From there, add back exactly what the application needs, as described in the next section.

Where the registration code lives

Style elements can be registered in two places, and the choice is only about where the code sits. Both are equivalent for tree-shaking, since the registries are global either way.

Inside the class, by subclassing BaseGraph and overriding registerDefaults(). The configuration travels with the class definition, which is convenient when the application already has a graph subclass. This is the approach used by the maxGraph example applications.

class CustomGraph extends BaseGraph {
protected override registerDefaults(): void {
ShapeRegistry.add('ellipse', EllipseShape);
registerOrthogonalEdgeStyle();
// ... the other elements the application uses
}
}

Outside the class, at application startup, before any graph instance is created. No subclass is needed, and the registration sits at the entry point of the application.

function registerStyleElements(): void {
ShapeRegistry.add('ellipse', EllipseShape);
registerOrthogonalEdgeStyle();
// ... the other elements the application uses
}

registerStyleElements();

const graph = new BaseGraph({ container, plugins: [/* ... */] });

Both are detailed, with complete imports, in Registering style elements with BaseGraph.

Register Only What You Use

This is the core principle: only load what you need, and register only the features you use.

Style elements, plugins, codecs, i18n and the logger are all optional, but each family opts in through its own mechanism: style elements through their registries and the granular register* helpers, plugins through the plugins constructor option, codecs through the codec registration functions, i18n and the logger through GlobalConfig.

What they share is that each offers a broad shortcut next to the narrow one: registerDefaultStyleElements() and the registerDefault* functions, getDefaultPlugins(), registerAllCodecs(). These load everything at once and therefore cancel the benefit. Always prefer the narrowest option that covers what the application actually uses.

warning

All the registries are global. Registering an element makes it visible to every Graph and BaseGraph instance of the application. Registering "just in case", in a shared module, or in a helper used by tests, is enough to pull the corresponding code into the bundle.

Shapes

Shapes are registered in ShapeRegistry. See Global Configuration, Styles.

import { EllipseShape, ShapeRegistry } from '@maxgraph/core';

ShapeRegistry.add('ellipse', EllipseShape);

Two shapes never need to be registered: RectangleShape is the fallback for vertices and ConnectorShape is the fallback for edges, both configurable through CellRenderer.defaultVertexShape and CellRenderer.defaultEdgeShape.

Avoid registerDefaultShapes() unless the application genuinely draws all sixteen built-in shapes.

Stencil shapes are registered in StencilShapeRegistry, and none are registered by default with either graph class.

Perimeters

Perimeters are registered in PerimeterRegistry. See the Perimeters page.

import { Perimeter, PerimeterRegistry } from '@maxgraph/core';

PerimeterRegistry.add('ellipsePerimeter', Perimeter.EllipsePerimeter);

Note that rectanglePerimeter is declared in the default vertex style, so it must be registered as soon as vertices rely on that default.

EdgeStyles

Edge styles are registered in EdgeStyleRegistry, together with metadata that other features depend on. Since 0.24.0, each built-in edge style has a dedicated helper that sets the correct metadata for you, so prefer them to a raw EdgeStyleRegistry.add() call:

import { registerOrthogonalEdgeStyle } from '@maxgraph/core';

registerOrthogonalEdgeStyle();

The helpers are registerElbowEdgeStyle, registerEntityRelationEdgeStyle, registerLoopEdgeStyle, registerManhattanEdgeStyle, registerOrthogonalEdgeStyle, registerSegmentEdgeStyle, registerSideToSideEdgeStyle and registerTopToBottomEdgeStyle. See the EdgeStyles page.

The metadata matters beyond rendering: handlerKind selects which EdgeHandler implementation is instantiated when the edge is selected. See Choosing the handler of an edge.

Edge markers

Edge markers are registered in EdgeMarkerRegistry. Several marker names share the same factory function, so registering a subset costs less than the number of names suggests:

import { EdgeMarker, EdgeMarkerRegistry } from '@maxgraph/core';

const arrowFunction = EdgeMarker.createArrow(2);
EdgeMarkerRegistry.add('classic', arrowFunction);
EdgeMarkerRegistry.add('block', arrowFunction);

Plugins

BaseGraph loads no plugin. Pass the exact list your application needs through the plugins option:

import {
BaseGraph,
CellEditorHandler,
PanningHandler,
SelectionCellsHandler,
SelectionHandler,
} from '@maxgraph/core';

const graph = new BaseGraph({
container,
plugins: [CellEditorHandler, PanningHandler, SelectionCellsHandler, SelectionHandler],
});

See the Plugins page for the list of available plugins and which ones Graph loads by default. Do not call getDefaultPlugins() with BaseGraph, it defeats the purpose, unless the application genuinely needs all the features it provides.

A read-only or visualization-only application typically needs very few of them. In particular, omitting SelectionCellsHandler keeps all the cell handler classes out of the bundle, since it is the plugin that instantiates VertexHandler, EdgeHandler, ElbowEdgeHandler and EdgeSegmentHandler. See the Cell Handlers page.

Codecs

Since version 0.6.0, no codec is registered by default, with either graph class. Register only what you encode or decode, and prefer the narrow functions:

  • registerModelCodecs for the GraphDataModel alone
  • registerCoreCodecs for the core classes
  • registerEditorCodecs for the Editor classes
  • registerAllCodecs registers everything, so avoid it

If you only serialize the data model, ModelXmlSerializer registers the model codecs under the hood and is enough. See the Codecs page.

i18n

By default, maxGraph uses a no-op i18n implementation that provides no translations. This is deliberate: not all applications need internationalization, and skipping the built-in translations keeps the library lighter.

Only set GlobalConfig.i18n when the application actually displays translated messages, and prefer plugging your existing i18n solution through a custom I18nProvider rather than enabling TranslationsAsI18n, which pulls the Translations machinery in. See the i18n page.

Logger

GlobalConfig.logger defaults to NoOpLogger, which does nothing and costs nothing.

import { ConsoleLogger, GlobalConfig } from '@maxgraph/core';

GlobalConfig.logger = new ConsoleLogger();
warning

Do not use MaxLogAsLogger in production, unless you deliberately display the UI components provided by maxGraph. It directs logs to MaxLog, a built-in console rendered in the page, which transitively pulls MaxWindow and the related DOM utilities into your bundle. Use ConsoleLogger, or your own Logger implementation, when you need logs without the UI.

CSS and images

CSS files are the only part of the package declared as having side effects, so an imported stylesheet is never removed. Import @maxgraph/core/css/common.css only if you use a feature that needs it, such as RubberBandHandler, and consider providing your own rules instead. See the CSS and Images page.

What Not to Load

A checklist of the patterns that silently inflate the bundle:

  • new Graph(container) in production code. It registers every built-in style element and loads every default plugin. Use BaseGraph.
  • registerDefaultStyleElements(), registerDefaultShapes() and the other registerDefault* functions called "to be safe". They are convenience helpers for prototyping, not production defaults.
  • registerAllCodecs() when the application only imports or exports the data model.
  • MaxLogAsLogger, and more generally the UI elements of maxGraph you do not display: MaxLog, MaxWindow, MaxPopupMenu, MaxToolbar, MaxForm.
  • The Editor class and its companions (EditorToolbar, EditorPopupMenu, EditorKeyHandler). This is a large legacy component inherited from mxGraph; do not import it unless you specifically build on it.
  • Forcing the CommonJS build, which cannot be tree-shaken. Let your bundler resolve the import condition of the package exports.
  • Re-exporting maxGraph through a barrel file of your own that your whole application imports, which can defeat the module-level analysis of some bundlers.

Measuring the Impact

The effect of these choices is visible in the example applications shipped in the repository. Each exists in two flavors, so that the two most common bundlers are covered:

ExampleBundlerWhat it demonstrates
ts-exampleViteA Graph-based application, with all defaults
ts-example-selected-featuresViteA BaseGraph subclass registering only the features it needs
ts-example-without-defaultsViteA minimal BaseGraph, no plugin and no style element at all
js-exampleWebpackSame as ts-example, in JavaScript
js-example-selected-featuresWebpackSame as ts-example-selected-features, in JavaScript
js-example-without-defaultsWebpackSame as ts-example-without-defaults, in JavaScript

Comparing the "full", "selected features" and "without defaults" variants of the same application shows what the registration choices are worth. From a clone of the repository, ./scripts/build-all-examples.bash builds them all and prints the resulting bundle sizes; --list-size-only prints the sizes of an existing build.

For your own application, use the bundle analyzer of your toolchain to check what @maxgraph/core actually contributes, for instance rollup-plugin-visualizer with Vite and Rollup, webpack-bundle-analyzer or Rsdoctor with Webpack and Rspack, or source-map-explorer for any bundler producing source maps. Measure before and after the migration described below, in your own environment.

Guide: Improving the Tree-Shaking of an Application Using Graph

Goal: take an existing application built on Graph, and reduce what maxGraph contributes to its bundle, without changing what the application does for its users.

Despite its name, the work is not only about swapping one graph class for another. It covers two distinct things:

  1. The graph class and what it registers, in steps 2 to 4. Graph is replaced by a BaseGraph configured with the exact plugins and style elements the application uses. This is where most of the reduction comes from, and it is the part that only concerns applications still using Graph.
  2. The features that do not depend on the graph class, in step 5. Codecs, i18n and the logger are global opt-ins that the application registers or configures itself, so they deserve a review whichever graph class you end up with. An application already built on BaseGraph can skip straight to that step.

Steps 1, 6 and 7 bracket the work: measure before, check the rendering, measure after.

Both graph classes inherit the same graph API from AbstractGraph, so the calls the application makes on the graph do not change. What differs is the construction: Graph takes positional arguments and loads its defaults on its own, BaseGraph takes an options object and requires the plugins and style elements to be declared explicitly. Proceed incrementally and keep the application running at every step.

The recommended strategy is not to start from an empty graph and guess what to add back. Start by loading everything, exactly as Graph does, so that the application behaves as before, then remove progressively what it turns out not to need. Each removal is a small, verifiable step.

Nothing prevents you from measuring at each of these steps rather than only at the end. Doing so tells you what each removal is actually worth, and whether a given family of elements is worth trimming further.

1. Measure the starting point

Build the application and record the size contributed by @maxgraph/core, using one of the analyzers listed above. Without this baseline, you cannot tell whether the migration paid off.

2. Switch the constructor, keeping all the defaults

Graph takes positional parameters, BaseGraph takes a single options object. At the same time, register explicitly what Graph used to register implicitly: getDefaultPlugins() for the plugins, and registerDefaultStyleElements() for the shapes, edge styles, perimeters and edge markers.

// Before
const graph = new Graph(container, model, plugins, stylesheet);
// After
import {
BaseGraph,
getDefaultPlugins,
registerDefaultStyleElements,
} from '@maxgraph/core';

registerDefaultStyleElements();

const graph = new BaseGraph({
container,
model,
stylesheet,
plugins: getDefaultPlugins(),
});

As explained in Where the registration code lives, the call to registerDefaultStyleElements() can also go into a registerDefaults() override in a BaseGraph subclass. Prefer the subclass when the application already extends Graph, since the override then replaces the subclass you already have, and the later steps are edits to a method you own:

class CustomGraph extends BaseGraph {
protected override registerDefaults(): void {
registerDefaultStyleElements();
}
}
note

This step alone does not reduce the bundle: the application still pulls every built-in. It is a checkpoint. The application must behave exactly as it did with Graph, and any difference at this point is a migration bug, not a missing registration. Commit here before trimming anything.

3. Trim the plugins

Replace getDefaultPlugins() with an explicit list, then remove the plugins the application does not need, one at a time, checking the application after each removal. A read-only or visualization-only application may end up with very few of them, or none.

const graph = new BaseGraph({
container,
plugins: [CellEditorHandler, SelectionCellsHandler, SelectionHandler, PanningHandler],
});

Check the Available Plugins table for what each one provides, and remember that dropping SelectionCellsHandler also drops all the cell handlers.

4. Trim the style elements

Proceed by family of elements, so that a rendering regression points straight at the family you just trimmed.

Start by replacing registerDefaultStyleElements() with the four functions it calls. Nothing is removed from the bundle yet, but each family can now be trimmed on its own:

// Instead of registerDefaultStyleElements()
registerDefaultShapes();
registerDefaultPerimeters();
registerDefaultEdgeStyles();
registerDefaultEdgeMarkers();

Then take the families one at a time. Go through the cell styles of the application, including the defaults of the Stylesheet and the named styles registered with putCellStyle, and collect the values used by the family being trimmed: shape, then perimeter, edgeStyle, and finally startArrow and endArrow.

For the shapes, drop registerDefaultShapes() and register exactly the shapes collected:

// Instead of registerDefaultShapes()
ShapeRegistry.add('ellipse', EllipseShape);

Check the application, then iterate over the other families the same way: perimeters, edge styles, and edge markers. Use the dedicated helper of each built-in edge style rather than registering it by hand.

At the end of the process, no registerDefault* call is left, and what remains is exactly what the application uses:

ShapeRegistry.add('ellipse', EllipseShape);
PerimeterRegistry.add('ellipsePerimeter', Perimeter.EllipsePerimeter);
PerimeterRegistry.add('rectanglePerimeter', Perimeter.RectanglePerimeter);
registerOrthogonalEdgeStyle();
EdgeMarkerRegistry.add('block', EdgeMarker.createArrow(2));

5. Review the codecs, i18n and logger

Unlike the previous steps, this one is independent of the graph class: these features are global opt-ins that the application registers or configures itself, and they are worth reviewing even when it already uses BaseGraph.

Register codecs only if the application imports or exports XML, and prefer the narrow registerModelCodecs to registerAllCodecs. Set GlobalConfig.i18n only if the application displays translated messages. Set GlobalConfig.logger to ConsoleLogger rather than MaxLogAsLogger, which pulls the MaxLog and MaxWindow UI into the bundle. The Register Only What You Use section details each of them.

6. Check for over-trimming

Removing one element too many does not throw. It degrades the rendering silently, which is what makes the incremental approach of the previous steps worthwhile:

Missing registrationSymptom
ShapeThe fallback shape is used: RectangleShape for a vertex, ConnectorShape for an edge
PerimeterNo perimeter point is computed, so the edge connects to the center of the vertex bounding box
EdgeStyleThe routing is not applied, so the edge is drawn as a straight line between its terminals
Edge markerNo marker is drawn, so the arrowhead is missing
EdgeStyle metadataThe wrong EdgeHandler is instantiated, so the handles do not match the actual routing

Review the diagrams visually, and pay attention to the styles exercised only by rarely used screens.

7. Measure again

Compare with the baseline recorded in step 1, in the same environment and with the same bundler configuration. This gives the total gain of the migration, whereas the intermediate measurements only give the gain of a single step.

Going Further

Selective loading of the built-in elements, shapes, plugins, style elements and editing features, is what the rest of this page describes: it is available today. What remains is finer-grained modularity, so that applications stop carrying code they never reach whichever features they opt into.

This work is tracked in issue #665, which serves as the parent issue for the topic and links all the sub-issues. The main ones still open are:

  • #758: make Graph.defaultLoopStyle configurable with a registered EdgeStyle string
  • #762: refactor the Graph class and its mixins to improve modularity and tree-shaking
  • #890: make the EdgeHandler registration in SelectionCellsHandler optional and modular

The main known limitation today is that the mixins of AbstractGraph are still loaded as a whole, so part of the graph API is included even when unused. Issue #762 tracks the extraction of these behaviors into dedicated plugins.