Let’s say the Monday after the Super Bowl, your boss wants to see a dashboard list of the more than 120 million people who watched it. (The reality of this situation does not matter. Just go with it!) Your boss says, “The dashboard needs to show every connected viewer in real time. A grid. Sortable. Filterable. And it can’t be slow.”
You open your code editor, create an HTML table, and try to render one million rows. The browser freezes. The tab crashes. Your Monday just got worse.
You need to display large amounts of data in a grid without killing the browser. The trick is simple: don’t render what the user can’t see.
This is where virtual scrolling comes in. Instead of creating a DOM element for every single row, you only render the rows visible on screen, plus a few extra. The user scrolls smoothly through millions of records, each new batch loading in as needed, and the browser stays fast and responsive.
Let’s do it together! Today, we will build a Super Bowl Dashboard in Angular. We will see how to handle one million viewers using Progress Kendo UI for Angular Grid. We will start with a simple table, see why it fails and then fix it using virtual scrolling and server-side data fetching.
Let’s make this work in a real project.
Setting Up the Project
First, create a new Angular application by running the command:
ng new superbowl-dashboard
Navigate to the project:
cd superbowl-dashboard
Now install the Angular Grid. The ng add command handles dependencies and theme configuration for us:
ng add @progress/kendo-angular-grid --skip-confirmation
This installs the grid package and its peer dependencies and sets up the Kendo UI default theme.
Once the installation is done, we need to activate our Kendo UI license. This step removes the watermark and unlocks all the features.
Don’t have a license? Don’t worry! You can get a completely free trial with no credit card required, so you can follow along and build the dashboard without worrying about the watermark.
To do this, download the license key file from your Telerik account. Then, run this command inside your project folder:
npx kendo-ui-license activate
Because the Kendo UI Grid is so robust, it includes many features that can slightly increase the initial bundle size. We need to prevent Angular from throwing a “bundle initial exceeded maximum budget” error when we run or build an app. Open the angular.json file and increase the budgets config for the initial type to around 5MB for warnings and 10MB for errors.
Perfect! We have a fresh Angular project with Kendo UI Grid installed and styled. Now let’s generate our Super Bowl viewers’ fake data.
Generating One Million Viewers
Before we start with the grid, we need data. We’ll create a single service that handles everything: generating fake viewer data for client-side demos and simulating a paginated server API for server-side scrolling.
To create the service, open the terminal and run the following Angular CLI command:
ng g s services/viewer
First, we will define a simple Viewer interface so our grid knows what fields to expect. Then, our service will include a generateViewers method to create mock data, and a fetchPage method that simulates a paginated server API. We won’t use fetchPage right away, but it will be ready for when we implement server-side virtual scrolling later.
Don’t worry too much about the exact implementation of the helper methods; the main idea here is not how to generate fake users, but how to create a grid capable of supporting massive amounts of data without breaking a sweat!
Open src/app/services/viewer.ts and add the following:
import { Injectable } from "@angular/core";
export interface Viewer {
id: number;
username: string;
watchTimeMin: number;
isLive: boolean;
}
export interface PagedResult {
data: Viewer[];
total: number;
}
const ADJECTIVES = [
"Swift",
"Lucky",
"Bold",
"Chill",
"Epic",
"Happy",
"Lazy",
"Wild",
"Cool",
"Fast",
];
const NOUNS = [
"Fan",
"Eagle",
"Tiger",
"Bear",
"Wolf",
"Hawk",
"Fox",
"Lion",
"Shark",
"Bull",
];
@Injectable({ providedIn: "root" })
export class ViewerService {
private readonly TOTAL_VIEWERS = 1_000_000;
generateViewers(count: number, startId = 0): Viewer[] {
const viewers: Viewer[] = [];
for (let i = 0; i < count; i++) {
viewers.push({
id: startId + i + 1,
username: `${this.randomFrom(ADJECTIVES)}${this.randomFrom(NOUNS)}${this.randomBetween(1, 9999)}`,
watchTimeMin: this.randomBetween(1, 240),
isLive: Math.random() > 0.08,
});
}
return viewers;
}
async fetchPage(skip: number, take: number): Promise<PagedResult> {
await new Promise((resolve) => setTimeout(resolve, 80));
return {
data: this.generateViewers(take, skip),
total: this.TOTAL_VIEWERS,
};
}
private randomFrom<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
private randomBetween(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
}
Each viewer has a fun auto-generated username (like SwiftEagle4821), their watch time and a live status. The fetchPage method is an async function that simulates a paginated API with a small delay to mimic network latency. We’ll use generateViewers first and come back to fetchPage later.
With our service ready, let’s see what happens when we try to render all these viewers at once.
The Problem: Rendering All Rows at Once
Let’s try to do it and experience the problem firsthand. Open src/app/app.ts and replace its content:
import { Component, inject, signal } from "@angular/core";
import { CommonModule } from "@angular/common";
import { ViewerService, Viewer } from "./services/viewer";
@Component({
selector: "app-root",
standalone: true,
imports: [CommonModule],
templateUrl: "./app.html",
})
export class App {
private viewerService = inject(ViewerService);
viewers = signal<Viewer[]>([]);
loadViewers(count: number): void {
const data = this.viewerService.generateViewers(count);
this.viewers.set(data);
}
}
Now, let’s open our template file, src/app/app.html and add a few buttons for 1K, 10K and 100K viewers. These buttons will call our loadViewers() method to quickly generate different amounts of fake data.
Finally, we’ll use the <kendo-grid> component in our template. The key property to pay attention to is [data]="viewers()", which tells Kendo UI to read from our reactive signal and constantly display the list of viewers.
<h1>Super Bowl Viewers Dashboard</h1>
<p>Connected viewers: {{ viewers().length.toLocaleString() }}</p>
<div class="actions">
<button (click)="loadViewers(1_000)">1K Viewers</button>
<button (click)="loadViewers(10_000)">10K Viewers</button>
<button (click)="loadViewers(100_000)">100K Viewers</button>
<button (click)="loadViewers(1_000_000)">1M Viewers</button>
</div>
<div class="grid-container" style="height: 600px; overflow: auto;">
<table border="1" style="width: 100%; border-collapse: collapse;">
<thead>
<tr>
<th>#</th>
<th>Username</th>
<th>Watch (min)</th>
<th>Live</th>
</tr>
</thead>
<tbody>
@for (viewer of viewers(); track viewer.id) {
<tr>
<td>{{ viewer.id }}</td>
<td>{{ viewer.username }}</td>
<td>{{ viewer.watchTimeMin }}</td>
<td>{{ viewer.isLive ? 'Yes' : 'No' }}</td>
</tr>
}
</tbody>
</table>
</div>
Now we can run our app. Go to the terminal and execute the following command:
ng serve
This will start the local development server. Once it finishes compiling, open your browser and navigate to http://localhost:4200.
Click “1K Viewers” and it feels smooth. Click “10K Viewers” and you will notice a significant lag.
Now click “100K Viewers” … and watch the browser scream for help. The page will freeze, the scroll will be jumpy and you might even get the “Page Unresponsive” dialog.

We have seen the problem: standard HTML tables and simple loops cannot handle huge datasets. When you click the 1M Viewers button, the browser stops working.
OK, but how can we fix this?
If you read about Kendo UI, you know the Kendo UI Grid, but before we to start to use it, I want to explain “virtual scrolling.” Think of virtual scrolling like a camera moving over a big stadium. You only see the seats in the camera frame, maybe 50 seats. The stadium has 1,000,000 seats, but the camera doesn’t need to show all of them at once. It only shows what is visible.
Kendo UI Grid does exactly this with one property: scrollable="virtual". Let’s use it.
First, update your app.ts. We need to import KENDO_GRID and remove the CommonModule because the grid will handle everything now:
import { Component, inject, signal } from "@angular/core";
import { KENDO_GRID } from "@progress/kendo-angular-grid";
import { ViewerService, Viewer } from "./services/viewer";
@Component({
selector: "app-root",
imports: [KENDO_GRID],
templateUrl: "./app.html",
})
export class App {
private viewerService = inject(ViewerService);
viewers = signal<Viewer[]>([]);
loadViewers(count: number): void {
const data = this.viewerService.generateViewers(count);
this.viewers.set(data);
}
}
Now, let’s update src/app/app.html. We will replace the standard <table> with the <kendo-grid> component:
<h1>Super Bowl Viewers Dashboard</h1>
<p>Connected viewers: {{ viewers().length.toLocaleString() }}</p>
<div class="actions">
<button (click)="loadViewers(10_000)">10K</button>
<button (click)="loadViewers(100_000)">100K</button>
<button (click)="loadViewers(1_000_000)">1M</button>
</div>
<kendo-grid
[data]="viewers()"
[height]="600"
scrollable="virtual"
[rowHeight]="36"
[pageSize]="50"
>
<kendo-grid-column field="id" title="#" [width]="70"></kendo-grid-column>
<kendo-grid-column field="username" title="Username" [width]="180"></kendo-grid-column>
<kendo-grid-column field="watchTimeMin" title="Watch (min)" [width]="110"></kendo-grid-column>
<kendo-grid-column field="isLive" title="Live" [width]="70"></kendo-grid-column>
</kendo-grid>
Why Does This Work?
We added three important properties to the <kendo-grid> to make it fast:
scrollable="virtual": This tells Kendo UI: “Don’t create all the rows at once. Only create the ones the user can see right now.”[rowHeight]="36": The grid needs to know exactly how tall each row is. This helps the grid calculate the scroll position correctly.[pageSize]="50": This is how many rows Kendo UI keeps in the DOM. A good tip: set this to 3 times the number of rows visible on your screen.
Now, if you click the buttons, you will see that a page with 10,000, 100,000 or even one million viewers scrolls smoothly. The browser is fast because only ~50 rows are in the DOM at any time.
Kendo UI Grid does all the hard work for the rendering.
But there is one more problem. We fixed the rendering, but we are still loading one million records into the browser’s memory. For a real app, loading 1,000,000 records at once is a bad idea. It uses too much RAM and it is slow to start.
What if we want to show one million users in real-time without using all the RAM? Let’s use the final solution: Server-Side Data Fetching.
Here’s the reality: the Super Bowl has 120 million viewers. We can’t load all of them into the browser at once. Instead, the grid should fetch data page by page as the user scrolls , loading only what’s needed, when it’s needed.
Kendo UI Grid supports this out of the box with the scrollBottom event. When the user scrolls to the bottom of the current data, the grid fires this event, and we simply fetch the next page and append it. Let’s build it!
Generate a new component by running the command in the terminal:
ng g c components/live-grid
Open src/app/components/live-grid/live-grid.ts and replace the content with:
import { Component, inject, signal, ChangeDetectionStrategy } from "@angular/core";
import { KENDO_GRID } from "@progress/kendo-angular-grid";
import { ViewerService, Viewer } from "../../services/viewer";
@Component({
selector: "app-live-grid",
imports: [KENDO_GRID],
templateUrl: "./live-grid.html",
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LiveGrid {
private viewerService = inject(ViewerService);
isConnected = signal(false);
loading = signal(false);
viewers = signal<Viewer[]>([]);
pageSize = 1_000;
connect(): void {
this.isConnected.set(true);
this.loadMore();
}
onScrollBottom(): void {
if (this.loading()) return;
this.loadMore();
}
private loadMore(): void {
this.loading.set(true);
this.viewerService.fetchPage(this.viewers().length, this.pageSize).then((result) => {
this.viewers.update((current) => [...current, ...result.data]);
this.loading.set(false);
});
}
}
The logic here is straightforward. First, connect() starts the process by fetching the initial data page using loadMore(). Then, whenever the user scrolls to the bottom of the grid, the (scrollBottom) event fires, triggering onScrollBottom() to fetch and append the next chunk of data to our viewers signal.
To make this work in the UI, we just need to set scrollable="scrollable" on our Kendo Grid to enable endless scrolling, and bind our loading signal to show a skeleton animation during the fetch.
Now let’s build the template in src/app/components/live-grid/live-grid.html to tie all of this together:
<h2>Live Server Feed</h2>
<p>Viewers: {{ viewers().length }}</p>
<button (click)="connect()" [disabled]="isConnected()">Connect to Live Feed</button>
@if (isConnected()) {
<kendo-grid
[data]="viewers()"
[height]="600"
scrollable="scrollable"
[loading]="loading()"
(scrollBottom)="onScrollBottom()">
<kendo-grid-column field="id" title="#" [width]="70"></kendo-grid-column>
<kendo-grid-column field="username" title="Username" [width]="180"></kendo-grid-column>
<kendo-grid-column field="watchTimeMin" title="Watch (min)" [width]="110"></kendo-grid-column>
<kendo-grid-column field="isLive" title="Live" [width]="70"></kendo-grid-column>
</kendo-grid>
}
Wire the component into the app. Update app.ts:
import { Component } from "@angular/core";
import { LiveGrid } from "./components/live-grid/live-grid";
@Component({
selector: "app-root",
imports: [LiveGrid],
templateUrl: "./app.html",
})
export class App {}
And src/app/app.html:
<h1>Super Bowl Viewers Dashboard</h1>
<app-live-grid />
Run ng serve, open http://localhost:4200 and click “Connect to Live Feed.”
You’ll see the grid load the first 1,000 viewers. Now scroll down. When you reach the bottom, the grid fetches the next 1,000 viewers and appends them and the loading skeleton appears briefly while data is being fetched. Keep scrolling, and watch the viewers counter grow until it gets to 1,000,000. Yeah!!

Recap
We saw the problem: rendering thousands of rows at once makes the browser freeze. Then we added Kendo UI virtual scrolling with scrollable="virtual". This is the secret to showing large datasets without crashing.
Finally, we used server-side data fetching with the (scrollBottom) event. The grid loads data page by page. This keeps the memory usage low and the experience smooth.
In your next challenge don’t worry about the data, Kendo UI Grid makes it easy. With a few properties, your dashboard can handle millions of rows. ✌
Give it a try for free!
Try Kendo UI for Angular
Happy coding!
Source Code: https://github.com/danywalls/kendo-grid-large-app