Implementing Urgency/Scarcity Elements (Timer, Limited Quantity) on Website
Urgency (time pressure) and scarcity (scarcity) — classic Cialdini principles, working in e-commerce for 20 years. Problem: most implementations are either technically unreliable (timer resets on page refresh) or look obviously manipulative (counter "3 left" that never changes). Below is implementation that works honestly and technically correctly.
Countdown Timer
Reliability requirement: timer should not reset on page refresh. Cannot do this via new Date() + N minutes on each component mount.
Correct scheme:
- On first visit to promotion, create Redis record with TTL
- On subsequent visits, get remaining time from Redis
- For unauthenticated — key by
sessionIdfrom cookie
// Get or create session timer
public function getCountdown(Request $request, string $promoCode): array
{
$sessionId = $request->cookie('session_id') ?? Str::uuid()->toString();
$key = "countdown:{$promoCode}:{$sessionId}";
$ttl = Redis::ttl($key);
if ($ttl <= 0) {
$duration = 1800; // 30 minutes
Redis::setex($key, $duration, now()->addSeconds($duration)->timestamp);
$ttl = $duration;
}
return [
'ends_at' => now()->addSeconds($ttl)->toIso8601String(),
'session_id' => $sessionId,
];
}
Timer component (React):
const CountdownTimer: React.FC<{ endsAt: string }> = ({ endsAt }) => {
const [timeLeft, setTimeLeft] = useState(0);
useEffect(() => {
const target = new Date(endsAt).getTime();
const tick = () => {
const diff = Math.max(0, target - Date.now());
setTimeLeft(diff);
};
tick();
const interval = setInterval(tick, 1000);
return () => clearInterval(interval);
}, [endsAt]);
const hours = Math.floor(timeLeft / 3_600_000);
const minutes = Math.floor((timeLeft % 3_600_000) / 60_000);
const seconds = Math.floor((timeLeft % 60_000) / 1000);
if (timeLeft === 0) return <ExpiredBanner />;
return (
<div className="countdown" role="timer" aria-live="polite">
<Digit value={hours} label="h" />
<Digit value={minutes} label="m" />
<Digit value={seconds} label="s" />
</div>
);
};
Component Digit adds flip animation on value change via CSS @keyframes.
Stock Indicator
Show real stock from inventory system — best practice. If quantity ≤ N, show warning:
const StockIndicator: React.FC<{ stock: number }> = ({ stock }) => {
if (stock > 10) return null;
return (
<div className={`stock-indicator stock-indicator--${stock <= 3 ? 'critical' : 'low'}`}>
{stock <= 3
? `Only ${stock} left`
: `${stock} left — running out`}
</div>
);
};
Sync with real warehouse — if using 1C or WMS, pull via API and cache in Redis for 5 minutes:
public function getStockLevel(int $productId): int
{
return Cache::remember("stock:{$productId}", 300, function () use ($productId) {
return $this->warehouseApi->getAvailableQuantity($productId);
});
}
Important: don't show exact stock for very popular items — this creates herd behavior effect and slightly strengthens conversion.
"X People Viewing Right Now"
Counter of active users on product page — real or approximate.
Real implementation via Redis:
// On product page load
public function trackView(int $productId, string $sessionId): int
{
$key = "viewers:{$productId}";
Redis::zadd($key, time(), $sessionId);
Redis::zremrangebyscore($key, 0, time() - 300); // remove older than 5 min
Redis::expire($key, 600);
return Redis::zcard($key);
}
For real-time update — either polling every 30 seconds or Server-Sent Events:
// SSE endpoint
public function viewersStream(int $productId): StreamedResponse
{
return response()->stream(function () use ($productId) {
while (true) {
$count = $this->viewerService->getCount($productId);
echo "data: {\"viewers\": {$count}}\n\n";
ob_flush();
flush();
sleep(30);
}
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
]);
}
// On client
useEffect(() => {
const es = new EventSource(`/api/products/${productId}/viewers`);
es.onmessage = (e) => setViewers(JSON.parse(e.data).viewers);
return () => es.close();
}, [productId]);
Flash Sale
Flash sale requires atomic work with stock — no race conditions:
// Lua script in Redis for atomic decrement
$script = <<<'LUA'
local key = KEYS[1]
local current = tonumber(redis.call('GET', key) or '0')
if current <= 0 then
return -1
end
return redis.call('DECR', key)
LUA;
$remaining = Redis::eval($script, 1, "flash_sale:{$saleId}:stock");
if ($remaining < 0) {
return response()->json(['error' => 'Product out of stock'], 422);
}
"Last in Cart"
If product is in other users' carts and real stock matches or is below reserved quantity:
{isLastInCart && (
<Alert variant="warning">
This product is in other buyers' carts. Checkout to reserve it.
</Alert>
)}
Technical implementation: cart_reservations(product_id, quantity, session_id, expires_at) table. Reservation lifted after 30 minutes or on order completion.
Ethics and Anti-Patterns
Urgency elements work when honest. Patterns that harm reputation:
- Timer that resets on every page visit
- "2 left" on item with constant stock
- Viewer counter randomly generated on client
These tricks boost conversion short-term but destroy trust long-term — users notice inconsistencies.
Timeline
| Task | Time |
|---|---|
| Countdown timer (Redis + component) | 1 day |
| Stock indicator (real data) | 0.5 day |
| Viewer counter (SSE) | 1 day |
| Flash sale with Redis reservation | 1–2 days |
Basic set (timer + stock): 1.5–2 days.







