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
Total Transfer Size
31.4%
10.5 MB → 7.2 MB
DOMContentLoaded
36.3%
1.55s → 987ms
Total Load Time
14.8%
2.71s → 2.31s
Hydration Time
36.1%
1.39s → 885ms
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.
The Chosen Address widget was the largest contributor to hydration (526ms of 1.39s total), despite not being essential for server-side rendering.
The Left Carousel component bundled 3.8MB of JavaScript, including unused Tokonow product card components due to inline conditional rendering.
Dynamic Icon component rendered all icons on first load, creating unnecessary DOM elements and paint workload.
Home Banner included 6 layout variants from A/B tests via a bloated ChannelContainer component that wasn't actually needed.
The entire @tokopedia/lite-hooks library was being bundled despite only using useIntersect.
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.”
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
Why split Chosen Address into a separate chunk with SSR disabled? It reduced hydration time by 36.1% without affecting Lighthouse scores.
Why refactor LeftCarousel to use render props? It enabled proper tree-shaking, reducing chunk size from 3.8MB to 619KB (83.7% reduction).
Why implement pagination for channels? Data showed most users never scrolled to the bottom—fetching all 33 channels upfront was wasteful.
Why replace ChannelContainer with a simple div? Removed 6 unused layout variants from the bundle, reducing complexity and load time.
Why implement Sequential Rendering? Mounting all components at once caused layout shifts during fast scrolling; checkpoint-based loading eliminated this issue.
Why use useSyncExternalStore for checkpoints? Provides SSR-safe external state management with fine-grained subscriptions—only components listening to specific checkpoints re-render.
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.
Why guard checkpoint updates? Prevents redundant re-renders when components try to mark the same checkpoint multiple times.
Why use startTransition for listener notifications? Signals React that checkpoint updates are non-urgent and can be deferred if there's more critical work.
Implementation Details
Setup Steps
Split component into separate chunk and disabled SSR options
// 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
// 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
# 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
// 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
// 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
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
| Metric | Before | After | Improvement |
|---|---|---|---|
| Total Transfer Size | 10.5 MB | 7.2 MB | 31.4% reduction |
| DOMContentLoaded | 1.55s | 987ms | 36.3% faster |
| Total Load Time | 2.71s | 2.31s | 14.8% faster |
| Hydration Time | 1.39s | 885ms | 36.1% reduction |
| Left Carousel Chunk Size | 3.8 MB | 619 kB | 83.7% reduction |
| Home Chunk Size | 3.7 MB | 2.3 MB | 37.8% reduction |
| Left Carousel Task Time | 169.13ms | 64.19ms | 62.0% reduction |
| Performance Score | 48 | 51 | 6.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







