DEV Community

Bruno Corrêa
Bruno Corrêa

Posted on

JavaScript Countdown Gotcha: Why Date.now() Depends on the User’s Clock

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?

Countdown timer UI

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

  1. Store a local timestamp on checkout
 localStorage.setItem(`nupay_order_${orderId}`, Date.now().toString()) 
Enter fullscreen mode Exit fullscreen mode

This works reliably on the same device.

  1. Use it on the confirmation page
    Compare the stored value to Date.now() to calculate remaining time.

  2. Fallback to server data
    If the local key doesn’t exist (e.g. user switches devices), use the backend’s insertedAt.


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)) } } 
Enter fullscreen mode Exit fullscreen mode

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) 
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
gkoos profile image
Gabor Koos

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.

Collapse
 
brinobruno profile image
Bruno Corrêa

Excellent point!