TL;DR
-
Date.now()
uses the user’s system clock, not the server’s. - If the user’s device time is wrong, your countdown timers will be wrong too.
- Always treat the server as the source of truth for time-sensitive flows.
Backstory: A Payment Timer Gone Wrong
While integrating Nubank’s NuPay system, I built a simple countdown timer: 10 minutes (600 seconds) before the payment link expired. Straightforward, right?
Fetch the order, grab the insertedAt
timestamp, and compare it with Date.now()
.
Except my timer kept showing 11:50 minutes instead of 10:00.
The Gotcha: Date.now()
Uses the Client Clock
After double-checking my math, I tried something unusual: I manually changed my system clock.
Suddenly the timer shifted.
That’s when I realized:
Date.now()
isn’t based on server time. It’s based on the user’s system clock.
If the system time is off — even by a couple of minutes — your timer drifts. On payments, that’s a dealbreaker.
How I Fixed It
- Store a local timestamp on checkout
localStorage.setItem(`nupay_order_${orderId}`, Date.now().toString())
This works reliably on the same device.
Use it on the confirmation page
Compare the stored value toDate.now()
to calculate remaining time.Fallback to server data
If the local key doesn’t exist (e.g. user switches devices), use the backend’sinsertedAt
.
Cross-Device Reality Check
What if a user checks out on their laptop but views the order on their phone?
- LocalStorage works only on the same device.
- Server timestamps cover cross-device cases.
- Best practice: have the backend return its current time along with
insertedAt
so you can calculate relative to the server clock.
Polling the Order Status
To keep the order status fresh, I used polling (every 5 seconds):
- Simpler than WebSockets/SSE
- Stateless, reliable, easy to retry
- Perfect for a short-lived, critical flow like payments
async function pollOrderStatus(orderId: number, timeout: number, signal: AbortSignal) { const startTime = Date.now() const interval = 5000 while (true) { // stop polling if request was aborted or max timeout exceeded if (signal.aborted || Date.now() - startTime > timeout) break const { data } = await client.query({ query: GetOrder, variables: { orderId }, fetchPolicy: 'no-cache' }) // Checks if order status has changed to paid/failed/canceled/etc if (data?.order?.status !== 'pending') { window.location.reload() break } await new Promise(r => setTimeout(r, interval)) } }
Key Lesson: Don’t Trust the Client Clock
-
Date.now()
is only as correct as the user’s system settings. - Clocks can drift minutes or hours.
- Cross-device usage makes things worse.
Always prefer:
- Server timestamps as the source of truth
- Local storage as a same-device helper
- Small tolerances where exactness isn’t critical
Visualizing the Flow
User Checkout → Order Created (10 min timeout) → Store local timestamp ↓ Confirmation Page → Calculate Remaining Time → Show Countdown ↓ Start Polling (5s intervals) → Check Order Status → Update UI ↓ Status Change → Reload Page → Final State (Real-time feedback for user)
Conclusion
Most of us take Date.now()
for granted, but in time-sensitive apps like payments, it can quietly undermine your logic.
Next time you build a countdown, remember:
The client clock is not gospel. The server is your source of truth.
References
Let’s Connect
I share real-world problem-solving and debugging lessons:
GitHub | LinkedIn | Portfolio
Top comments (2)
Nice article and clear explanation! Just a heads-up: the countdown still depends on the user's clock, so if their system time changes it could be off. Using the server's time as the source of truth, possibly combined with polling or WebSocket syncing, can make it more reliable in practice.
Excellent point!