typescript scss 115 lines · 3 tabs

Virtual Scrolling a Long List in Angular with CDK and trackBy

Shared by codesnips Sep 2026
3 tabs
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { FormsModule } from '@angular/forms';
import { Contact, ContactStore } from './contact.store';

@Component({
  selector: 'app-contact-list',
  standalone: true,
  imports: [ScrollingModule, FormsModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <input
      class="search"
      placeholder="Filter contacts"
      [ngModel]="query()"
      (ngModelChange)="query.set($event)" />

    <cdk-virtual-scroll-viewport [itemSize]="56" class="viewport">
      <div *cdkVirtualFor="let c of contacts(); trackBy: trackById" class="row">
        <span class="avatar">{{ c.name.charAt(0) }}</span>
        <div class="meta">
          <strong>{{ c.name }}</strong>
          <small>{{ c.email }}</small>
        </div>
        <button type="button" (click)="store.remove(c.id)">Remove</button>
      </div>
    </cdk-virtual-scroll-viewport>
  `,
  styleUrl: './styles.scss',
})
export class ContactListComponent {
  protected readonly store = inject(ContactStore);
  protected readonly query = signal('');

  protected readonly contacts = computed(() => {
    const q = this.query().trim().toLowerCase();
    const all = this.store.contacts();
    if (!q) return all;
    return all.filter(
      (c) => c.name.toLowerCase().includes(q) || c.email.toLowerCase().includes(q),
    );
  });

  protected trackById(_index: number, item: Contact): string {
    return item.id;
  }
}
3 files · typescript, scss Explain with highlit

This snippet shows how a large list is rendered efficiently in Angular by combining the CDK's cdk-virtual-scroll-viewport with a stable trackBy function so the DOM only ever holds the rows currently in view. The core idea behind virtual scrolling is that painting thousands of DOM nodes is expensive and mostly wasteful — the user can only see a handful at a time — so a viewport of fixed height recycles a small pool of elements as the user scrolls, translating them into position with transform. This keeps layout, style recalculation, and change detection bounded regardless of collection size.

In ContactListComponent, the template wires an *cdkVirtualFor over a signal-backed contacts() source, delegates to trackById for identity, and drives itemSize with FixedSizeVirtualScrollStrategy. Using *cdkVirtualFor instead of a plain *ngFor is essential: it is the directive that talks to the viewport and asks only for the visible slice via the scrolling range. The trackById method returns each row's stable id, which lets Angular reuse existing DOM nodes across data changes instead of destroying and recreating them; without it, the recycled node pool churns on every scroll and much of the performance win evaporates.

The component uses OnPush change detection and reads data through a signal, so re-renders are only triggered when the signal's reference actually changes. The filter computed derives a filtered view without mutating the source, and the fixed itemSize of 56 must match the CSS row height exactly — if they disagree, rows overlap or gaps appear because the viewport miscalculates scroll offsets.

ContactStore is a small injectable that owns the list state as a WritableSignal, exposing load and remove. Keeping mutation behind the store means the component stays declarative and the signal reference updates cleanly, which is what actually notifies OnPush. The styles.scss tab defines the row height and forces the viewport to a bounded height, which is the other non-negotiable requirement: a virtual viewport needs a constrained height to have a scrollable area at all. A common pitfall is variable-height rows — those require autoSizeVirtualScroll rather than the fixed strategy. This pattern is the right tool whenever a list can grow past a few hundred items and rows are uniform in height.


Related snips

typescript
export interface RetryOptions {
  retries: number;
  baseMs: number;
  maxMs: number;
  signal?: AbortSignal;
  onRetry?: (attempt: number, delay: number, err: unknown) => void;

Exponential backoff with jitter for retries

typescript reliability retry
by codesnips 2 tabs
python
class Product(models.Model):
    name = models.CharField(max_length=200)
    slug = models.SlugField(blank=True)
    price = models.DecimalField(max_digits=10, decimal_places=2)
    cost = models.DecimalField(max_digits=10, decimal_places=2)
    margin = models.DecimalField(max_digits=5, decimal_places=2, blank=True)

Django model signals vs overriding save

django python models
by Priya Sharma 2 tabs
ruby
require "csv"

class PeopleCsvStream
  include Enumerable

  HEADERS = %w[id full_name email signed_up_at plan].freeze

Resilient CSV Export as a Streamed Response

rails performance streaming
by codesnips 3 tabs
ruby
Rails.application.configure do
  config.after_initialize do
    Bullet.enable = true
    Bullet.alert = false
    Bullet.bullet_logger = true
    Bullet.console = true

N+1 query detection with Bullet gem

rails performance activerecord
by Alex Kumar 2 tabs
ruby
json.array! @posts do |post|
  json.cache! ['v1', post], expires_in: 1.hour do
    json.id post.id
    json.title post.title
    json.excerpt post.excerpt
    json.published_at post.published_at

Fragment caching for expensive JSON serialization

rails caching performance
by Alex Kumar 1 tab
typescript
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
  timeout: 15000,

Axios API client with interceptors

react axios api
by Maya Patel 1 tab

Share this code

Here's the card — post it anywhere.

Virtual Scrolling a Long List in Angular with CDK and trackBy — share card
Link copied