You built a Gantt chart for your Angular app. It worked great in development with 100 tasks. You demoed it to the team. Everyone was happy.
Then someone tested it with a realistic dataset, 5,000 tasks, then 10,000. Suddenly the page takes 8 seconds to render. Scrolling stutters. Dragging a task freezes the browser for a full second. The dev tools profiler turns red across the board.
If you've been there, you know it's not just frustrating, it's the kind of problem that gets a project quietly canceled, because "the prototype was fine, why is the real thing so broken?"
This post is about why this happens and the four strategies, ranked by complexity, that actually solve it.
Why your Gantt chart is slow (the real reason)
The instinct is to blame Angular. It's not Angular. Angular is fine. The issue is that Gantt charts are unusually hostile to the way browsers render content, and most Gantt implementations make this worse before they make it better.
Let's count nodes. A "simple" Gantt task bar typically renders as:
- 1 container element (the row)
- 1 main bar element
- 1 label element (often 2 if split)
- 2-4 drag handles (left edge, right edge, move handle)
- Possible tooltip elements
- Possible dependency line nodes
- Sometimes nested icons or status indicators
Conservatively, 5-10 DOM nodes per task. At 10,000 tasks, that's 50,000 to 100,000 DOM nodes the browser has to manage. Add CSS layout, repaints, event listeners on each one, and you've built something the DOM was simply not designed to handle.
Now add Angular's change detection on top. Every Zone-triggered event walks the component tree. If your task bars are components (and they usually are), Angular has to check thousands of them on every interaction. Even with OnPush, the cost of checking whether to update each component adds up.
The browser is doing its best. The problem is that you've asked it to do something close to impossible.
Strategy 1: Virtual scrolling
Best for: datasets up to a few thousand tasks where most of them are off-screen at any given moment.
Virtual scrolling is the first thing every dev tries, and it works — up to a point. The idea: only render the rows that are actually visible in the viewport. As the user scrolls, recycle DOM nodes to display new rows.
Angular CDK has this built in:
import { ScrollingModule } from '@angular/cdk/scrolling';
@Component({
template: `
<cdk-virtual-scroll-viewport itemSize="40" class="gantt-viewport">
<div *cdkVirtualFor="let task of tasks" class="task-row">
<!-- task bar template -->
</div>
</cdk-virtual-scroll-viewport>
`
})
export class GanttComponent {
tasks: Task[] = [];
}
What it fixes: initial render time, memory usage from off-screen elements.
What it doesn't fix:
- Horizontal complexity. A Gantt is bi-dimensional. Virtual scrolling helps with vertical, but each visible row still renders its full timeline — which can mean hundreds of nodes per row if your time range is wide.
- Drag operations across non-visible rows.
- Real-time updates streaming from a websocket. Every update can trigger a re-render storm.
- Dependency lines that span scroll positions.
In practice, virtual scrolling buys you maybe one order of magnitude. If your dataset is 1,000 tasks, you'll be smooth. At 10,000 you'll be okay-ish. At 100,000 you'll be back where you started.
Strategy 2: DOM recycling and OnPush everywhere
Best for: mid-size apps that need real-time updates and can't accept canvas tradeoffs.
This is the "aggressive optimization within the DOM" path. The techniques compound:
-
Strict
ChangeDetectionStrategy.OnPushon every component touching the Gantt -
trackByfunctions on every*ngFor(without these, Angular re-renders every row on every change) - Object pooling keep a fixed number of task bar instances and reuse them as data changes, rather than creating/destroying components
-
Manual change detection via
ChangeDetectorRef.detectChanges()instead of letting Zone trigger it -
runOutsideAngularfor high-frequency events like mouse moves during drag
constructor(private ngZone: NgZone, private cdr: ChangeDetectorRef) {}
onDragStart(event: PointerEvent) {
this.ngZone.runOutsideAngular(() => {
document.addEventListener('pointermove', this.onDragMove);
document.addEventListener('pointerup', this.onDragEnd);
});
}
private onDragMove = (event: PointerEvent) => {
// Manual position update without triggering change detection
this.updateDragPosition(event);
};
private onDragEnd = () => {
// Now re-enter Angular zone for final commit
this.ngZone.run(() => {
this.commitDragResult();
this.cdr.detectChanges();
});
};
What it fixes: dragging stutters, real-time update floods, change detection overhead.
What it doesn't fix: the fundamental cost of having tens of thousands of DOM nodes. You're optimizing how the DOM is updated, not how much of it exists.
This strategy can get you to maybe 5,000-10,000 tasks with acceptable performance, but you'll spend weeks tuning it and the code becomes hard to maintain. Every new feature risks blowing up the optimization carefully built around the old features.
Strategy 3: Canvas rendering
Best for: large-scale, real-time, performance-critical scheduling UIs.
This is the strategy that actually scales. Instead of asking the DOM to represent each task as an element, you draw everything onto a single <canvas> element using 2D drawing APIs. It's closer to how video games render their UI than how typical web pages work.
Why it scales: rendering 50,000 rectangles on a canvas is a fraction of the cost of creating 50,000 DOM nodes, because the browser isn't tracking each one as a separate object. You're just telling it what pixels to put where.
The basic skeleton:
@Component({
template: `<canvas #ganttCanvas></canvas>`
})
export class CanvasGanttComponent implements AfterViewInit {
@ViewChild('ganttCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
private ctx!: CanvasRenderingContext2D;
private tasks: Task[] = [];
ngAfterViewInit() {
const canvas = this.canvasRef.nativeElement;
this.ctx = canvas.getContext('2d')!;
this.render();
}
private render() {
const ctx = this.ctx;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const task of this.visibleTasks()) {
const { x, y, width, height } = this.computeTaskRect(task);
ctx.fillStyle = task.color;
ctx.fillRect(x, y, width, height);
ctx.fillStyle = '#fff';
ctx.fillText(task.name, x + 4, y + height / 2);
}
requestAnimationFrame(() => this.render());
}
}
That's the easy part. The hard parts are:
- Hit detection for clicks and drags (the canvas is one element, so you have to figure out which task was clicked by computing positions yourself)
- Accessibility screen readers can't see canvas content. You need a hidden DOM layer or ARIA tricks
- Text rendering canvas text doesn't auto-wrap, doesn't ellipsize, doesn't pick the right font weight
- High DPI screens require manual canvas scaling
- Selection rectangles, hover states, tooltips, dependency lines — everything you got "for free" with the DOM, you implement from scratch
This is why most teams don't pick canvas: it works, but it's a serious undertaking. You're essentially building a rendering engine.
Strategy 4: Use a specialized framework
Best for: teams that need it to work in production and don't want to spend six months building strategy 3.
Sometimes the right answer is to not build the engine yourself. There are JavaScript scheduling libraries that have already invested years in the canvas-rendering, hit-detection, accessibility, and real-time-update problems. You give up some flexibility, you spend some money, but you ship.
Honest options to evaluate:
- ScheduleJS — built specifically for Angular/TypeScript, designed for smooth scrolling on hundreds of thousands of rows, with an object-oriented API that lets you customize at a pixel level. Particularly suited if your use case involves real-time scheduling, MES, aviation, or broadcasting where the DOM-based approach breaks down. It's also the natural choice if you're migrating from a FlexGanttFX (JavaFX) app.
- Bryntum Gantt — premium commercial Gantt with strong project-management features and polished UI.
- DHTMLX Gantt — long-standing all-rounder, with a GPL Standard edition for OSS use.
I covered the broader landscape in Top 7 Angular Gantt Chart Libraries in 2026 if you want a full comparison, but the short version is: if your app is a project-tracking tool, the mainstream options will probably do. If your app needs to handle real-time operational data at industrial scale, the specialized end of the spectrum exists for a reason.
How to actually measure performance
Whatever strategy you pick, you need real numbers. Vibes aren't a benchmark.
The three metrics that matter for a Gantt:
1. Time to first render
Wrap your component init with performance.now():
ngOnInit() {
const start = performance.now();
this.loadAndRenderTasks().then(() => {
console.log(`Render time: ${performance.now() - start}ms`);
});
}
Target: under 1 second for the first frame, even at 10k tasks.
2. Sustained frame rate during interaction
Use the DevTools Performance tab. Record a 5-second drag operation. Look at the FPS graph. Anything below 30 FPS feels sluggish; anything below 15 FPS feels broken.
3. Memory footprint
The DevTools Memory tab shows heap size. Watch it during a stress test. A Gantt with 10k tasks should not consume more than ~200 MB. If it's hitting 1+ GB, you have a leak or a rendering strategy that won't survive a long user session.
Generate a synthetic dataset to stress-test:
function generateTasks(count: number): Task[] {
return Array.from({ length: count }, (_, i) => ({
id: `task-${i}`,
name: `Task ${i}`,
startDate: new Date(2026, 0, 1 + (i % 365)),
endDate: new Date(2026, 0, 5 + (i % 365)),
color: `hsl(${(i * 137) % 360}, 70%, 50%)`,
}));
}
Run this with 1k, 10k, and 100k tasks. The number where your implementation breaks tells you which strategy you actually need.
Decision shortcuts
| Your dataset | Recommended strategy |
|---|---|
| < 500 tasks, static | Plain DOM rendering is fine |
| 500–2,000 tasks | Virtual scrolling (Strategy 1) |
| 2,000–10,000 tasks | DOM recycling + aggressive OnPush (Strategy 2) |
| 10,000+ tasks, or real-time updates | Canvas rendering (Strategy 3) or specialized framework (Strategy 4) |
| 100,000+ tasks, or industrial real-time | Specialized framework — building Strategy 3 yourself will take longer than the rest of your project |
The takeaway
Most Angular Gantt performance problems aren't really Angular problems. They're DOM-volume problems wearing an Angular costume. The strategies above are listed roughly in increasing order of effort and decreasing order of "looks like a normal Angular app."
If your project genuinely needs the high end of this list and you don't want to build a rendering engine, that's exactly what specialized frameworks exist for. If you're staying mid-range, Strategy 2 with disciplined OnPush and runOutsideAngular will get you surprisingly far.
The mistake to avoid: pretending Strategy 1 will be enough when your real-world data is 10x what your prototype tested. Generate the synthetic stress dataset. Measure. Pick the strategy that survives the real numbers, not the demo ones.
Have you fought this fight in production? I'm curious which strategy you ended up with and what surprised you along the way — drop it in the comments.
Companion post: Top 7 Angular Gantt Chart Libraries in 2026 for a full comparison of the available frameworks.
Top comments (0)