Skip to main content
Tokopedia2023

Tokopedia Lite
Homepage Performance

Comprehensive two-phase optimization: Phase 1 reduced transfer size by 31.4% and TBT by 25.82%; Phase 2 eliminated layout shifts (CLS 0.12 → 0.00) via Sequential Rendering

ReactTypeScriptNext.jsWebpackGraphQLChrome DevTools

Total Transfer Size

31.4%

10.5 MB7.2 MB

DOMContentLoaded

36.3%

1.55s987ms

Total Load Time

14.8%

2.71s2.31s

Hydration Time

36.1%

1.39s885ms

Phase 1

Background & Problem Statement

The Tokopedia Lite homepage was suffering from performance issues that directly impacted user experience and business metrics. Through profiling and user feedback analysis, we identified several critical bottlenecks: excessive hydration time, oversized JavaScript chunks, unnecessary component renders, massive GraphQL response payloads, and layout shift issues during fast scrolling. These issues combined to create a slow, janky experience—especially on mobile devices and slower networks. Additionally, mounting all components at initial load caused significant layout shifts when users scrolled quickly, as offscreen components would suddenly render and expand the page height.

1

The Chosen Address widget was the largest contributor to hydration (526ms of 1.39s total), despite not being essential for server-side rendering.

2

The Left Carousel component bundled 3.8MB of JavaScript, including unused Tokonow product card components due to inline conditional rendering.

3

Dynamic Icon component rendered all icons on first load, creating unnecessary DOM elements and paint workload.

4

Home Banner included 6 layout variants from A/B tests via a bloated ChannelContainer component that wasn't actually needed.

5

The entire @tokopedia/lite-hooks library was being bundled despite only using useIntersect.

6

Channel component loaded up to 33 channels at once, with response sizes reaching unacceptable levels for mobile users.

Architecture Context

SSR and Hydration Architecture

Understanding the hydration process was crucial to our optimization strategy. Server-Side Rendering generates HTML on the server, but then the client-side JavaScript must 'hydrate' that HTML—attaching event listeners, setting up state, and making the page interactive. This hydration process can be expensive, especially when components perform unnecessary work.

The Hydration Tax

Every SSR-enabled component adds to hydration time. Even if a component doesn't need interactivity immediately, React still walks the tree and attaches listeners. We needed to identify which components truly needed SSR and which could be deferred to client-side rendering.

Bundle Composition

Webpack bundles modules based on import statements. Dynamic imports and conditional rendering don't automatically create separate chunks—developers must explicitly configure code splitting to achieve optimal bundle sizes.

GraphQL Response Patterns

Our homepage fetched all channel data upfront. While this seemed efficient (single request), it created massive response payloads that took time to download, parse, and render—especially problematic on slow networks.

Checkpoint System Architecture

The Sequential Rendering implementation relies on a custom checkpoint system built with React's useSyncExternalStore hook. This pattern enables components to mark checkpoints when rendered while allowing other parts of the application to react to these state changes. The architecture mirrors the Apollo Client IDB cache approach—using postTask with background priority for non-urgent updates.

External Store Pattern

The checkpoint state lives outside React's component tree in a singleton store. This offers decoupled state (components can update without re-rendering), multiple subscribers (various components can listen to the same checkpoint), and SSR compatibility (explicit server/client handling).

useSyncExternalStore Bridge

React 18's useSyncExternalStore is the perfect tool for subscribing to external data sources. It provides a clean API for subscribing to the checkpoint store, getting snapshots of current state, and handling SSR with getServerSnapshot.

postTask Scheduling

Checkpoint updates use postTask with 'background' priority—exactly like our Apollo IDB cache writes. This defers non-urgent work until the browser is idle, ensuring user interactions and animations remain smooth.

Guarded Updates with startTransition

The store prevents redundant updates by checking if a checkpoint is already set. Listeners are wrapped in startTransition, signaling to React that these updates are non-urgent and can be deferred.

In e-commerce, performance directly correlates with conversion rates. Every 100ms of delay can measurably drop conversions. The Tokopedia Lite homepage serves millions of users daily, many on mobile devices with limited bandwidth. Beyond user experience, there were infrastructure implications: large bundle sizes increased CDN costs, excessive hydration drained device batteries, and massive GraphQL responses strained backend services. The challenge was to dramatically improve performance without compromising functionality or requiring a complete architectural rewrite.

— Business Context
Our Approach

Solution Overview

We implemented a comprehensive two-phase optimization strategy. Phase 1 targeted code-level bottlenecks: (1) Disabled SSR and split chunks for non-critical components, (2) Refactored component APIs to enable proper tree-shaking, (3) Implemented virtualization for offscreen content, (4) Removed unused dependencies and layouts, (5) Introduced pagination for channel data. Phase 2 implemented Sequential Rendering—a checkpoint-based mounting system that loads components progressively based on viewport visibility and scroll position. This eliminated layout shifts and improved perceived performance by deferring offscreen components until needed.

Defer Non-Critical Work

Move work out of the critical rendering path. Components that don't affect initial user experience should be deferred to after hydration or loaded on demand.

Enable Tree-Shaking

Restructure code so bundlers can eliminate unused code. This means avoiding inline conditional rendering of imported components and using render props patterns.

Paginate Aggressively

Don't fetch or render content the user might never see. Implement pagination and virtual scrolling to keep initial payload small.

Measure Everything

Use Chrome DevTools Performance profiler, Lighthouse, and real user metrics (RUM) to validate every optimization. If we can't measure improvement, it doesn't exist.

Key Decisions

1

Why split Chosen Address into a separate chunk with SSR disabled? It reduced hydration time by 36.1% without affecting Lighthouse scores.

2

Why refactor LeftCarousel to use render props? It enabled proper tree-shaking, reducing chunk size from 3.8MB to 619KB (83.7% reduction).

3

Why implement pagination for channels? Data showed most users never scrolled to the bottom—fetching all 33 channels upfront was wasteful.

4

Why replace ChannelContainer with a simple div? Removed 6 unused layout variants from the bundle, reducing complexity and load time.

5

Why implement Sequential Rendering? Mounting all components at once caused layout shifts during fast scrolling; checkpoint-based loading eliminated this issue.

6

Why use useSyncExternalStore for checkpoints? Provides SSR-safe external state management with fine-grained subscriptions—only components listening to specific checkpoints re-render.

7

Why use postTask with 'background' priority? Same pattern as Apollo IDB cache writes—defers non-urgent checkpoint updates until browser is idle, keeping UI responsive.

8

Why guard checkpoint updates? Prevents redundant re-renders when components try to mark the same checkpoint multiple times.

9

Why use startTransition for listener notifications? Signals React that checkpoint updates are non-urgent and can be deferred if there's more critical work.

Deep Dive

Implementation Details

Setup Steps

Split component into separate chunk and disabled SSR options

typescript
// Before: SSR-enabled, bundled with main chunk
import ChosenAddress from './ChosenAddress';

// After: Dynamic import, client-side only
const ChosenAddress = dynamic(
  () => import('./ChosenAddress'),
  { ssr: false }
);

Code Examples

Hydration Measurement

Using Chrome DevTools to measure hydration time

typescript
// Profile hydration in Chrome DevTools Performance tab
// 1. Open DevTools > Performance
// 2. Enable 'CPU throttling' to simulate mobile (4x slowdown)
// 3. Click record, reload page
// 4. Stop recording after page stabilizes
// 5. Look for 'Hydration' phase in the timeline

// Before optimization: 1.39s
// After optimization: 885ms
// Reduction: 36.1%

Bundle Analysis

Using webpack-bundle-analyzer to identify large chunks

bash
# Run bundle analyzer
ANALYZE=true npm run build

# Key findings from analysis:
# - chunk.home-left-carousel.js: 3.8 MB (before)
# - chunk.home-left-carousel.js: 619 kB (after)
# Reduction: 83.7%

GraphQL Response Size Comparison

Before and after pagination implementation

json
// Before: Single request for all 33 channels
{
  "data": {
    "channels": [
      // 33 channel objects
      // Response size: ~900KB
    ]
  }
}

// After: Paginated request (first 10 channels)
{
  "data": {
    "channels": [
      // 10 channel objects
      // Response size: ~280KB
    ],
    "pagination": {
      "page": 1,
      "totalPages": 4,
      "hasMore": true
    }
  }
}

Checkpoint System with useSyncExternalStore

Complete implementation of the checkpoint pattern for coordinating component rendering

typescript
// Complete Checkpoint System Implementation

// types.ts
interface CheckpointStateType {
  dc: boolean;
  feed: boolean;
  seo: boolean;
}

export type SetCheckpointParamType = keyof CheckpointStateType;

// store.ts - External Store Pattern
import { startTransition } from 'react';

const createStore = () => {
  let state: CheckpointStateType = { dc: false, feed: false, seo: false };
  const listeners = new Set<() => void>();

  return {
    getSnapshot: (key: SetCheckpointParamType) => state[key],
    getServerSnapshot: () => false,
    subscribe: (listener: () => void) => {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
    setStore: (key: SetCheckpointParamType) => {
      if (state[key]) return; // Guard against duplicates
      state = { ...state, [key]: true };
      listeners.forEach(l => startTransition(l));
    },
  };
};

export const checkpointStore = createStore();

// useCheckpoint.ts - React Hook
import { useCallback, useSyncExternalStore } from 'react';
import { postTask } from '@scheduler/web';

export const useCheckpoint = (checkpoint?: SetCheckpointParamType) => {
  const getSnapshot = useCallback(
    () => checkpoint ? checkpointStore.getSnapshot(checkpoint) : false,
    [checkpoint]
  );

  const onReachCheckpoint = useCallback((key: SetCheckpointParamType) => {
    postTask(() => checkpointStore.setStore(key), {
      priority: 'background', // Non-urgent, defers until idle
    });
  }, []);

  const snapshot = useSyncExternalStore(
    checkpointStore.subscribe,
    getSnapshot,
    checkpointStore.getServerSnapshot
  );

  return [snapshot, onReachCheckpoint] as const;
};

Performance Optimizations

Code Splitting

Dynamic imports create separate chunks loaded on demand

Reduced initial JS payload by splitting non-critical components

SSR Disable Strategy

Selectively disable SSR for hydration-heavy components

Chosen Address widget moved out of hydration path, saving 526ms

Tree-Shaking Optimization

Refactor component APIs to enable dead code elimination

LeftCarousel refactor removed 3.2MB of unused code

GraphQL Pagination

Paginated queries reduce response size

Reduced initial data load by ~70% with pagination

Sequential Rendering

Checkpoint-based component mounting eliminates layout shifts

Components mount progressively based on viewport and scroll position, reducing CLS

Placeholder Stabilization

Fixed-height placeholders prevent cumulative layout shift

Skeleton placeholders maintain layout stability during deferred component loads

Results

Impact Analysis

The Tokopedia Lite homepage optimization project delivered significant, measurable improvements across all key performance metrics through a comprehensive two-phase approach. Phase 1 reduced total transfer size by 31.4%, improved Total Blocking Time by 25.82%, and achieved substantial bundle size reductions. Phase 2 implemented Sequential Rendering which eliminated layout shifts entirely (CLS 0.12 → 0.00) and improved scroll performance. Most importantly, these improvements translated to better user experience metrics: faster perceived load times, smoother scrolling without jarring content shifts, and reduced data usage for mobile users.

Performance Metrics

MetricBeforeAfterImprovement
Total Transfer Size10.5 MB7.2 MB31.4% reduction
DOMContentLoaded1.55s987ms36.3% faster
Total Load Time2.71s2.31s14.8% faster
Hydration Time1.39s885ms36.1% reduction
Left Carousel Chunk Size3.8 MB619 kB83.7% reduction
Home Chunk Size3.7 MB2.3 MB37.8% reduction
Left Carousel Task Time169.13ms64.19ms62.0% reduction
Performance Score48516.25% improvement

Qualitative Improvements

Significantly faster perceived load time for returning users on mobile devices

Reduced data usage benefits users on limited bandwidth plans

Smoother initial page interaction due to reduced hydration overhead

Improved Lighthouse scores positively impact SEO rankings

Cleaner component architecture enables future optimizations

Backend load reduced due to paginated channel requests

Visual Documentation

Hydration performance before optimization - Chosen Address widget contributed 526ms
Before: Chosen Address widget was the largest hydration contributor (526ms of 1.39s total)
Hydration performance after optimization - Reduced to 885ms
After: Hydration time reduced by 36.1% (from 1.39s to 885ms)
Left Carousel chunk analysis showing 3.8MB size
Bundle Analysis: Left Carousel was 3.8MB including unused Tokonow components
Left Carousel profiling showing component inclusion issue
Root Cause: Inline conditional rendering caused both product card variants to be bundled
Dynamic Icon rendering all icons on load
Issue: All icons rendered on first load, creating unnecessary DOM elements
Dynamic Icon with virtualization
Solution: Render blank divs for offscreen icons, reducing paint workload
Home Banner unused layout variants
Finding: 6 layout variants from A/B tests were included but unused
Unused hooks bundled in Home Banner
Issue: Entire @tokopedia/lite-hooks library bundled despite only using useIntersect