
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.
To set up a strong foundation for performance on production-grade diagrams, your default setup should always include async rendering and view management.
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.
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.
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.
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.
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.
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.
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.
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.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.has* methods over find* methodsMethods 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);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);JointJS resolves link endpoints through two strategies:
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 } }
});
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)'
}
}Routers re-run on every source/target move. Cost order, cheapest to most expensive:
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.
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())
});
});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,
});paper.wakeUp() (not the old checkViewport()).prioritizeCellViewMount() to control which cells render first.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.
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.
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 bottom line is that, in order to maximize performance, you have to:
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).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.)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.