Front-end Engineering Lab

Infinite Scroll Observer

Automatically load more data when the user reaches the end of a list using IntersectionObserver.

Problem

Traditional infinite scroll implementations use scroll event listeners with manual calculations, which are inefficient and difficult to maintain.

Solution

Use IntersectionObserver to detect when a sentinel element (last item or loading indicator) enters the viewport, then trigger the next page load.

interface InfiniteScrollConfig {
  root?: HTMLElement | null;
  rootMargin?: string;
  threshold?: number;
}

interface PageData<T> {
  items: T[];
  hasMore: boolean;
  nextCursor?: string;
}

class InfiniteScrollManager<T> {
  private observer: IntersectionObserver | null = null;
  private loading = false;
  private hasMore = true;
  private currentPage = 0;

  constructor(
    private sentinel: HTMLElement,
    private fetchData: (page: number) => Promise<PageData<T>>,
    private onDataLoaded: (items: T[], isFirstLoad: boolean) => void,
    private onError: (error: Error) => void,
    config: InfiniteScrollConfig = {}
  ) {
    this.initialize(config);
  }

  private initialize(config: InfiniteScrollConfig): void {
    const options: IntersectionObserverInit = {
      root: config.root || null,
      rootMargin: config.rootMargin || '100px',
      threshold: config.threshold || 0.1,
    };

    this.observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting && !this.loading && this.hasMore) {
          this.loadMore();
        }
      });
    }, options);

    this.observer.observe(this.sentinel);
  }

  private async loadMore(): Promise<void> {
    if (this.loading || !this.hasMore) return;

    this.loading = true;
    this.currentPage++;

    try {
      const result = await this.fetchData(this.currentPage);

      this.hasMore = result.hasMore;
      this.onDataLoaded(result.items, this.currentPage === 1);

      if (!this.hasMore) {
        this.disconnect();
      }
    } catch (error) {
      this.currentPage--; // Rollback page on error
      this.onError(error instanceof Error ? error : new Error('Unknown error'));
    } finally {
      this.loading = false;
    }
  }

  public reset(): void {
    this.currentPage = 0;
    this.hasMore = true;
    this.loading = false;
  }

  public disconnect(): void {
    if (this.observer) {
      this.observer.disconnect();
      this.observer = null;
    }
  }
}

// Practical example
interface Post {
  id: string;
  title: string;
  content: string;
}

async function fetchPosts(page: number): Promise<PageData<Post>> {
  const response = await fetch(`/api/posts?page=${page}&limit=20`);

  if (!response.ok) {
    throw new Error(`Failed to fetch posts: ${response.statusText}`);
  }

  const data = await response.json() as { posts: Post[]; hasMore: boolean };
  
  return {
    items: data.posts,
    hasMore: data.hasMore,
  };
}

function setupInfiniteScroll(
  container: HTMLElement,
  sentinel: HTMLElement
): InfiniteScrollManager<Post> {
  const renderPosts = (posts: Post[], isFirstLoad: boolean): void => {
    if (isFirstLoad) {
      container.innerHTML = '';
    }

    posts.forEach((post) => {
      const article = document.createElement('article');
      article.className = 'post';
      article.innerHTML = `
        <h2>${post.title}</h2>
        <p>${post.content}</p>
      `;
      container.appendChild(article);
    });
  };

  const handleError = (error: Error): void => {
    console.error('Failed to load posts:', error);
    const errorElement = document.createElement('div');
    errorElement.className = 'error';
    errorElement.textContent = 'Failed to load more posts. Please try again.';
    container.appendChild(errorElement);
  };

  return new InfiniteScrollManager(
    sentinel,
    fetchPosts,
    renderPosts,
    handleError,
    { rootMargin: '200px' }
  );
}

Performance Note

Using IntersectionObserver is far more efficient than scroll listeners:

  • No manual calculations: the browser handles intersection detection natively
  • Automatic throttling: the browser optimizes callback execution
  • Better battery life: fewer JavaScript executions on mobile

The rootMargin: '200px' setting starts loading the next page 200px before the user reaches the sentinel, creating a seamless infinite scroll experience with no visible loading states.

This pattern can handle lists with millions of items by loading only 20-50 at a time, keeping memory usage low and scroll performance smooth.

On this page