Design an E-commerce Product Listing

1. Understanding the Problem

Design the Android client for the product listing flow of an e-commerce app like Flipkart, Amazon, or Meesho โ€” covering the home feed, category browsing, search with filters, product detail page (PDP), cart, and wishlist. The defining challenges are heterogeneous UI layouts (banners, carousels, grids), instant search with debounce, multi-select filters, and optimistic cart updates with offline queuing.

๐Ÿ“Œ Pattern: Server-Driven UI

Top e-commerce apps use Server-Driven UI (SDUI): the server returns a JSON layout descriptor that the client renders generically. This lets the backend A/B test new home screen widgets, banners, and carousels without an app release. The client maps component types to ViewHolders โ€” it never hard-codes the home page structure.

Learn This Pattern โ†’

โœ… Functional Requirements

  • Home feed: banners, deal carousels, category grid (SDUI)
  • Category / search listing: paginated product grid
  • Filters: price range, brand, rating, discount โ€” multi-select
  • Sort: relevance, price asc/desc, popularity
  • Product Detail Page (PDP): images, specs, reviews, variants
  • Add to cart / wishlist with optimistic update
  • Cart badge count live in toolbar
  • Search with suggestions and history

โš™๏ธ Non-Functional Requirements

  • Listing load: <800 ms on 4G
  • Search response: suggestions within 200 ms of debounce
  • Images: Thumbnail first, full-res on expand
  • Scroll: 60 fps on a 2-column product grid
  • Offline: Wishlist and cart readable without network
  • Cart sync: Queue offline add-to-cart, sync on reconnect
  • Price freshness: PDP re-fetches price on resume (flash sales)
๐Ÿ’ก Clarifying Questions to Ask
  • Is the home feed server-driven or hardcoded? (SDUI vs fixed layout)
  • Is search client-side (local Room) or server-side (Elasticsearch)?
  • Should cart/wishlist work offline?
  • Is checkout / payment in scope?

2. The Set Up

The Four Key Screens

๐Ÿ 
Home Feed
SDUI ยท Banner carousel ยท Deal sections
๐Ÿ”
Listing / Search
2-col grid ยท Paging 3 ยท Filters
๐Ÿ“ฆ
Product Detail
Image gallery ยท Variants ยท Reviews
๐Ÿ›’
Cart / Wishlist
Optimistic updates ยท Offline Room cache

Core Components

๐Ÿ“‹
ConcatAdapter / SDUI
Heterogeneous home feed sections
๐Ÿ“„
Paging 3
Infinite product grid with filters
๐Ÿ—„๏ธ
Room DB
Cart ยท Wishlist ยท Search history
๐Ÿ–ผ๏ธ
Coil
Thumbnail + full-res progressive load
โš™๏ธ
WorkManager
Offline cart sync queue
๐ŸŒ
Retrofit + OkHttp
REST API + HTTP response cache

3. High-Level Design

Architecture Overview โ€” E-commerce Client
UI Layer HomeFragment (SDUI) ListingFragment (grid) PdpFragment CartFragment ViewModels HomeVM ยท ListingVM CartVM ยท SearchVM Repositories ProductRepo ยท CartRepo Room DB cart ยท wishlist search_history ยท action_queue REST API GET /home (SDUI) GET /products?q=&filter= GET /product/{id} POST /cart ยท POST /wishlist Search API Elasticsearch / Typesense CDN Product images ยท thumbnails WorkManager SyncCartWorker โญ Room = Cart/Wishlist SSoT

Server-Driven UI (SDUI) โ€” Home Feed Response

// Server returns a list of typed components โ€” client renders generically
{
  "components": [
    { "type": "BANNER_CAROUSEL", "items": [{ "imageUrl": "...", "deepLink": "app://sale/diwali" }] },
    { "type": "SECTION_HEADER", "title": "Deal of the Day", "timer": 1714291200 },
    { "type": "PRODUCT_HORIZONTAL_SCROLL", "products": [ ... ] },
    { "type": "CATEGORY_GRID", "columns": 4, "categories": [ ... ] },
    { "type": "PRODUCT_GRID", "title": "Recommended for You", "products": [ ... ] }
  ]
}

The client's HomeAdapter maps each type string to a distinct ViewHolder. New component types released by the server gracefully degrade if the client doesn't know them โ€” handled with an UNKNOWN fallback ViewHolder that renders nothing.

Listing Filter State

Filters are composable and cumulative. Model them as a sealed class or a data class that the ViewModel holds as a StateFlow. Every filter change creates a new FilterState and triggers a Paging 3 refresh:

data class FilterState(
    val query: String = "",
    val sortBy: SortOption = SortOption.RELEVANCE,
    val priceMin: Int? = null,
    val priceMax: Int? = null,
    val brands: Set<String> = emptySet(),
    val minRating: Float? = null,
    val discountMin: Int? = null
)

// In ViewModel: any filter change resets pagination
val filterState = MutableStateFlow(FilterState())

val products = filterState
    .debounce(300)              // wait for user to finish typing/selecting
    .flatMapLatest { filters ->
        Pager(PagingConfig(pageSize = 20)) {
            ProductPagingSource(api, filters)
        }.flow
    }
    .cachedIn(viewModelScope)

4. Low-Level Design

Whiteboard: Product Listing Full Pipeline

Listing Page โ€” Search + Filter + Paginate
ListingFragment ListingViewModel PagingSource Search API RecyclerView + DiffUtil 1 User types "shoes" 2 filterState.update(query=shoes) debounce 300 ms 3 flatMapLatest โ†’ new Pager 4 GET /products?q=shoes&page=0 5 Returns LoadResult.Page 6 Emits PagingData<Product> 7 DiffUtil โ†’ smooth grid insert 8 Taps "Nike" brand filter filterState โ†’ brands += Nike โ†’ flatMapLatest cancels old Pager โ†’ new Pager with brand=Nike 9 โ€” Paging auto-APPEND: next page fetched 5 items before scroll reaches end

Flow 1: Search with Debounce + Suggestions

Flow 1 โ€” Search: debounce prevents API spam; suggestions merge local history + server
SearchBar (UI) SearchViewModel Room (search_history) Search API
1User types "nikes" (typo) โ€” each keystroke emitted to ViewModel
2queryFlow.debounce(300ms) โ€” waits for typing to pause
3Query both sources in parallel via combine()
4SELECT * FROM search_history WHERE query LIKE 'nike%' LIMIT 3
5GET /suggest?q=nikes โ†’ ["nike shoes", "nike air max"] (with fuzzy match)
6Merge: history (labelled ๐Ÿ•) appears first, then server suggestions
7Suggestion dropdown shown. User taps "nike air max"
8INSERT "nike air max" into search_history (upsert with timestamp)
9filterState.update(query="nike air max") โ†’ Paging refresh

Flow 2: Add to Cart (Optimistic + Offline Queue)

Flow 2 โ€” Cart: instant UI, background API, offline queue if no internet
PDP / Listing UI CartViewModel Room (cart table) Cart API WorkManager
1User taps "Add to Cart" โ†’ button shows spinner momentarily
2Optimistic: immediately increment cart badge count in StateFlow
3INSERT into cart (productId, qty, addedAt, syncStatus=PENDING)
4Cart badge updates reactively from Room Flow โ€” user sees it immediately
5a โœ“ Online: POST /cart {productId, qty} โ†’ success
5b โœ“UPDATE syncStatus=SYNCED
6a โœ— Offline: POST fails with IOException
6bEnqueue SyncCartWorker (CONNECTED constraint) โ€” retries when online
7 โœ— Server error: rollback โ€” DELETE from Room, decrement badge, show Snackbar

Flow 3: Product Detail Page (PDP) Load

Flow 3 โ€” PDP: stale-while-revalidate for fast open; live price on resume
PdpFragment PdpViewModel OkHttp Cache / Room Product API
1User taps product card โ†’ navigate with productId
2Check OkHttp disk cache for /product/{id}
3Cache hit: return stale data immediately (max-age=5min)
4Render product immediately from cache โ€” images, title, specs
5Background revalidation: GET /product/{id} with If-None-Match ETag
6Fresh data arrives โ†’ update only the price/stock badge (not full reload)
7 onResume: Always re-fetch price + stock (flash sales change price every minute)
8Progressive image: thumbnail loads first, full-res replaces on complete

Key Code: SDUI Home Adapter

// One adapter handles all component types โ€” new types from server need no app update
class HomeAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {

    private val items = mutableListOf<HomeComponent>()

    override fun getItemViewType(position: Int) = when (items[position]) {
        is HomeComponent.BannerCarousel         -> VIEW_TYPE_BANNER
        is HomeComponent.SectionHeader           -> VIEW_TYPE_HEADER
        is HomeComponent.ProductHorizontalScroll  -> VIEW_TYPE_H_SCROLL
        is HomeComponent.CategoryGrid            -> VIEW_TYPE_CAT_GRID
        is HomeComponent.ProductGrid             -> VIEW_TYPE_PROD_GRID
        is HomeComponent.Unknown                -> VIEW_TYPE_UNKNOWN
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) {
        VIEW_TYPE_BANNER    -> BannerCarouselVH.from(parent)
        VIEW_TYPE_H_SCROLL  -> HorizontalScrollVH.from(parent)
        VIEW_TYPE_CAT_GRID  -> CategoryGridVH.from(parent)
        VIEW_TYPE_PROD_GRID -> ProductGridVH.from(parent)
        else                -> UnknownVH.from(parent)  // graceful degradation
    }
}

// JSON deserialization: polymorphic based on "type" field
sealed class HomeComponent {
    data class BannerCarousel(val items: List<BannerItem>) : HomeComponent()
    data class SectionHeader(val title: String, val timerEpoch: Long?) : HomeComponent()
    data class ProductHorizontalScroll(val products: List<Product>) : HomeComponent()
    data class ProductGrid(val title: String, val products: List<Product>) : HomeComponent()
    object Unknown : HomeComponent()        // unknown server type โ†’ render nothing
}

Key Code: ProductPagingSource with Filters

class ProductPagingSource(
    private val api: ProductApi,
    private val filters: FilterState
) : PagingSource<Int, Product>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Product> {
        val page = params.key ?: 0
        return try {
            val response = api.getProducts(
                query     = filters.query,
                sortBy    = filters.sortBy.apiKey,
                priceMin  = filters.priceMin,
                priceMax  = filters.priceMax,
                brands    = filters.brands.joinToString(","),
                minRating = filters.minRating,
                page      = page,
                size      = params.loadSize
            )
            LoadResult.Page(
                data     = response.products,
                prevKey  = if (page == 0) null else page - 1,
                nextKey  = if (response.hasMore) page + 1 else null
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }

    // Invalidate when filter changes โ€” Paging re-creates the source
    override fun getRefreshKey(state: PagingState<Int, Product>) = null
}

5. Potential Deep Dives

Product Image โ€” Progressive Loading

Product images follow a two-phase load: (1) Tiny thumbnail (e.g., 20ร—20 blurred placeholder from the API response as a base64 string) renders instantly; (2) The full product image loads via Coil and cross-fades in when ready. On the PDP image gallery, pre-fetch adjacent images (left/right) while the user views the current one using headless Coil requests. Use a ViewPager2 with offscreenPageLimit = 1 so adjacent pages stay in memory.

๐Ÿ“Œ Pattern: Stale-While-Revalidate

Serve product details from OkHttp disk cache instantly, then silently refresh in the background. The user sees content immediately. The UI only updates the price/stock badge when fresh data arrives โ€” not the whole page โ€” preventing a jarring full reload.

Learn This Pattern โ†’

Cart Badge โ€” Global State

The cart item count must be visible across all screens (toolbar badge). Model it as a SharedViewModel scoped to the Activity, not individual Fragments. The CartViewModel exposes a cartCount: StateFlow<Int> derived from Room.observeCartCount(). Every Fragment accesses it via activityViewModels<CartViewModel>(). The badge auto-updates across the whole app when any screen adds/removes a cart item โ€” zero event bus or broadcast receiver needed.

Filter Bottom Sheet

Use a BottomSheetDialogFragment with a custom layout for multi-select brand chips, a RangeSlider for price, and star rating buttons. When the user taps "Apply", build a new FilterState and pass it back via a setFragmentResult. The ListingFragment receives it and calls listingViewModel.applyFilters(newState), which updates the filterState Flow, triggering flatMapLatest to cancel the old Pager and start fresh. Show a filter badge count on the filter button (e.g., "Filter (3)") to indicate active filters.

Deep Link Navigation

E-commerce apps receive deep links from notifications, WhatsApp shares, and web browsers. Example: app://product/B08P3LGQKR or https://store.com/product/B08P3LGQKR. Configure these in the Manifest with <intent-filter> and <nav-graph> deep links. The Navigation Component resolves the deep link to the correct Fragment with the correct arguments. Handle the case where the user is not logged in โ€” redirect to login first, then return to the deep-linked product after authentication.

Recently Viewed Products

Store recently viewed product IDs in Room's recently_viewed table (capped at 20 rows via a trigger or manual eviction). On the Home screen, include a "Recently Viewed" horizontal scroll section that reads from Room reactively. Update on every PDP open: INSERT OR REPLACE INTO recently_viewed (id, viewedAt) VALUES (?, ?). This feature works completely offline.

6. What is Expected at Each Level

๐Ÿ”ต Mid-Level
  • RecyclerView with product grid (2 columns)
  • Loads images with Coil/Glide
  • Basic search with API call on text change
  • Cart stored locally in Room
  • Handles config changes in ViewModel
  • Basic filter passed as query params
๐ŸŸข Senior
  • SDUI home feed with unknown type fallback
  • Paging 3 + flatMapLatest for filter-aware pagination
  • Search debounce + merge local history + server
  • Optimistic cart with rollback on error
  • WorkManager for offline cart sync queue
  • Stale-while-revalidate for PDP
  • Cart badge as Activity-scoped SharedViewModel
๐ŸŸฃ Staff+
  • Full SDUI rendering engine with versioning
  • A/B testing home layouts via server config
  • Client-side price update via SSE (flash sale)
  • Product image CDN with blurhash thumbnails
  • Search ranking: pinned results, sponsored items
  • Analytics instrumentation: product impressions, add-to-cart funnel
  • Accessibility: content descriptions, custom touch targets for price

7. Interview Questions

1. What is Server-Driven UI (SDUI) and why do e-commerce apps use it?
Mediumโ–พ

SDUI means the server returns a layout descriptor (JSON with component types and data) rather than just raw data. The app renders components dynamically based on the type field. Benefits: (1) No app update needed to change the home page layout โ€” add a new banner, reorder sections, or run a seasonal sale layout by changing only the server response; (2) A/B testing: serve different layouts to different user segments; (3) Personalisation: the home feed can be different per user based on purchase history. The client's responsibility is rendering any valid component type correctly and gracefully degrading unknown types.

2. How do you handle a new component type that the app doesn't know about?
Easyโ–พ

During JSON deserialization, use a polymorphic deserializer (e.g., Gson's RuntimeTypeAdapterFactory or Kotlin Serialization's @JsonClassDiscriminator). When the type field is unrecognised, map it to an Unknown sealed class variant. In the adapter's getItemViewType, the Unknown variant returns a special view type. The UnknownViewHolder inflates a View.GONE layout โ€” it renders nothing, occupying zero height. This prevents a crash while leaving a no-op placeholder. Alternatively, use @JsonAdapter with a fallback null and filter nulls before binding.

3. Why use flatMapLatest when the user changes a filter?
Mediumโ–พ

flatMapLatest cancels the previous coroutine/flow when a new value arrives on the upstream flow. If the user changes the sort from "Price: Low to High" to "Popularity" while a page fetch is in-flight, flatMapLatest cancels the old Pager and its pending HTTP requests, then starts a fresh Pager with the new filter. Using flatMap (without Latest) would let both Pagers run concurrently โ€” the results of the old filter could arrive after the new filter's results and overwrite them, showing the wrong products. Combined with debounce on the filter flow, this ensures only one API call fires per user interaction.

4. How do you implement search debounce correctly?
Easyโ–พ

Expose the search text as a MutableStateFlow<String> in the ViewModel. In the Fragment, collect text changes from the SearchView or EditText and call viewModel.onQueryChanged(text). In the ViewModel: queryFlow.debounce(300).distinctUntilChanged().flatMapLatest { query -> searchApi.suggest(query) }. debounce(300) waits 300 ms after the last keystroke before emitting. distinctUntilChanged() skips re-running if the user typed then deleted the same letter. flatMapLatest cancels the in-flight suggestion API call if the user types again before it completes.

5. How do you keep the cart badge count in sync across all screens?
Mediumโ–พ

Scope a CartViewModel to the Activity (not individual Fragments): val cartVm: CartViewModel by activityViewModels(). It exposes a cartCount: StateFlow<Int> backed by a Room query: SELECT COUNT(*) FROM cart returning a Flow<Int>. Every Fragment and the Activity's toolbar observe this same Flow. When any Fragment adds/removes a cart item and Room updates, the Flow emits and every observer receives the new count simultaneously โ€” no event bus, no broadcast, no manual notification. The ViewModel survives Fragment transactions and navigation, keeping state consistent throughout the session.

6. How does optimistic cart update work and what happens on rollback?
Mediumโ–พ

On "Add to Cart" tap: (1) Immediately INSERT the item into Room with syncStatus=PENDING; (2) The cart badge count updates reactively from Room โ€” user sees it increment instantly; (3) Fire POST /cart in a background coroutine. On API success: UPDATE syncStatus=SYNCED. On API failure (4xx/network error): DELETE the item from Room โ€” the badge decrements back, and show a Snackbar "Failed to add to cart. Retry?". For offline: keep the PENDING row in Room and enqueue a WorkManager job. The key insight is that the source of truth for the count is always Room โ€” the API just keeps the server in sync.

7. How do you implement a 2-column staggered product grid?
Easyโ–พ

Use StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL) for product grids where image heights vary (portrait vs landscape products). For uniform-height cards, use GridLayoutManager(context, 2). With Paging 3, the PagingDataAdapter works with both. For the header row (filter bar, sort chips), set the header ViewHolder to span both columns: in GridLayoutManager, override setSpanSizeLookup returning 2 for the header position. In StaggeredGrid, call params.isFullSpan = true in onBindViewHolder for header items.

8. How do you handle price changes during flash sales on the PDP?
Hardโ–พ

Three layers: (1) onResume re-fetch: the PDP ViewModel calls fetchLivePrice(productId) every time the screen is resumed. This covers users who left the PDP and came back. (2) Short max-age: set Cache-Control: max-age=60 on the price endpoint โ€” OkHttp serves cache for 60 s, then revalidates. (3) SSE push (advanced): for true flash sales, the server sends a price update via Server-Sent Events on the /product/{id}/live endpoint โ€” the client updates the price badge in real-time. On "Add to Cart", always hit the price API one last time to confirm the current price and show a "Price changed" dialog if it differs from what the user saw.

9. How do you implement product image zoom on the PDP?
Easyโ–พ

Use a ViewPager2 for the image gallery (swipe between images). For pinch-to-zoom within each image, use PhotoView library (wraps ImageView with matrix-based pan/zoom) or implement a custom ScaleGestureDetector. Double-tap toggles between fit-to-screen and 2x zoom. The zoomed ImageView intercepts touch events, so configure viewPager2.isUserInputEnabled = false when the image is zoomed (zoom > 1.0) to prevent accidental page swipes while panning. When zoom resets to 1.0, re-enable swipe.

10. How do you track product impressions (which items were seen in the list)?
Hardโ–พ

Use RecyclerView.addOnScrollListener to detect when items become visible. Alternatively, attach a VisibilityTracker that uses each ViewHolder's itemView.getGlobalVisibleRect() to compute visibility percentage on scroll settle. An item is "impressed" when โ‰ฅ50% visible for โ‰ฅ1 second. Collect impression events in a batch list in the ViewModel. Flush the batch to analytics API every 30 s or on Fragment.onStop. Don't fire one API call per impression โ€” batch them: POST /impressions [{productId, position, rankScore, timestamp}]. This data feeds the search ranking and recommendation models server-side.

11. How do you show a "Back in stock" notification for a wishlisted product?
Hardโ–พ

When the user wishlists an out-of-stock product, send POST /wishlist {productId, notifyOnRestock: true}. The server subscribes the user's FCM token to the product's restock topic. When stock is replenished, the server sends an FCM data message with {type: "RESTOCK", productId: "XYZ", productName: "Nike Air Max"}. The FirebaseMessagingService.onMessageReceived() handler builds and shows a notification with a PendingIntent deep-linking to the PDP. On the client Room side, update the product's inStock flag so the Wishlist screen shows updated availability without an extra API call.

12. How would you implement product variant selection (size, colour)?
Mediumโ–พ

The product API returns a variants map: {size: [S, M, L, XL], colour: [Red, Blue]} with a variantMatrix mapping combinations to availability and price. Render each dimension as a horizontally scrollable chip group. Selected chips are highlighted. On selection, look up the combination in the variant matrix to: (1) update the displayed price; (2) grey out unavailable combinations; (3) update the product image if colour changed (each colour has a separate image array); (4) update the skuId passed to the cart API. If a combination is out of stock, show "Notify Me" instead of "Add to Cart".

13. How does OkHttp's disk cache help with product listing performance?
Mediumโ–พ

Configure OkHttp with a disk cache: OkHttpClient.Builder().cache(Cache(cacheDir, 20L * 1024 * 1024)).build(). The server sets Cache-Control: max-age=300 on product listing responses. OkHttp stores the response and serves it from disk for 5 minutes without hitting the network. When the user navigates back to the same category, the listing appears instantly. After max-age expires, OkHttp revalidates using the ETag header โ€” if unchanged, the server returns 304 and OkHttp serves from cache again. This is the HTTP-layer equivalent of offline-first for read-only data where Room isn't needed.

14. How do you handle the "Add to Cart" button state during an in-flight request?
Easyโ–พ

Model button state as a sealed class: CartButtonState { Idle | Loading | InCart | Error }. On tap: set state to Loading โ†’ disable button, show spinner. On success: set to InCart โ†’ show "Go to Cart" with green background. On error: set to Error โ†’ show "Retry" with red background, re-enable button. The ViewModel exposes this as a StateFlow<CartButtonState>. Because we write to Room optimistically before the API call, the cart badge updates immediately regardless of the button state. The button state only reflects the API sync status โ€” it's a cosmetic indicator, not the source of truth for cart contents.

15. How would you implement infinite scroll with header + filter chips + product grid?
Hardโ–พ

Use ConcatAdapter merging three adapters: (1) SortFilterAdapter โ€” a single-item adapter showing the sort bar and active filter chips; (2) PagingDataAdapter โ€” the infinite product grid; (3) LoadStateAdapter โ€” the loading footer. Set GridLayoutManager.SpanSizeLookup: the header spans all 2 columns (spanSize=2), product cards span 1 each. When filter chips are tapped, update FilterState in the ViewModel โ†’ flatMapLatest creates a new PagingDataAdapter source โ†’ the RecyclerView resets to the top smoothly. The header adapter is not recreated โ€” only the product adapter refreshes.

16. How do you test the ProductPagingSource?
Hardโ–พ

Use Paging 3's TestPager: val pager = TestPager(PagingConfig(pageSize=20), ProductPagingSource(fakeApi, filters)). Call pager.refresh() and assert the returned LoadResult.Page contains the expected products. Call pager.append() to test pagination. For the filter integration test: create a FakeProductApi that checks the query parameters match the FilterState. Test error paths by having the fake API throw an exception and assert LoadResult.Error. For the ViewModel's flatMapLatest: use Turbine to collect Flow emissions and assert the correct PagingData is emitted after each filter change.

17. How would you implement "Compare products" across multiple PDPs?
Hardโ–พ

A comparison tray is a global floating widget visible across screens. Model the comparison list as an Activity-scoped CompareViewModel with a StateFlow<List<Product>> (max 3 items). Each PDP has a "Compare" checkbox that writes to/from this ViewModel. A persistent bottom tray Fragment (added to the Activity layout, not per-Fragment) observes the CompareViewModel and shows thumbnails of selected products. On "Compare Now": navigate to a dedicated CompareFragment that renders a scrollable table mapping spec names โ†’ values across the selected products. Specs are fetched in parallel with async { api.getProduct(id) }.await() for each product.

18. How do you handle a 429 Too Many Requests from the search API?
Mediumโ–พ

The search API has rate limits because Elasticsearch queries are expensive. Defense layers: (1) Debounce (already in place) โ€” 300 ms delay prevents keystroke-per-request; (2) OkHttp Authenticator / Interceptor: on 429, read the Retry-After header and delay the retry accordingly; (3) OkHttp cache: for the same query + filters, serve from cache for 60 s before re-hitting the server; (4) Exponential backoff in Retrofit: on 429, use Kotlin's delay() before retry. For the PagingSource: return LoadResult.Error on 429 โ€” Paging shows the retry button via LoadStateAdapter, letting the user manually retry rather than hammering the server.

19. How would you personalise the home feed per user?
Hardโ–พ

Send the user's auth token with GET /home. The server's personalisation engine uses purchase history, browsing history, and segment data to build the SDUI response with personalised product IDs and section ordering. The client renders it generically โ€” it doesn't know it's personalised. For cache strategy: personalised home pages must have a user-specific cache key (include user ID in the URL or cache key), so user A's cached home page isn't served to user B on a shared device. Set a shorter max-age (e.g., 5 minutes) for personalised responses vs 30 minutes for non-personalised category pages.

20. How do you make the product listing accessible for screen reader users?
Hardโ–พ

Key accessibility requirements for product cards: (1) Content description: imageView.contentDescription = "Nike Air Max, โ‚น5,999, 4.3 stars, 30% off" โ€” merge all card info into one meaningful description so TalkBack reads it in one swipe rather than announcing each element separately; (2) Import for accessibility: set card.importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES; (3) Minimum touch target: 48dp ร— 48dp for Add to Cart and Wishlist buttons โ€” use TouchDelegate or padding to expand small icons; (4) Price changes: use ViewCompat.setAccessibilityLiveRegion(priceView, ACCESSIBILITY_LIVE_REGION_POLITE) so TalkBack announces price updates without interrupting ongoing speech.