How We Cut Course Editor Lag by 90%: Frontend Performance Hacks with Vue & React

This article details a front‑end team's systematic performance optimization of a large‑scale education editor, covering root‑cause analysis of list lag, memory leaks, iframe blocking, and animation stalls, and presenting practical solutions such as IntersectionObserver lazy loading, dynamic rendering, virtual lists, and progressive animation rendering to dramatically improve user experience.

ELab Team
ELab Team
ELab Team
How We Cut Course Editor Lag by 90%: Frontend Performance Hacks with Vue & React

About This Share

Performance optimization is a recurring challenge in front‑end development; system speed and stability directly affect user experience. This article shares a TOB system performance‑optimization case from the author's perspective, illustrating common pain points and solutions.

Team Position

The team works on a complex education front‑end product that provides a complete online courseware solution, consisting of an editor and a renderer. Editor offers basic courseware creation and the ability to assemble various educational resources such as interactive questions, Cocos, PDF, PPT, etc. Renderer supports rendering of basic courseware and integrates various educational resources.

The data structure resembles PPT: each page is a page object, and each page contains nodes for text, images, audio, video, etc., stored in arrays.

{
  pages: [
    data: {
      nodes: ['text', 'image', 'video', 'staticQuestion', ...]
    }
  ]
}

Performance Optimization Journey

3‑4 Double‑Month Project Initiation

The team set a bi‑monthly goal to launch a dedicated courseware performance‑optimization project.

Targeted Solutions for Specific Issues

The following cases illustrate how the team tackled concrete problems.

Course List Page Lag

The system stored all course data in a serialized format without pagination, causing the entire course content to be fetched and rendered at once. When the number of pages exceeded 100, the UI became unresponsive, with click and scroll events taking several seconds.

Using the Vue updated lifecycle hook, the team logged render counts and discovered that clicking a single page triggered unnecessary re‑renders of the entire left‑hand list, causing the virtual‑DOM patch process to block the single thread.

updated() {
  // Log component update count
  console.log("%c left viewer rerender", 'color: red;');
}

Analysis showed that excessive component updates and a lack of dynamic rendering caused the bottleneck.

Solution: IntersectionObserver Lazy Loading

Inspired by image lazy loading, the team added an IntersectionObserver to each page container, rendering page content only when it entered the viewport.

export default {
  data() {
    return {
      elementNeedLoad: false,
      elementNeedVisible: false
    };
  },
  mounted() {
    const target = this.$refs.containerNeedLazyLoad;
    const intersectionObserver = this.lazyLoadObserver(target);
    intersectionObserver.observe(target);
  },
  methods: {
    lazyLoadObserver(target) {
      return new IntersectionObserver((entries) => {
        entries.forEach((entry) => {
          if (entry.intersectionRatio > 0) {
            if (this.elementNeedLoad === false) {
              this.elementNeedLoad = true;
              this.$nextTick(() => {
                this.elementNeedVisible = true;
              });
              this.lazyLoadObserver().unobserve(target);
            }
          } else {
            this.elementNeedLoad = false;
            this.elementNeedVisible = false;
          }
        });
      }, { threshold: [0, 0.5, 1] });
    }
  }
};

After applying this, only 7‑8 pages were rendered at any time, reducing render time to under 300 ms per click.

Dynamic Loading for Scroll Performance

When scrolling, the lazy‑load approach still caused frequent virtual‑DOM calculations. The team switched to a dynamic loading strategy that renders only the pages around the viewport, using a debounced scroll listener and progressive rendering.

import { on, off, mapState, mapMutations } from 'utils';
import debounce from 'lodash/debounce';

const renderPagesBuffer = 7;
const renderPagesBoundary = 2 * renderPagesBuffer + 1;
const debounceTime = 400;
const progressiveTime = 150;
const bodyHeight = document.documentElement.clientHeight || document.body.clientHeight;

export default {
  data() {
    return {
      additionalPages: [],
      commonPages: []
    };
  },
  mounted() {
    this.observeTarget = this.$refs.pprOverviewList;
    on(this.observeTarget, 'scroll', () => {
      this.handleClearTimer();
      this.handleListScroll();
    });
    if (!this.renderAllPages) {
      this.updateCurrentPagesInView(new Array(renderPagesBoundary).fill(1).map((_, i) => i));
    } else {
      const timer = setTimeout(() => {
        this.handleClearTimer();
        this.handleListScroll();
        clearTimeout(timer);
      }, debounceTime * 2);
    }
  },
  beforeDestroy() {
    off(this.observeTarget, 'scroll', this.handleListScroll);
  },
  computed: {
    ...mapState('editor', ['currentPagesInView']),
    pagesLength() {
      return this.pptDetail?.pages?.length || 0;
    },
    renderAllPages() {
      return this.pagesLength > renderPagesBoundary;
    }
  },
  watch: {
    additionalPages() {
      this.observerIndex = 1;
      this.handleRenderNextPage();
    }
  },
  methods: {
    ...mapMutations('editor', ['updateCurrentPagesInView']),
    handleListScroll: debounce(function () {
      const { scrollTop, scrollHeight } = this.observeTarget;
      const percent = (scrollTop + bodyHeight / 2) / scrollHeight;
      const currentMiddlePage = Math.floor(this.pagesLength * percent);
      const start = Math.max(currentMiddlePage - renderPagesBuffer, 0);
      const end = Math.min(currentMiddlePage + renderPagesBuffer, this.pagesLength + 1);
      const commonPages = [];
      const additionalPages = [];
      for (let i = start; i < end; i++) {
        if (this.currentPagesInView.includes(i)) {
          commonPages.push(i);
        } else {
          additionalPages.push(i);
        }
      }
      this.commonPages = commonPages;
      this.additionalPages = additionalPages;
    }, debounceTime),
    handleRenderNextPage() {
      const nextPages = this.additionalPages.slice(0, this.observerIndex);
      this.updateCurrentPagesInView([...nextPages, ...this.commonPages]);
      this.observerIndex++;
      if (this.observerIndex >= this.additionalPages.length) {
        this.handleClearTimer();
      } else {
        this.observerTimer = setTimeout(() => {
          this.animationTimer = requestAnimationFrame(this.handleRenderNextPage);
        }, progressiveTime);
      }
    },
    handleClearTimer() {
      this.observerTimer && clearTimeout(this.observerTimer);
      this.animationTimer && cancelAnimationFrame(this.animationTimer);
    }
  }
};

This approach eliminated scroll‑induced frame drops and kept the UI responsive.

Memory Leak Investigation

Even after reducing DOM nodes, prolonged scrolling caused the page to freeze due to memory leaks. Snapshots showed thousands of detached DIV elements retained in memory, primarily from the rich‑text render component.

Investigation revealed that a third‑party SDK added a body resize listener without removing it, preventing garbage collection. Removing that listener resolved the leak.

Iframe Blocking Issue

Previewing a course opened an iframe on the same domain, sharing the same rendering process and causing UI blockage. The solution was to open the preview on a different sub‑domain or use an a tag with rel="noopener" to separate processes.

<a v-if="showOpenEntry" class="intro-step3 preview-wrap" rel="noopener" target="__blank" :disabled="!showEditArea" :style="{ color: !showEditArea ? 'lightgray' : '#515a6e' }" :href="pageShareUrl">
  <lego-icon type="preview" size="16" />
</a>

Long Animation Rendering

Adding animations to many elements caused several seconds of UI freeze. The team introduced progressive rendering using requestAnimationFrame to add one animation per frame, reducing total response time from 2.35 s to 370 ms.

export default {
  data() {
    return { nextRenderQuantity: 0 };
  },
  computed: {
    animationLength() { return this.animationConfigsUnderActiveTab.length; },
    renderAnimationList() { return this.animationConfigsUnderActiveTab.slice(0, this.nextRenderQuantity); }
  },
  watch: {
    animationLength: { handler() { this.handleRenderProgressive(); }, immediate: true }
  },
  beforeDestroy() { this.timer && cancelAnimationFrame(this.timer); },
  methods: {
    handleRenderProgressive() {
      this.timer && cancelAnimationFrame(this.timer);
      if (this.nextRenderQuantity < this.animationConfigsUnderActiveTab.length) {
        this.nextRenderQuantity += 1;
        this.timer = requestAnimationFrame(this.handleRenderProgressive);
      }
    }
  }
};

Other Common Optimizations

Route lazy loading

Static asset caching

Bundle size reduction

Using CDN for large third‑party libraries

Loop optimizations (prefer for loops for large arrays)

Potential pre‑compilation improvements

Deep Dive: Framework Philosophies

Vue’s Lazy‑Man Philosophy vs React’s Brutal Aesthetics

Vue automatically tracks fine‑grained data dependencies, re‑rendering only components whose state changed. However, when the data flow is messy, Vue may trigger excessive re‑renders, leading to performance issues.

React, by contrast, does not track data changes; developers must explicitly decide when to re‑render using tools like React.memo, PureComponent, or shouldComponentUpdate. This gives developers more control but requires careful state management.

React Context Example

const AppContext = React.createContext(null);

const App = () => {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('Mike');
  return (
    <AppContext.Provider value={{ count, setCount, name, setName }}>
      <ComponentA />
      <ComponentB />
    </AppContext.Provider>
  );
};

const ComponentA = () => {
  const { name } = useContext(AppContext);
  return <div>{name}</div>;
};

const ComponentB = () => {
  const { count, setCount } = useContext(AppContext);
  return (
    <div>
      {count}
      <button onClick={() => setCount(c => c + 1)}>SetCount</button>
    </div>
  );
};

In this demo, both components re‑render when count changes, even though ComponentA only uses name. Splitting contexts or using selectors can prevent unnecessary renders.

React Context Split Example

const NameContext = React.createContext(null);
const CountContext = React.createContext(null);

const App = () => {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('Mike');
  return (
    <NameContext.Provider value={{ name, setName }}>
      <CountContext.Provider value={{ count, setCount }}>
        <ComponentA />
        <ComponentB />
      </CountContext.Provider>
    </NameContext.Provider>
  );
};

Separating contexts ensures that updating count does not re‑render ComponentA.

React Memo and useMemo

const ComponentA = () => {
  const { name } = useContext(NameContext);
  return useMemo(() => <div>{name}</div>, [name]);
};

Using useMemo caches the rendered output, avoiding re‑render when unrelated state changes.

Conclusion

Effective front‑end performance optimization requires understanding the underlying framework mechanics, careful data‑flow design, and targeted techniques such as lazy loading, virtual lists, progressive rendering, and proper resource isolation. Combining Vue’s fine‑grained reactivity with React‑style explicit control can lead to robust, scalable applications.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

OptimizationVuememory-leaklazy-loading
ELab Team
Written by

ELab Team

Sharing fresh technical insights

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.