OGE logoOGE

Performance

Large data stays fast when only what is visible is rendered, the server does the querying, and code nobody needs on the first screen loads later. The knobs for each, with the defaults they start from.

virtual scrolling DataSource lazy entry points

Virtualization

Virtual scrolling renders the rows inside the viewport plus an overscan margin. It needs a bounded height — a viewport to be inside of.

Virtualization settings per family
FamilySettingDefault
Data grid virtualScroll (shorthand) or scrolling: { mode: 'virtual' | 'infinite', remote, columnRenderingMode } ; rowHeight , overscan , autoRowHeight standard (off); rowHeight 36, overscan 6
Tree list virtualScroll , columnRenderingMode , rowHeight , overscan off
Pivot grid virtualScrolling off
Select box, tag box, autocomplete, multi-column combo box virtualScroll: true | { itemHeight, overscan } off; overscan 4
Scheduler (timeline) virtualScrolling: boolean | 'auto''auto' — on above 50 timeline rows
Kanban virtualScrolling (per-column card windowing), cardHeight on; cardHeight 112
Gantt Always on; fixed rowHeight in the config rowHeight 36
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { OgeGrid, OgeColumn } from '@oge-ui/grid';

interface Reading {
  id: number;
  sensor: string;
  value: number;
}

@Component({
  selector: 'demo-root',
  imports: [OgeGrid, OgeColumn],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <!-- a bounded height is what gives the virtualizer a viewport -->
    <oge-grid
      [data]="rows"
      keyField="id"
      [scrolling]="{ mode: 'virtual', columnRenderingMode: 'virtual' }"
      [rowHeight]="32"
      [overscan]="10"
      style="height: 560px"
    >
      <oge-column field="id" caption="Id" [width]="90" dataType="number" />
      <oge-column field="sensor" caption="Sensor" [width]="160" />
      <oge-column field="value" caption="Value" [width]="120" dataType="number" />
    </oge-grid>
  `,
})
export class Demo {
  // 100 000 rows: only the rows in view (plus overscan) are in the DOM
  protected readonly rows: Reading[] = Array.from({ length: 100_000 }, (_, i) => ({
    id: i + 1,
    sensor: 'S-' + (i % 250),
    value: Math.round(Math.sin(i) * 1000) / 10,
  }));
}
// app-wide defaults instead of per-grid inputs
import { ApplicationConfig } from '@angular/core';
import { provideOgeGridConfig } from '@oge-ui/grid';

export const appConfig: ApplicationConfig = {
  providers: [provideOgeGridConfig({ rowHeight: 32, overscan: 8, filterDebounce: 250 })],
};

Column virtualization renders only the columns in view; it does not combine with pinned columns or column bands, and a column without a numeric width counts with the minimum width. autoRowHeight measures real row heights in virtual mode at some cost — keep a fixed rowHeight when rows are uniform.

Remote data

Pass a DataSource instead of an array and the grid sends its whole query — sort, filter, search, grouping, summaries, page — to it; the source's capabilities say which parts the server handles.

Data sources in @oge-ui/core
SourceUse it forServer does
ArrayDataSource In-memory rows (what a plain array becomes) Nothing — everything runs in the browser; implements loadSync
CustomDataSource Your own endpoint: one load(options) function Whatever capabilities says (default: everything)
CursorDataSource Cursor-paged APIs ( after=…&limit=50 → { items, nextCursor } ) with infinite scrolling Sort and filter by default
ODataDataSource OData v4 services (read-only); buildODataQuery for your own Sort, filter, paging, counts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeGrid, OgeColumn } from '@oge-ui/grid';
import { CustomDataSource } from '@oge-ui/core';
import type { LoadResult } from '@oge-ui/core';

interface Order {
  id: number;
  customer: string;
  total: number;
}

@Component({
  selector: 'demo-root',
  imports: [OgeGrid, OgeColumn],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <oge-grid [data]="orders" keyField="id" [paging]="{ pageSize: 50 }" [filterRow]="true">
      <oge-column field="id" caption="Order" dataType="number" />
      <oge-column field="customer" caption="Customer" />
      <oge-column field="total" caption="Total" dataType="number" />
    </oge-grid>
  `,
})
export class Demo {
  // the server sorts, filters, groups and pages; the grid sends one query per change
  // and aborts the previous request when a newer one starts
  protected readonly orders = new CustomDataSource<Order>({
    key: 'id',
    load: async ({ signal, ...query }) => {
      const response = await fetch('/api/orders/query', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify(query), // { skip, take, sort, filter, searchText, … }
        signal,
      });
      return (await response.json()) as LoadResult<Order>;
    },
  });
}

Block loading while scrolling: scrolling.mode: 'infinite' (or 'virtual' with remote: true) fetches row blocks from the source as the viewport moves — rows only, no grouping or master-detail in that mode.

First render and loadSync

DataSource.load() returns a promise. ArrayDataSource (and a plain array, which the grid wraps in one) also implements the optional loadSync(), so in-memory data is in the very first render — on the server too (the server-rendered HTML contains the first page of rows). Remote sources omit loadSync and load after mount; give the page a loading state rather than an empty grid.

Load heavy families later

Wrap a family the first screen does not show in @defer; Angular splits its code into a chunk that loads on the trigger you pick. The suite does this itself: the grid renders its form and popup editors inside @defer, so @oge-ui/forms is not in a grid-only bundle.

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { OgeScheduler } from '@oge-ui/scheduler';

@Component({
  selector: 'demo-root',
  imports: [OgeScheduler],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <!-- the scheduler's code loads when the block scrolls into view -->
    @defer (on viewport) {
      <oge-scheduler [dataSource]="appointments" [currentDate]="date" style="height: 560px" />
    } @placeholder {
      <div style="height: 560px">Calendar</div>
    }
  `,
})
export class Demo {
  protected readonly date = new Date(2026, 9, 5);
  protected readonly appointments = [
    { id: 1, text: 'Design review', startDate: new Date(2026, 9, 6, 9), endDate: new Date(2026, 9, 6, 10) },
  ];
}

Exports are separate entry points, and their libraries are optional peers — exceljs and jspdf load with the export chunk on the first export, not with the component:

// exceljs loads with this chunk, on the first click — not with the grid
async exportOrders(grid: OgeGrid<Order>): Promise<void> {
  const { exportGridToExcel } = await import('@oge-ui/grid/export-excel');
  await exportGridToExcel(grid, { filename: 'orders.xlsx', scope: 'all' });
}

Bundle size

Gzip size of a few entry points (the baseline CI holds every pull request to, +10 %). Each number is the entry and its package-internal chunks; shared dependencies (@oge-ui/core, @oge-ui/behavior) are counted once, on their own row, and tree-shaking takes off whatever your app does not import. Every entry point is listed on Bundle size.

Gzip size of selected entry points
Entry pointGzip
oge-ui 2.5 kB
@oge-ui/core 55.5 kB
@oge-ui/grid 108.0 kB
@oge-ui/grid/export-excel 0.7 kB
@oge-ui/inputs/select-box 13.5 kB
@oge-ui/buttons 19.9 kB
@oge-ui/buttons/fab 10.5 kB

Import per component where a family offers it — the @oge-ui/inputs barrel itself is tiny, but a per-editor entry keeps one editor from pulling in its neighbours' dependencies:

// one editor, not the whole inputs family
import { OgeSelectBox } from '@oge-ui/inputs/select-box';
import { OgeSplitter } from '@oge-ui/layout/splitter';
import { OgeFab } from '@oge-ui/buttons/fab';