E-commerce apps must show accurate stock levels without creating a poor user experience, and must protect against fraud signals that are detectable on the client side.
Inventory: Eventual Consistency Problem
True real-time inventory is expensive. Most apps use a compromise: show "approximate" stock, optimistically allow adding to cart, and validate at checkout.
data class StockInfo(
val productId: String,
val status: StockStatus, // IN_STOCK, LOW_STOCK, OUT_OF_STOCK
val quantity: Int?, // show actual count only if < 10 (urgency cue)
val updatedAt: Long // freshness timestamp
)
enum class StockStatus { IN_STOCK, LOW_STOCK, OUT_OF_STOCK }
// Refresh stock for visible products as user scrolls
class InventoryManager(private val api: InventoryApi, private val cache: InventoryCache) {
suspend fun getStock(productId: String): StockInfo {
val cached = cache.get(productId)
// Use cache if fresh (< 5 minutes)
if (cached != null && System.currentTimeMillis() - cached.updatedAt < 5 * 60 * 1000) {
return cached
}
// Refresh in background, return stale cache immediately
scope.launch { refreshStock(productId) }
return cached ?: StockInfo(productId, StockStatus.IN_STOCK, null, 0)
}
private suspend fun refreshStock(productId: String) {
try {
val fresh = api.getStock(productId)
cache.put(productId, fresh.copy(updatedAt = System.currentTimeMillis()))
} catch (e: IOException) {
// Keep stale cache; don't crash product listing
}
}
// Batch refresh for product list (single API call)
suspend fun refreshBatch(productIds: List<String>) {
val fresh = api.getBatchStock(productIds)
fresh.forEach { cache.put(it.productId, it.copy(updatedAt = System.currentTimeMillis())) }
}
}
Stock UI: Show Urgency
@Composable
fun StockBadge(stock: StockInfo) {
when (stock.status) {
StockStatus.OUT_OF_STOCK ->
Text("Out of Stock", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.labelSmall)
StockStatus.LOW_STOCK ->
Text(
if (stock.quantity != null) "Only ${stock.quantity} left!" else "Low stock",
color = Color(0xFFE65100), // deep orange — urgency
style = MaterialTheme.typography.labelSmall
)
StockStatus.IN_STOCK -> {} // don't show "In Stock" — it's assumed
}
}
Client-Side Fraud Signals
The app cannot prevent fraud alone, but can send signals to the server for server-side decisions:
Device Fingerprinting (for Risk Scoring)
class DeviceRiskProfiler(private val context: Context) {
fun getRiskSignals(): DeviceRiskSignals {
return DeviceRiskSignals(
// Emulator indicators
isEmulator = isEmulator(),
// Rooted device
isRooted = isRooted(),
// Screen interaction patterns (real users scroll; bots don't)
userAgent = buildUserAgent(),
// Play Integrity token (strongest device attestation)
// Fetched separately via Play Integrity API
)
}
private fun isEmulator(): Boolean {
return (Build.FINGERPRINT.startsWith("generic") ||
Build.FINGERPRINT.startsWith("unknown") ||
Build.MODEL.contains("Emulator") ||
Build.MODEL.contains("Android SDK built for x86"))
}
private fun isRooted(): Boolean {
return arrayOf("/su", "/system/bin/su", "/system/xbin/su").any { File(it).exists() }
}
private fun buildUserAgent(): String =
"Android/${Build.VERSION.RELEASE} ${Build.MANUFACTURER}/${Build.MODEL}"
}
data class DeviceRiskSignals(
val isEmulator: Boolean,
val isRooted: Boolean,
val userAgent: String
)
OTP / 3DS for High-Value Orders
class CheckoutRiskManager {
fun requiresAdditionalVerification(order: Order, riskSignals: DeviceRiskSignals): Boolean {
return order.totalCents > 100_00 || // > $100
riskSignals.isRooted ||
riskSignals.isEmulator ||
order.isFirstOrder ||
order.shippingAddressDiffersFromBilling
}
}
Rate Limiting UI
class AddToCartThrottler {
private val clickTimes = LinkedList<Long>()
private val maxClicksPerMinute = 10
fun canAddToCart(): Boolean {
val now = System.currentTimeMillis()
// Remove clicks older than 1 minute
while (clickTimes.isNotEmpty() && now - clickTimes.first() > 60_000) {
clickTimes.removeFirst()
}
if (clickTimes.size >= maxClicksPerMinute) return false
clickTimes.addLast(now)
return true
}
}
Key Takeaways
| Pattern | Rule |
|---|---|
| Inventory cache TTL | 5 minutes for product listing; always validate at checkout |
| Show scarcity | "Only 3 left!" increases urgency and converts better |
| Batch stock requests | One API call for the whole visible page; not N calls |
| Fraud signals to server | Send device signals; let server make fraud decisions |
| Play Integrity | Strongest client attestation; use for high-value operations |
| 3DS for high-value | Orders > $100 or unusual risk signals → require additional authentication |