Import on Visibility: Load When Element Enters Viewport

Medium

Import on visibility is a practical lazy-loading pattern for JavaScript-heavy UI that users may never reach. Instead of shipping every widget on first load, you wait until an element becomes relevant to the current viewport and only then fetch the code.

Quick Decision Guide

Use Intersection Observer to watch a placeholder. When it approaches the viewport, dynamically import() the heavy module.

- Best for below-the-fold, optional, or expensive UI - rootMargin lets you start loading slightly before the element is visible - Always disconnect the observer after success or on cleanup - Strong candidates: comments, embeds, charts, chat widgets, recommendations

Interview signal: this pattern reduces initial JavaScript cost by moving optional code behind actual user scroll behavior.

Why This Pattern Exists

Not every component deserves to be in the initial bundle.

🔥 Insight

The performance win is not just “loading later.” The real win is protecting the critical path from code that is optional, late, or below the fold.

Good Use Cases

comments sections
chat widgets
social embeds
video players
analytics panels
related products or articles
large charts below the fold

Weak Use Cases

navigation
hero content
primary CTA
SEO-critical content
tiny components where chunk overhead is not worth it

Mental Model

> If a user might never scroll to it, the browser probably should not pay for it during startup.

Intersection Observer Basics

The Intersection Observer API lets you asynchronously observe when a target element intersects with the viewport or another scroll container.

const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        console.log('Element is visible');
      }
    });
  },
  {
    root: null,
    rootMargin: '0px',
    threshold: 0
  }
);

const element = document.querySelector('.lazy-component');
observer.observe(element);

observer.disconnect();

Important Options

root: the viewport by default
rootMargin: expands or shrinks the trigger zone
threshold: how much of the target must intersect before the callback fires

Practical Takeaway

For lazy-loading code, rootMargin is often more important than threshold. It lets you begin fetching before the user actually sees the component.

Basic Import-on-Visibility Pattern

const placeholder = document.querySelector('.comments-placeholder');
let hasLoaded = false;

const observer = new IntersectionObserver(async (entries) => {
  for (const entry of entries) {
    if (!entry.isIntersecting || hasLoaded) continue;

    hasLoaded = true;

    const { default: Comments } = await import('./Comments.js');

    const mountNode = document.createElement('div');
    placeholder.replaceWith(mountNode);

    // Mount Comments here
    // Example: ReactDOM.createRoot(mountNode).render(<Comments />)

    observer.unobserve(entry.target);
    observer.disconnect();
  }
}, {
  rootMargin: '100px'
});

observer.observe(placeholder);

What Happens

1. placeholder HTML renders immediately

2. observer waits for that area to approach the viewport

3. code starts downloading only when likely needed

4. once loaded, the real component mounts

Why `rootMargin` Helps

Without margin, loading may start too late and the user can briefly see an empty placeholder.

With a positive margin like 100px or 200px, the browser starts fetching a little early for smoother UX.

React Hook Pattern

import { useState, useEffect, useRef } from 'react';

function useLoadOnVisibility(loader, rootMargin = '100px') {
  const ref = useRef(null);
  const [Component, setComponent] = useState(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    if (!ref.current || Component || isLoading) return;

    let cancelled = false;

    const observer = new IntersectionObserver(
      async ([entry]) => {
        if (!entry.isIntersecting) return;

        observer.disconnect();
        setIsLoading(true);

        try {
          const mod = await loader();
          if (!cancelled) {
            setComponent(() => mod.default || mod);
          }
        } catch (err) {
          if (!cancelled) {
            setError(err);
          }
        } finally {
          if (!cancelled) {
            setIsLoading(false);
          }
        }
      },
      { rootMargin }
    );

    observer.observe(ref.current);

    return () => {
      cancelled = true;
      observer.disconnect();
    };
  }, [loader, rootMargin, Component, isLoading]);

  return { ref, Component, isLoading, error };
}

function App() {
  const { ref, Component, isLoading, error } = useLoadOnVisibility(
    () => import('./HeavyComponent.js')
  );

  return (
    <div>
      <h1>My Page</h1>
      <div ref={ref}>
        {error && <div>Failed to load component</div>}
        {isLoading && <div>Loading component...</div>}
        {Component && <Component />}
        {!Component && !isLoading && !error && (
          <div className="placeholder">Scroll to load component</div>
        )}
      </div>
    </div>
  );
}

Why This Pattern Works

observer setup stays localized
component loads once
cleanup avoids leaks
placeholder gives the user context before the real UI arrives

Reusable Component Pattern

import { useEffect, useRef, useState } from 'react';

function LazyLoadOnVisible({
  loader,
  placeholder = <div>Loading...</div>,
  rootMargin = '100px'
}) {
  const ref = useRef(null);
  const [Component, setComponent] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    if (!ref.current || Component || error) return;

    let done = false;

    const observer = new IntersectionObserver(async ([entry]) => {
      if (!entry.isIntersecting || done) return;
      done = true;
      observer.disconnect();

      try {
        const mod = await loader();
        setComponent(() => mod.default || mod);
      } catch (err) {
        setError(err);
      }
    }, { rootMargin });

    observer.observe(ref.current);

    return () => observer.disconnect();
  }, [loader, rootMargin, Component, error]);

  return (
    <div ref={ref}>
      {error ? <div>Failed to load</div> : Component ? <Component /> : placeholder}
    </div>
  );
}

Good Use

<LazyLoadOnVisible
  loader={() => import('./Comments.js')}
  placeholder={<div>Scroll down to see comments</div>}
  rootMargin="150px"
/>

Interview Takeaway

A reusable abstraction is good only if it keeps the loading trigger, fallback, and cleanup behavior explicit.

Advanced Patterns

Multiple Lazy Regions

function Page() {
  return (
    <div>
      <Hero />
      <MainContent />

      <LazyLoadOnVisible loader={() => import('./Comments.js')} />
      <LazyLoadOnVisible loader={() => import('./RelatedArticles.js')} />
      <LazyLoadOnVisible loader={() => import('./Newsletter.js')} />
    </div>
  );
}

Preload Slightly Before Visibility

const { ref, Component } = useLoadOnVisibility(
  () => import('./HeavyWidget.js'),
  '200px'
);

Larger rootMargin starts fetching sooner. This improves smoothness but may also load code the user never reaches.

Parallel Follow-up Imports

If one visible region requires several heavy modules together, start them in parallel:

const [charts, utils] = await Promise.all([
  import('./charts.js'),
  import('./chart-utils.js')
]);

Trade-off

Visibility-triggered loading improves startup, but pushes some latency into scroll-time interaction. That is a good trade only when the component is genuinely non-critical.

Real-World Examples

Comments Section

function BlogPost({ post }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />

      <LazyLoadOnVisible
        loader={() => import('./Comments.js')}
        placeholder={<div>💬 Comments will load when you scroll here</div>}
        rootMargin="150px"
      />
    </article>
  );
}

Chat Widget

function Layout({ children }) {
  return (
    <div>
      <Header />
      <main>{children}</main>
      <Footer />

      <LazyLoadOnVisible
        loader={() => import('./ChatWidget.js')}
        placeholder={<div>💬 Live chat</div>}
        rootMargin="200px"
      />
    </div>
  );
}

Social Embeds

function ArticleWithEmbeds() {
  return (
    <article>
      <h1>Article Title</h1>
      <p>Content...</p>

      <LazyLoadOnVisible
        loader={() => import('./TwitterEmbed.js')}
        placeholder={<div>🐦 Twitter embed will load here</div>}
      />

      <LazyLoadOnVisible
        loader={() => import('./InstagramEmbed.js')}
        placeholder={<div>📸 Instagram embed will load here</div>}
      />
    </article>
  );
}

Performance Impact and Trade-offs

Benefits

smaller initial JavaScript bundle
less parse and execute work during startup
better fit for optional widgets
users who never scroll there never pay for that code

Costs

later network request when the component is needed
possible loading flash if triggered too late
extra placeholder and observer logic
too many tiny lazy regions can increase complexity

🔥 Insight

The goal is not to delay everything. The goal is to keep the startup path reserved for what the user needs first.

Strong Candidates

comments
reviews
embeds
recommendation blocks
analytics dashboards below the fold
optional support widgets

Weak Candidates

top-of-page UI
core content
navigation
critical actions
very small components where chunk overhead outweighs savings

Best Practices

1. Pick a Sensible `rootMargin`

{ rootMargin: '200px', threshold: 0 } // earlier
{ rootMargin: '50px', threshold: 0 }  // balanced
{ rootMargin: '0px', threshold: 0.5 } // later, more conservative

2. Always Clean Up

useEffect(() => {
  const observer = new IntersectionObserver(callback);
  if (ref.current) observer.observe(ref.current);
  return () => observer.disconnect();
}, []);

3. Load Once

if (entry.isIntersecting && !hasLoaded) {
  hasLoaded = true;
  loadComponent();
  observer.disconnect();
}

4. Provide Meaningful Placeholder UI

<div
  ref={ref}
  role="region"
  aria-label="Comments section"
  aria-busy={isLoading}
>
  {Component ? <Component /> : <div>Comments will load when visible</div>}
</div>

5. Test Observer Logic Explicitly

global.IntersectionObserver = class IntersectionObserver {
  constructor(callback) {
    this.callback = callback;
  }
  observe() {
    this.callback([{ isIntersecting: true }]);
  }
  disconnect() {}
  unobserve() {}
};

6. Prefer Native Lazy Features Where Available

For images and iframes, prefer native lazy loading before building custom JavaScript observer logic. Save this pattern for JavaScript-heavy UI, not assets the browser already knows how to lazy-load efficiently.

Key Takeaways

1Import on visibility combines Intersection Observer with dynamic import()
2It is a strong pattern for below-the-fold, optional, or expensive UI
3Use rootMargin to start loading slightly before the component is visible
4Always clean up observers and guard against multiple loads
5The biggest performance win is reducing initial JavaScript cost, not just delaying network requests
6Prefer native lazy-loading for images and iframes, and use this pattern for JavaScript-heavy components