Design an E-commerce Product Listing
A mobile-first system design breakdown of an e-commerce app like Flipkart or Amazon — covering SDUI home feeds, Paging 3 with filter-aware pagination, optimistic cart updates, and offline-first Room architecture on Android.
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.
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.
✅ 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)
- 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?
// 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.
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)
| 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 |
| 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 |
| 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 |
// 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 }
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 }
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.
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.
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.
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.
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.
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.
This is the defining real-time challenge in e-commerce. A flash sale launches: 50,000 users have the product page open, stock shows "1 left", and thousands tap "Buy Now" within the same second. Three separate problems must be solved simultaneously: atomic server-side reservation, optimistic client rollback on failure, and real-time "Sold Out" propagation to every device that still has the page open.
| Approach | Mechanism | Best For |
|---|---|---|
| Optimistic locking | UPDATE inventory SET stock=stock-1, version=v+1 WHERE id=? AND stock>0 AND version=? — 0 rows affected → return 409 |
Low-to-medium contention. No DB lock held between read and write. |
| Redis atomic DECR | DECR product:{id}:stock — if result < 0 → INCR to reverse → 409. Sub-millisecond, single-threaded Redis. |
Flash sales with thousands of RPS. DB never touched until Redis confirms reservation. |
| Pessimistic locking | SELECT stock FROM inventory WHERE id=? FOR UPDATE — serialises all buyers through a DB row lock. |
Avoid under load. One slow transaction blocks every other buyer on that product. |
On "Buy Now" tap, the client writes to Room immediately (optimistic) then fires the API. On a 409 it must cleanly reverse everything:
suspend fun addToCart(product: Product) { // 1. Optimistic — write to Room instantly, badge updates immediately val localId = cartRepo.insertPending(product) // syncStatus = PENDING try { val response = api.addToCart(product.id) // POST /cart/buy when (response.code()) { 200 -> cartRepo.markSynced(localId) // ✅ confirmed 409 -> { // ❌ race lost — item sold out cartRepo.delete(localId) // rollback Room row → badge decrements productRepo.markSoldOut(product.id) // update local stock to 0 _uiState.emit(UiState.SoldOut(product.name)) // show Snackbar } 503 -> { // server overloaded — queue offline cartRepo.markQueued(localId) // WorkManager will retry } } } catch (e: IOException) { // network loss cartRepo.markQueued(localId) // offline queue, sync on reconnect } }
The hardest part: after User A buys the last item, the 49,999 other users still see "1 left" on their screens. You need to push stock=0 to every open PDP instance within milliseconds. Three options:
| Strategy | How | Latency | Scale |
|---|---|---|---|
| SSE (Server-Sent Events) | Client opens GET /product/{id}/stock/stream. Server pushes data: {"stock":0} when sold out. OkHttp keeps connection alive. |
<200ms | Best for PDP — one stream per user viewing that product |
| FCM data push | Server sends silent FCM to all devices subscribed to product_{id}_stock topic. Client updates Room on receive. |
1–30s | Best for background/killed apps. Too slow for open-PDP scenario. |
| onResume polling | PDP ViewModel calls fetchLiveStock() every time screen resumes. |
On resume | Fallback. Always implement alongside SSE as a safety net. |
A standard "add to cart" flow collapses under flash sale traffic. The solution is to decouple the user-facing response from the actual inventory write using a reservation queue:
The client subscribes to the SSE stream the moment it opens the PDP. When the stock=0 event arrives, the ViewModel updates Room and the UI transitions to "Sold Out" — the Buy button greys out and the badge disappears, even if the user never tapped Buy Now themselves.
// PDP ViewModel — subscribe to live stock updates via SSE fun observeLiveStock(productId: String) { viewModelScope.launch(Dispatchers.IO) { val request = Request.Builder() .url("$BASE_URL/product/$productId/stock/stream") .build() okHttpClient.newCall(request).execute().use { response -> response.body?.source()?.let { source -> while (!source.exhausted()) { val line = source.readUtf8Line() ?: break if (line.startsWith("data:")) { val event = gson.fromJson(line.removePrefix("data:"), StockEvent::class.java) productRepo.updateStock(productId, event.stock) // → Room → UI if (event.stock == 0) break // sold out, close stream } } } } } } // onCleared() — OkHttp call is cancelled automatically when ViewModel is destroyed
User adds "Nike Air Max" to cart on the web browser. Three seconds later, they open the Android app. The item is already in the server-side cart. What should happen?
On app launch, the client calls GET /cart and receives the authoritative server cart. The client does a full replace of Room's cart table with the server response — no merge logic needed on the client. The server handles deduplication: if the item is in both the web cart and the mobile cart (both added offline), the server merges by quantity (taking the max, or summing, depending on product rules). The merge policy lives server-side because it has the full history.
User A has their phone and tablet open. They add the same item on both devices at the same second. The server receives two POST /cart/buy calls for the same (userId, productId). The correct server behaviour is idempotency: use INSERT OR IGNORE (or ON CONFLICT DO NOTHING) on the cart table keyed by (userId, productId). The second request returns 200 with the existing cart row — no duplicate, no error. The client simply refreshes from the server response and Room reflects the single item.
-- Server-side cart upsert — idempotent by design INSERT INTO cart (user_id, product_id, quantity, added_at) VALUES (?, ?, 1, now()) ON CONFLICT (user_id, product_id) DO UPDATE SET quantity = cart.quantity + EXCLUDED.quantity, -- or MAX() depending on rules updated_at = now(); -- Client refreshes Room from the returned cart row — single source of truth
User removes an item on the web. The Android app is backgrounded. When the app resumes, onResume triggers a background GET /cart sync. The server returns the cart without that item. Room is updated via cartDao.replaceAll(serverItems) — a single DELETE + batch INSERT inside a transaction. The RecyclerView observing Room via Flow updates automatically. The user never sees stale data once they return to the screen.
- 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
- 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
- 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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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".
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.
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.
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.
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.
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.
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.
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.
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.
Three strategies: (1) Redis DECR — before writing to DB, run DECR inventory:{productId}. If result < 0, run INCR to restore and return 409. Sub-millisecond and handles thundering herd; best for flash sales. (2) Optimistic locking — DB row has a version column. UPDATE inventory SET stock=stock-1, version=version+1 WHERE product_id=? AND version=? AND stock>0. If 0 rows updated → 409. Best for low-contention scenarios. (3) Pessimistic locking (SELECT FOR UPDATE) — serialises all writers; safe but creates a bottleneck under flash-sale load. In practice: Redis DECR as the fast gate, async DB write through a queue, SSE to propagate Sold Out to all open clients.
Optimistic UI + rollback: (1) On tap, immediately write a PENDING item to Room and render it in the RecyclerView — zero perceived latency. (2) Fire POST /cart to the server. (3) On HTTP 409 (inventory exhausted), delete the PENDING row from Room and call adapter.notifyItemRemoved() to animate it out. Show a Snackbar: "Sorry, this item just sold out." (4) On HTTP 503 (queue full during flash sale), keep the item in Room as QUEUED and poll for confirmation. (5) On IOException (no network), keep as PENDING with a retry indicator — WorkManager retries with exponential backoff when connectivity returns.
Three-tier approach: (1) SSE (primary) — each app opens a persistent SSE connection. When stock hits 0, the inventory service publishes to a Pub/Sub topic; fan-out workers push data: {"productId":"X","stock":0} to all open SSE streams. Latency <200ms, no polling. (2) FCM (background) — for apps backgrounded or on flaky networks, send a high-priority FCM data message. Latency 1–30s but guaranteed delivery via FCM's retry mechanism. (3) onResume sync (fallback) — every time the app foregrounds, hit GET /products/{id} and refresh stock UI from the response. Catches anything missed by SSE or FCM. The client marks a product view as stale if its SSE connection dropped; on reconnect it re-fetches fresh stock before showing the PDP.
Optimistic locking: best when contention is rare (most SKUs most of the time). No locks held, reads are free, only the occasional retry on conflict. Fails under high concurrency because retry storms amplify DB load. Redis DECR: best for high-contention scenarios (flash sales, limited drops). Atomic, sub-millisecond, handles thousands of RPS without DB pressure. Downside: Redis is in-memory — needs AOF persistence or a DB reconciliation job to avoid ghost stock on restart. Pessimistic locking (SELECT FOR UPDATE): simplest correctness guarantee but serialises all writers on the same row. Fine for back-office tools or very low throughput. Avoid in user-facing flows with >50 concurrent buyers on the same SKU — lock wait timeouts cascade into timeouts and retries. In production: use Redis DECR as a fast pre-check, then optimistic locking in DB for the authoritative write.
Server is source of truth. (1) Idempotent writes: every POST /cart uses ON CONFLICT (user_id, product_id) DO UPDATE SET quantity = cart.quantity + EXCLUDED.quantity so duplicate adds from two devices merge quantities rather than overwrite. (2) SSE cart-update events: after any successful cart write, the server pushes data: {"type":"cart_updated"} to all other active SSE connections for that user. The receiving device calls GET /cart and calls cartDao.replaceAll(serverItems) — a single transaction that DELETEs local cart and batch-INSERTs the server state, keeping Room exactly in sync. (3) onResume sync: as a fallback, every time either device foregrounds, it fetches GET /cart and replaces local state. This catches any SSE events missed while backgrounded. The result: both devices always converge to the same cart within <500ms of any change on either side.