Blog · · 11 min
A countdown timer that is honest
A countdown is honest when the deadline comes from the server, is identical for every visitor, and something real happens when it passes. A deadline stored in localStorage restarts per visitor, which makes the scarcity claim false. The code that matters is the endpoint that serves the deadline, a hook that recomputes from a target instead of decrementing, and an expired state that is not a bug.
By uxgen
Three conditions. The deadline comes from the server, it is the same for every visitor, and something real changes when it passes. Meet all three and a countdown is a fact about your shop. Miss any one and it is a fabricated deadline, which Annex I of Directive 2005/29/EC reaches under the practices considered unfair in all circumstances: point 7 covers falsely stating that a product will be available only for a very limited time in order to elicit an immediate decision.
The interesting part is not how to count seconds. It is where the deadline lives, and what the component does at zero.
What makes a timer dishonest?
The single most common implementation, produced almost every time a coding agent is asked for an urgency timer:
// The pattern to recognise and delete.
let ends = localStorage.getItem('offerEnds')
if (!ends) {
ends = Date.now() + 20 * 60 * 1000 // twenty minutes from *this* visitor
localStorage.setItem('offerEnds', ends)
}
That deadline is manufactured at the moment of the visit. Two people looking at the same page see two different ends. Clear the storage, open a private window, arrive from a different phone, and the sale that was ending in four minutes is ending in twenty again. The claim was not true when it was made, and the mechanism that makes it false is the same mechanism that makes it convenient.
The tell is easy to state: if the deadline is created client-side, there is no deadline. There is only a stopwatch started by the arrival of a customer, wearing the costume of a deadline.
Everything that follows is about removing that possibility from the code, rather than promising not to use it.
Where must the deadline live?
On the server, in the same row that decides the offer. Not in a constant in the front end, and not in a config file that the campaign will outlive.
// app/api/offer/route.ts
// One deadline, one source, identical for every visitor.
export const dynamic = 'force-dynamic'
export async function GET() {
const offer = await db.offer.findFirst({ where: { slug: 'autumn', live: true } })
const now = new Date()
if (!offer || offer.endsAt <= now) {
return Response.json(
{ active: false },
{ headers: { 'cache-control': 'no-store' } },
)
}
return Response.json(
{
active: true,
endsAt: offer.endsAt.toISOString(),
// The client's clock may be wrong by minutes. Sending our own now
// lets the browser correct for it instead of showing a nonsense
// remainder to whoever has a badly set phone.
serverNow: now.toISOString(),
},
{ headers: { 'cache-control': 'no-store' } },
)
}
no-store is doing real work here. A cached response with a deadline in it is a deadline that arrives already stale, and a CDN happily serving it for ten minutes will hand a visitor a countdown that finished before it was drawn.
The clock skew correction is worth the two lines. Compute skew = Date.parse(serverNow) - Date.now() once at fetch time and add it to every subsequent reading. Without it, a device whose clock is three minutes fast displays a countdown three minutes shorter than everyone else's, which is a bug that looks exactly like dishonesty.

How do you render a countdown that does not drift?
By recomputing the remainder from the target on every tick, never by subtracting one from a stored number. A decrementing interval drifts a little on every pass, freezes when the tab goes to the background, then resumes from where it froze — so it ends up showing a remainder that is minutes wrong and, worse, wrong in the merchant's favour.
import { useEffect, useMemo, useState } from 'react'
/**
* Milliseconds left until `endsAtIso`, corrected for a wrong device clock.
* Never decrements: every tick is a fresh subtraction from the target, so a
* backgrounded tab or a suspended laptop resumes with the right figure.
*/
export function useRemaining(endsAtIso: string, skewMs = 0): number {
const target = useMemo(() => Date.parse(endsAtIso), [endsAtIso])
const read = () => Math.max(0, target - (Date.now() + skewMs))
const [left, setLeft] = useState(read)
useEffect(() => {
if (!Number.isFinite(target)) return
let id: number
const schedule = () => {
// Land on the next whole second rather than every 1000 ms, so the
// digits change when the second changes and not 40 ms after it.
const delay = 1000 - ((Date.now() + skewMs) % 1000)
id = window.setTimeout(() => {
setLeft(read())
schedule()
}, delay)
}
setLeft(read())
schedule()
return () => window.clearTimeout(id)
}, [target, skewMs])
return left
}
Two properties fall out of that shape for free. The hook is correct after the machine sleeps for an hour, because it never trusted its own count. And when endsAtIso is garbage it returns a stable zero instead of NaN:NaN:NaN, which is the failure mode of most hand-written timers.
What happens when it hits zero?
Something in the shop changes. That is the whole test, and it is the one that separates a deadline from a decoration.
If the price returns to normal, the same row that carried endsAt has to be the row the pricing reads, so that expiry is enforced rather than announced. If a stock hold is released, release it. If nothing at all changes at zero, then there was no deadline and the component should not have existed.
The component also needs a dignified end. Not a frozen 00:00:00, which reads as a broken widget, and not a silent disappearance mid-scroll that shifts the page under a reader's eyes.
export function OfferCountdown({
endsAtIso,
skewMs,
onExpired,
}: {
endsAtIso: string
skewMs: number
onExpired: () => void
}) {
const left = useRemaining(endsAtIso, skewMs)
useEffect(() => {
// Ask the server what the offer is now. Do not decide locally that the
// price went back up: the server owns that, and it may have been extended.
if (left === 0) onExpired()
}, [left, onExpired])
if (left === 0) {
return (
<p className="offer-ended" role="status">
This offer has ended. The current price is shown above.
</p>
)
}
const total = Math.floor(left / 1000)
const hh = String(Math.floor(total / 3600)).padStart(2, '0')
const mm = String(Math.floor((total % 3600) / 60)).padStart(2, '0')
const ss = String(total % 60).padStart(2, '0')
return (
<p className="offer-countdown">
{/* The digits are decoration for assistive technology: announcing them
once a second makes the page unusable with a screen reader. */}
<span aria-hidden="true">
<time dateTime={endsAtIso}>{hh}:{mm}:{ss}</time>
</span>
{/* Announced once on arrival, and once again when it matters.
aria-live is deliberately absent above; this node carries it. */}
<span className="sr-only" aria-live="polite">
{total > 3600
? `Offer ends in about ${Math.round(total / 3600)} hours`
: total > 60
? `Offer ends in about ${Math.round(total / 60)} minutes`
: 'Offer ends in under a minute'}
</span>
</p>
)
}
The accessibility line is the one that is nearly always wrong. Putting aria-live="polite" on the digits makes a screen reader read the full time every single second, indefinitely, over everything else on the page. The digits get aria-hidden, and a coarse sentence beside them carries the meaning: about two hours, about ten minutes, under a minute. It changes rarely, so it is announced rarely.
Which timers are actually true?
| Timer | Where the deadline lives | Same for every visitor | What happens at zero |
|---|---|---|---|
| Session timer written to localStorage | the visitor's browser | no, it restarts | nothing, and the claim was never true |
| Sale ends in, from a campaign row | server | yes | the price returns, enforced by the same row |
| Cart reservation on real stock | server, tied to a hold | yes | the hold is released and the cart really changes |
| Dispatch cutoff | the shop's own clock | yes | the promised date moves to the next working day |
| Countdown on a struck-through price | usually neither | usually not | two separate problems at once |
The last row deserves the warning. Wrapping a permanent struck-through figure in a timer stacks a fabricated urgency claim on top of a prior-price problem, and in the EU those are two different rules. The pricing half is in compare-at price: anchoring that is legal.
The countdown that is always true: the dispatch cutoff
There is one timer nobody has to defend, and most stores that could use it do not. Order within 3 h 12 min for dispatch today. It is true, it is checkable against the tracking number, it resets every day by itself because the world does, and it is genuinely useful information rather than pressure.
The only subtlety is whose clock it uses. Yours, not the buyer's.
/** The shop's own wall clock, whatever the buyer's device believes. */
function shopClock(now: Date, timeZone: string) {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone,
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).formatToParts(now)
const get = (t: string) => parts.find((p) => p.type === t)?.value ?? ''
return {
weekday: get('weekday'),
hour: Number(get('hour')),
minute: Number(get('minute')),
}
}
const WORKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri']
export function dispatchWindow(
now: Date,
timeZone = 'Europe/Zurich',
cutoffHour = 14,
holidays: string[] = [], // ISO dates you do not ship on
) {
const { weekday, hour, minute } = shopClock(now, timeZone)
const today = now.toISOString().slice(0, 10)
const working = WORKDAYS.includes(weekday) && !holidays.includes(today)
const minutesLeft = cutoffHour * 60 - (hour * 60 + minute)
return {
working,
minutesLeft,
dispatchesToday: working && minutesLeft > 0,
}
}
// Rendered as: "Order within 3 h 12 min for dispatch today"
// and after the cutoff, plainly: "Dispatched tomorrow morning."
The holidays argument is not decoration. A cutoff that promises same-day dispatch on a public holiday is a false statement made by a component that was technically correct, and it produces exactly the support ticket you were trying to avoid.
Sitting this line inside a sticky bar on mobile is the natural home for it, and the constraints of that bar are in sticky add-to-cart on mobile.
FAQ
Are countdown timers on ecommerce sites legal?
A countdown announcing a genuine deadline that you enforce is a factual statement and is fine. A countdown creating the impression of scarcity that does not exist runs into the unfair commercial practices rules: Annex I of Directive 2005/29/EC lists, among practices unfair in all circumstances, falsely stating that a product will be available only for a very limited time to elicit an immediate decision. The directive is transposed nationally, so check the local text before relying on this summary.
How do I stop a countdown timer from resetting on reload?
Stop creating the deadline in the browser. Serve endsAt from the same server row that governs the offer, with cache-control: no-store, and have the client only subtract. If the deadline is in localStorage there is nothing to fix in the rendering, because the problem is that the deadline never existed as a fact about the shop.
Why does my countdown drift or jump after the tab is backgrounded?
Because it decrements a stored number on an interval. Browsers throttle timers in background tabs and stop them entirely when the machine sleeps, so the count falls behind real time and then resumes from the wrong place. Recompute the remainder from the target timestamp on every tick and the problem disappears, including across sleep.
Should a countdown be announced by a screen reader?
Not every second. Hide the digits with aria-hidden and put a coarse sentence in a live region beside them, saying about two hours, about ten minutes, under a minute. A polite live region on a node that changes once a second reads the whole time indefinitely and drowns everything else on the page.
If you want these already built
uxgen.ai is an MCP server that gives Claude 168 commerce components and puts them in the store as HTML the merchant owns: nothing to uninstall, no commission on orders, $19, $29 or $59 a month. The design rule behind this article, that a constraint belongs in the code rather than in documentation, is applied throughout the public kit at github.com/kinerette/uxgen-commerce-kit, MIT-licensed and readable without an account.