Blog post preview
September 21, 2026

10 practical JointJS performance tips for fast, efficient production-grade diagrams

JointJS can easily handle large diagrams by default, but for truly massive diagrams with thousands or even tens of thousands of elements, you need to pay attention to performance and use some optimization techniques.

In this guide, you’ll find the most efficient techniques for keeping large diagrams fast and responsive, leveraging JointJS 4.3 features and hands-on experience.

Note:

This guide focuses on the core JavaScript version of the JointJS library. JointJS for React, on the other hand, includes many of these optimization techniques out of the box, built in as defaults.

1.  Use async rendering, view management, and autoFreeze

To set up a strong foundation for performance on production-grade diagrams, your default setup should always include async rendering and view management.

Enable async rendering

For graphs with hundreds or more cells, rendering everything synchronously blocks the main thread. Enable async rendering to batch renders across multiple animation frames:

new dia.Paper({ async: true });

With async rendering, the diagram appears progressively, while the UI stays responsive.

Enable view management

In scenarios with production-grade diagrams and a large number of cells, the performance of Paper can be drastically improved if you enable view management, which, when set to true (viewManagement: true), enables lazy initialization of cell views, meaning that a cell view will only be created and attached to the DOM when the cellVisibility() callback returns true for the cell.

const paper = new dia.Paper({
    async: true,
    viewManagement: true
});

This option switches Paper to a modern rendering behavior. Without viewManagement, Paper stays in legacy mode, where views are created in memory, even if they are never used during the app lifecycle. Treat this as the baseline for all techniques that follow.

Enable autoFreeze

Set autoFreeze: true to stop the async render loop when there are no pending updates (idle state), reducing CPU usage and allowing garbage collection to run sooner, improving resource efficiency.

const paper = new dia.Paper({
  async: true,
  viewManagement: true,
  autoFreeze: true,
});

// If the paper is idle and needs to re-check visibility.
// Note that paper.wakeUp() requires viewManagement
paper.wakeUp();

The paper will wake up automatically when new updates arrive, but you can manually wake it up by calling paper.wakeUp() when the change happens outside of the graph and has an impact on cell visibility.

2. Optimize bulk operations

Each model change in JointJS (adding, removing, or modifying cells) can trigger a re-render. For bulk updates, such as loading or resetting the whole graph, wrap your operation in paper.freeze() / paper.unfreeze():

paper.freeze();
graph.resetCells(cells); // faster than looping addCell
paper.unfreeze();

If you’re using async mode, wrapping your operations in freeze/unfreeze is not necessary. 

Note that resetCells is always faster than looping through addCell, because it replaces the internal collection in one go and updates the DOM z-order once.

3. Use virtual rendering with PaperScroller

The single biggest win for large diagrams is setting the scroller to only mount views for cells inside (or near) the visible viewport: 

const scroller = new ui.PaperScroller({
    paper,
    virtualRendering: {
        // px buffer beyond the viewport edge
        margin: 100,                     
        
        // default; prioritize mounting over unmounting
        prioritizedCellVisibility: true
    }
});

Note that the wrapped paper must have async: true and viewManagement enabled. If either is missing, the scroller throws an error at runtime:

new dia.Paper({
    async: true,
    viewManagement: {
      	// Destroy views scrolled out of viewport
        disposeHidden: true,        

      	// Pre-calculate geometry before mount
      	initializeUnmounted: true   
    }
});

Setting disposeHidden is critical, as it completely removes hidden cell views from memory, allowing them to be garbage collected, making massive diagrams viable. Note that it does not apply to views that currently have tools or highlighters attached; those are detached from the DOM but kept in memory.

Setting initializeUnmounted: true will make new cells hidden by default, making the paper scroller prioritize creating and rendering of the cells in the viewport first.

4. Don't wait for the diagram to render

A common pattern with async rendering is to load the cells, wait for render:done event, then zoom to fit the diagram. The wait is there because fitting needs a content bounding box, and bounding boxes are measured from views that don't exist yet.

Instead, you can pass useModelGeometry to paper.transformToFitContent() to zoom the diagram just from the model sizes:

graph.resetCells(cells);

paper.transformToFitContent({ 
  useModelGeometry: true 
});

The models are complete as soon as resetCells() returns, so this can run immediately before anything is even rendered.

If you’re using virtual rendering (as you should), you’ll need to take this approach for zoom to fit, as cells outside the viewport are never rendered, so a view-based content box covers only what's visible on the screen.

5. Use dia.SearchGraph + QuadTree for spatial queries

Swap dia.Graph for dia.SearchGraph when you need point/area lookups (hit-testing, proximity checks, layout):

// Enter `lazy` mode
searchGraph.setQuadTreeLazyMode(true);

// Enter `eager` mode
searchGraph.setQuadTreeLazyMode(false);

Methods like findElementsAtPoint  and findElementsInArea become O(log n), not O(n). Lazy mode defers rebuilding until the first query, which is useful if you have many rapid model changes before any query.

Eager vs lazy mode:

  • Eager mode (default, setQuadTreeLazyMode(false)) rebuilds the quadtree immediately on every graph change. Use it when spatial queries are frequent, for example, while doing live hit testing during drag.
  • Lazy mode (setQuadTreeLazyMode(true)) defers the rebuild to the first query after a change. Use it when the graph changes frequently, but queries are infrequent, for example, when bulk loading is followed by a single layout pass.

Prefer has* methods over find* methods

Methods starting with has* short-circuit on the first match and don't allocate a results array:

// Slower — always collects all results
const hit = graph.findElementsAtPoint(point).length > 0;
  
// Faster — exits on the first match
const hit = graph.hasElementsAtPoint(point);

Align the quadtree with your cell visibility

If you hide cells via cellVisibility, tell the quadtree to exclude them too, otherwise, spatial queries return cells that would immediately be filtered out:

const isVisible = (cell) => !cell.get('hidden');
const paper = new dia.Paper({ cellVisibility: isVisible, ... });
searchGraph.setQuadTreeIndexFilter(isVisible);

6. Use useModelGeometry wherever possible

JointJS resolves link endpoints through two strategies: 

  • DOM-based reads the element's rendered bounding box from the SVG.
  • Model-based uses the position and size stored in the cell's data model, skipping the DOM entirely. 

DOM-based is the default, and it's the more accurate option. It works from the SVG elements that are actually on screen, so it follows everything precisely, from the outline of a circle to a label that overflows its box. It follows the real edge of a polygon.

The model doesn’t have those details. It stores a position and a size, so as far as the model is concerned, every element is a rectangle, unless you write something that derives a more specific geometry from its attributes. 

Model geometry is faster because it never touches the DOM. Set useModelGeometry: true on the anchors and connection points you use:

const paper = new dia.Paper({
	// Model-based geometry
	defaultAnchor: { name: 'modelCenter' },

    // Only 'bbox' and 'rectangle' connection points support useModelGeometry
    defaultConnectionPoint: { name: 'bbox', args: { useModelGeometry: true } }
});

Log DOM measurements with measureNode

Several tips in this article can be boiled down to the advice that you should avoid measuring SVG nodes. That’s precisely why model geometry (useModelGeometry) exists. You can use the measureNode option to find out whether DOM measurement is actually happening in your app.

The simplest setup to see if your diagram relies on browser measurement:

const paper = new dia.Paper({
    measureNode: () => console.log('A DOM measurement was requested!')
});

Additionally, it’s a good idea to avoid using markup attributes, as they rely on the DOM measurements. Where you can, replace them with calc() expressions, which don’t rely on the browser's bbox measurements and don’t impact performance negatively:

attrs: {
  label: {
    // . . .
    x: 'calc(w / 2)',
    y: 'calc(h / 2)'
  }
}

7. Choose the right link router

Routers re-run on every source/target move. Cost order, cheapest to most expensive:

Router Note
normal Near zero cost. Straight line. No obstacle avoidance.
rightAngle Cheap. Right-angle, replacement for orthogonal. Avoids collisions with source and target elements, but does not avoid other obstacles
manhattan Expensive.
metro Expensive, different configuration for manhattan.

Use normal for straight connections and rightAngle when you need right angles, as these are the most performant options. Reach for metro or manhattan only when your diagram genuinely needs links to route around elements.

Connectors have their own cost, and jumpover can be very expensive. It draws an arc wherever two links cross, so it has to check each link against the others, and the work grows with the number of links. Avoid it on large graphs.

If you need real obstacle avoidance at scale, avoid the manhattan router and use @joint/router-avoid instead. It routes JointJS links with libavoid, a C++ library for automatic, obstacle-avoiding orthogonal connector routing, compiled to WebAssembly via libavoid-js.

8. Debounce expensive model event reactions

Don't trigger layout, validation, or serialization synchronously on every change event. Use debouncing instead:

import { util } from '@joint/plus';
const reLayout = util.debounce(() => runLayout(), 150);
graph.on('change:size', reLayout);

If you want to save the diagram after each meaningful user change, a better approach would be to use the command manager to save the graph after each batch, and use debounce only if you specifically want or need it:

import { dia } from '@joint/plus';
const commandManager = new dia.CommandManager({ model: graph });

commandManager.on('stack', () => {
    fetch('/api/diagram', {
        method: 'POST',
        body: JSON.stringify(graph.toJSON())
    });
});

The baseline for a performant Paper setup

Based on the tips described in this guide, your potentially large diagrams should start with this setup:

const paper = new dia.Paper({
    async: true,
    autoFreeze: true,
    viewManagement: {
        // fully GC views scrolled out of the viewport
        disposeHidden: true,

        // all new cells start unmounted; you control render order
        initializeUnmounted: true,   
    },
    defaultAnchor: { 
        name: 'center', 
        args: { useModelGeometry: true } 
    },
    defaultConnectionPoint: { 
        name: 'rectangle', 
        args: { useModelGeometry: true } 
    }, 
    cellViewNamespace: shapes,
});
  • async: true — renders in batches across animation frames instead of blocking the main thread. Essential beyond a few hundred cells.
  • autoFreeze: true — puts the paper in an idle state when there are no pending updates, stopping the render loop and allowing GC to run sooner. Wake it with paper.wakeUp() (not the old checkViewport()).
  • disposeHidden: true — when a cell leaves the viewport, its view is completely removed from memory and garbage collected. Caveat: views with tools or highlighters attached are detached but not fully disposed.
  • initializeUnmounted: true — all newly added cells go to the unmounted queue instead of triggering a render. Combine with prioritizeCellViewMount() to control which cells render first.
  • useModelGeometry — calculates paper content area from cell models instead of views.

Advanced performance optimization techniques

If you’re working on large-scale diagramming applications, you might want to consider advanced performance optimization techniques that require a bit of additional setup. Use the examples below as ideas for how to implement them in your projects.

9. Adjust the level of detail to the zoom

A well-designed diagram element often has many details, from labels and ports to icons and a status badge. This is useful at 100% zoom level, but as the user zooms out, more of the diagram becomes visible, and the details on each node can become unreadable, not adding anything to the diagram or UX. At the same time, the browser still has to paint everything.

That’s exactly where you can omit those details, making the diagram clearer, more usable, and more performant by applying level-of-detail (LOD) optimization.

An example setup with CSS can look like this. Give the label a class in the markup:

const Node = dia.Element.define(
  'Node',
  { /* ... */ },
  {
    markup: [
      { tagName: 'rect', selector: 'body' },
      { tagName: 'text', selector: 'label', className: 'node-label' },
    ],
  },
);

When the zoom crosses your threshold, simply toggle a single class on the paper element :

let labelsHidden = false;

paper.on('scale', (sx) => {
   const shouldHide = sx <= 0.4;

   // only set the class on a real change
   if (shouldHide === labelsHidden) return;
   labelsHidden = shouldHide;
   paper.el.classList.toggle('labels-hidden', shouldHide);
});

In your CSS, you only need something like this:

.labels-hidden .node-label {
   display: none;
}

With this setup, you essentially need one class on one node to stop the browser from painting thousands of text nodes, which would be unreadable anyway. No model changes, no re-render, and the labels are back the moment you zoom in.

You can take this technique further by adding additional thresholds, depending on what your diagram needs.

We built a demo so you can see the LOD optimization technique and the difference it can make for yourself: https://www.jointjs.com/demos/level-of-detail

The demo is a 1,200-node service map built in JointJS+ where every element swaps its rendering for a less-expensive one when you zoom out: a full card at reading zoom, a plain chip in the middle, and a single tinted rectangle when the whole map is on screen.

10. Switch to canvas for low zoom levels

You can take the level of detail optimization even further. On large diagrams, SVG can become expensive at low zoom levels because the browser still renders every path even when nodes are sub-pixel in size. Replace SVG with an HTML <canvas> below a zoom threshold.

For example, this is the pattern used in the JointJS performance demo where rendering switches to canvas:

// switch to canvas below this zoom level
const THRESHOLD = 0.6;

// tell the paper to render no SVG views below the threshold
virtualRendering: {
 cellVisibility: () => scroller.zoom() > THRESHOLD
}

// render everything to canvas instead
class MyBitmapPaper extends BitmapPaper {
 drawElement(el) {
     const bbox = el.getBBox();
     ctx.beginPath();
     ctx.roundRect(bbox.x, bbox.y, bbox.width, bbox.height, 8);
     ctx.fillStyle = el.attr('body/fill');
     ctx.fill();
 }
 drawLink(link) { /* ctx.lineTo */ }
}

new BitmapController({ paper, maxZoom: THRESHOLD, bitmapPaperClass: MyBitmapPaper });

The most important performance takeaways 

The bottom line is that, in order to maximize performance, you have to:

  • Minimize the number of DOM elements by enabling virtual rendering, and show only as many details as necessary in the diagram based on the canvas zoom level (LOD).
  • Avoid DOM measuring by enabling useModelGeometry, and use the measureNode callback that implements a heuristic to calculate the size of DOM elements (for example, you could use the Pretext library to measure text).
  • Use SearchGraph to search for cells based on their spatial properties, as it’s optimized for spatial queries and uses a Quadtree data structure. (It’s a drop-in replacement for the dia.Graph class.)

Conclusion

JointJS can easily handle massive diagrams if you apply some of the performance techniques outlined here. Combine freezing, virtual rendering, async updates, and cut down the DOM clutter for the best results. Above all, always measure and profile your app's performance.

For more details, see the JointJS documentation, and if you want advice tailored to your app and exact setup, installing the JointJS MCP server can give you precise performance tips for your use case.

FAQ

Authors
Blog post author
Zoran Jambor
Developer Advocate at JointJS. Founder of CSS Weekly. Interested in developer tooling and workflows.
Roman Bruckner
Diagramming enthusiast from an early age, traveler and a persistent contributor to the developer community.
Stay in the loop

Speed up your development with a powerful library