The Hidden Timezone Bug in JavaScript Date Formatting
A user's mindfulness minutes showed zero because toISOString() silently converted their local date to UTC -- returning tomorrow instead of today.
A user reported that their mindfulness minutes showed "0" in the mobile app despite completing meditation sessions on the web. The culprit was one of JavaScript's most common traps.
The Bug
const dateString = new Date().toISOString().split('T')[0];
Looks innocent -- get today's date as YYYY-MM-DD. But toISOString() converts to UTC before formatting.
A user in CST (UTC-6) at 6:00 PM on December 15th:
new Date().toISOString(); // "2025-12-16T00:00:00.000Z"
new Date().toISOString().split('T')[0]; // "2025-12-16" -- WRONG
Their local date is December 15th, but the code returns December 16th because UTC is 6 hours ahead.
The Impact
The mobile app queried the API for the wrong date. User completes meditation at 6 PM on Dec 15, web saves it to Dec 15, mobile queries for Dec 16, backend returns zero. The bug only hits users in timezones behind UTC, and only during certain hours -- making it hard to reproduce.
The Fix
function formatLocalDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
I audited the entire codebase and found 6 places using the broken pattern:
| File | Impact |
|---|---|
mindfulness.ts |
Wrong day queried |
healthkit.ts (3 places) |
Data attributed to wrong day |
MeditationScreen.tsx |
Sessions saved to wrong day |
widgetDataService.ts |
Widget shows wrong date |
All replaced with a shared formatLocalDate() utility.
When toISOString IS Right
It's not always wrong -- it's correct for timestamps (exact moments in time), server communication expecting UTC, and logging. It's wrong whenever you're extracting just the date portion to mean "today."
Takeaway
toISOString() always converts to UTC. That's by design. If you're anywhere behind UTC and it's past midnight in London, .split('T')[0] returns tomorrow. Search your codebase for .toISOString().split('T') -- you might be surprised.
Built while working on PlanPal.
Back to danielships ->