The performance story behind exemONE charts, drawn with EXEM's open-source library EVUI
Hi, I'm Giyeon Son from the FE3 Team in the Integrated Development Division.
I work on the dashboard part of exemONE.
exemONE is an integrated dashboard that lets you observe IT metrics scattered from infrastructure to applications on a single platform. The point is that you can bring many monitoring targets together on one screen.
Another trait of the exemONE dashboard is freedom. Users pick the metrics, targets, and charts they want and build their own screens. We don't decide in advance how many widgets (charts) go on a screen or how many targets attach to one widget.
That freedom also means the user decides how much data appears on one screen. So we have a lot to watch on performance. However users lay out the screen, charts must respond smoothly, even in the brief moment of a hover or a drag.
The exemONE dashboard shows dozens of charts on one screen, with many series per chart, refreshed every second. Most of those charts are drawn with EXEM's open-source library EVUI, and the dashboard is the screen in exemONE that uses EVUI the most. So most of the reports saying "the dashboard is laggy" turned out to be chart performance problems.
This post is about how we have secured chart performance.
1. The user decides how heavy the screen is
Before talking about performance, let me lay out the environment our dashboard runs in.
- Screen: Users can place up to dozens of widgets in one screen (viewport).
- Data: One chart shows hundreds to thousands of series. A scatter chart can plot hundreds of thousands of points. Updates can run in real time, every second.
- Interaction: Users hover, drag, and click to see details in tooltips or modals.
- Constraint: Most of the charts doing the rendering are Canvas-based. Instead of building up DOM, they draw on a canvas every frame.
So when we got reports that "the dashboard is laggy," we suspected Canvas first. The heavier the screen, the more there is to draw. But the bottleneck wasn't only there. Drawing, building the data upstream and handing it to the reactivity system, and the DOM that pops up outside the canvas all overlapped. Many of the fixes required working on those together.
2. Scatter: a bottleneck made by data and canvas together
A scatter chart shows the distribution of many observations as points. It can draw hundreds of thousands of points on one screen, so canvas rendering was clearly a bottleneck. But the cost of building and handing over the data every time new data arrived was significant too. To make real-time scatter smooth, we had to change both how data is updated and how it is drawn.
2-1. The birth of Real Time Scatter: we were swapping out the whole dataset
Real-time scatter is a screen where points keep pouring in, like a response time distribution. The first implementation was simple. Every time new data came in, we rebuilt the entire chart dataset and passed it in. Back then the data was a 1D (one-dimensional) array. Because exemONE and EVUI use Vue, Vue wrapped the whole array in reactive Proxies. On top of that, EVUI copied the data once more internally with
cloneDeep.This
cloneDeep was defensive code to keep the original data passed in from being polluted when the chart processed it internally.Once points grew to tens or hundreds of thousands, frames dropped while building the data, before anything was even drawn. And the upper limit we had in mind was 1 million points per chart.
So we added a new
realTimeScatter mode to EVUI, dedicated to large real-time data. We changed three main things.- Pass with shallow reactivity: The page passes chart data with
shallowRef, so the whole array is not wrapped in reactive Proxies.
- Bypass
cloneDeep: TherealTimeScatterpath skips the internal deep copy. The array is not rebuilt from scratch on every update.
- 1D array → per-second bucket array: Data is stacked in groups of one-second time buckets. As time passes, the oldest bucket is pushed out of the window and cleared automatically, and a new bucket is appended at the end, like a ring buffer. This bucket structure brings into EVUI the way our in-house product InterMax handled large real-time data.

With this structural change, the cost of updating data became proportional to "the newly arrived range," not "the total size."
While stacking data this way, we also keep only one copy of points that land on the same coordinates. Even if thousands of points pile up on the same coordinate (same time, same value), they are drawn once.

2-2. Shift the previous frame and draw only new points (blit)
We had cut the cost of building data. But drawing cost is a separate matter. A canvas redraws the screen from scratch on every update, so with hundreds of thousands of accumulated points it redraws all of them each time, even if most of the data hasn't changed. With per-second updates, frames drop again at the drawing stage no matter how cheaply you update the data. Yet in a scatter chart, most points sit in the same place as in the previous update. Only the time axis flows to the left. Do we really need to redraw what is already drawn every time?
So we added a blit approach to the render path. Pixels already drawn are not recalculated; they are shifted as a whole. Drawing cost became proportional to "new points," not "all points."

There is one condition. Blit only works when the y-axis is fixed. It fits well when the y-axis doesn't change, but it is not a cure-all.
The key is to build only the newly arrived range and not redraw what hasn't changed since the last frame.
3. Line (time series): this time the bottleneck was upstream of the canvas
A line chart shows metrics that change over time as lines. It receives new data periodically and updates, and this time the cost upstream of the canvas was larger than the canvas itself.
3-1. Why was it slow when there wasn't nearly as much to draw as in scatter?
Having seen the effect on scatter, we suspected the canvas first for line charts too.
In a line chart, one series usually draws tens of points and lines, a few hundred at most. Compared with the hundreds of thousands of points scatter threw on one screen, that's a drop in the bucket. Of course, total rendering grows as series increase, so a light single series doesn't mean the canvas cost is light. But in this update, the bigger cost was not the canvas but the work upstream of it: fetching the data (from the server on every update) and processing the fetched data (repeated on every update). Let's start with fetching.
3-2. Don't fetch again what you already have (incremental append)
Dashboard widgets request new data every few seconds to update their charts. The first implementation requested the entire time range again on every update. A widget has hundreds to thousands of series and targets, and every update fetched that whole range again. Only a few data points at the very end were new, yet we were pulling back from the server the range we already had.
So we changed it to request only the range after the last received time (
lastTime). The server returns only the lastTime, toTime range, and a worker appends this new range to the accumulated cache. The data exchanged on each update became proportional to the newly arrived range, not the whole.This cut the upstream costs of each update, such as server queries, transfer, and worker parsing, and eased the load that dozens of widgets put on both server and client every few seconds.
But incremental fetching is conditional. Normal updates where time moves forward continue incrementally, but if the user changes the target, metric, or query range so the request itself is different, the whole range is fetched again.
In fact, this "fetch only the new range" approach isn't unique to line charts. The real-time scatter we saw earlier also fetches only new data based on the same
lastTime. For scatter we only covered stacking received data in the ring buffer, but fetching is incremental too. The difference is how the reference point is kept. In scatter, points arrive irregularly per target, so each request remembers its own reference point. In line charts, metrics fall on a regular time grid, so one reference point plus an overlap buffer is enough. It's the same principle, implemented differently to fit the shape of the data.
3-3. Other things we repeated on every update
On the side that prepares fetched data, we also removed work that was repeated on every update, one item at a time.
- Turn off deep tracking for large data: Vue had been tracking the whole data tree on every update; we switched to shallow tracking (
shallowRef).
- Limit data to the top k: Even with hundreds to thousands of series, we keep only the top k that are meaningful on screen.
- Move formula and cumulative calculations into a worker: Heavy formula and cumulative operations are gathered into a worker thread to reduce load on the main thread.
Fetching less data wasn't enough on its own. The key is to drop the unnecessary data handled on every update and move heavy computation to a worker to lighten the main thread.
4. Tooltip: the bottleneck of too much DOM
When you hover over a chart series, a tooltip shows the value for each series. But in widgets with hundreds of series, frames stuttered the moment the tooltip appeared. That's because the tooltip built and attached a DOM row for every series.
4-1. Put only part of the list in the DOM (virtual scrolling)
So we added virtual scrolling to the EVUI tooltip.
When the number of series exceeds a threshold, the tooltip keeps only some rows and virtualizes the rest.
Virtual scrolling has a trade-off too. Since only some rows render, the tooltip width became uneven. We fixed this with logic that estimates and locks a minimum width from the length of series names.

We also fixed another tooltip bottleneck. The tooltip follows the mouse, and every time the cursor moved it recalculated coordinates (
getBoundingClientRect) to reposition itself. So it stuttered not just when drawing but the whole time the mouse moved. We cached the coordinates and re-read them only when they actually change (resize, scroll, etc.), throttled mouse moves slightly, and moved the tooltip with transform instead of left/top to avoid reflow.The key is to put only part of the whole in the DOM. Even in Canvas-based charts, the interaction bottleneck can be the DOM.
5. And more: this is only part of it
The three areas above (scatter, line, tooltip) are only part of the improvements we made. There are a few more we couldn't cover in detail.
- Layer separation: The chart body and interaction displays (highlights, tooltips) are split into separate canvases, so not everything is redrawn on each interaction.
- Buffer Canvas: We draw to an offscreen buffer and composite it in one go to prevent flicker.
- Skip coordinate recalculation: Frames where the axes and data haven't changed skip coordinate recalculation.
- Instance reuse: Instances that survive a change in series composition are reused.
- Legend virtual scrolling: In widgets with hundreds of series, the legend also puts only part of the list in the DOM. It's the same family of technique we used for the tooltip.
Our FE team at EXEM is still adding to these improvements. Because we can work on the charts directly instead of depending on an external library, we fix new bottlenecks instead of working around them.
6. Closing: there is no silver bullet
There was no silver bullet for performance. In some cases the canvas was the problem, in others data preparation and reactivity, and in others the DOM. It was a repeated cycle of finding the exact bottleneck and fixing only that part to minimize side effects. Charts in the exemONE dashboard are still being improved this way.
An integrated observability platform that puts countless metrics on one screen in real time
👉🏻 Contact us
Related articles

