# My Laptop Was in India, My Server Was in UTC, and My Bookings Were in Yesterday

So i've been building a revamp version of Gtribe 2.0. Its an adventure tourism company. they run treks, camps, corporate outings and school programs. They also sell trekking gear. So, i'm rebuilding everything into one platform, trek booking, gear store, Razorpay payments, coupons, Whatsapp updates and an admin dashboard where the team can see how the business is doing.

Here is the stack, because it matters for this story.

![](https://cdn.hashnode.com/uploads/covers/6783639eb96085e62fc34ca1/000b20a3-9243-4996-9d92-6fb18017093c.png align="center")

That's the four place where the code runs, and it turns out they didnt all agree on what time it was.

This post covers a bug that took me longer to understand than to fix. i'll go through how i noticed, how i tracked down, then what i changed, and then i kept thinking that was my fix actually right way to do it, or where there any other options?

### The day the numbers didn't add up

Everything about Gtribe happens in India time. A trekker books at 11PM after dinner, a coupon is valid till 31st Oct, a trek on 10th oct. Nobody at Gtribe thinks in UTC.

Once the admin dashboard was mostly built, and I did what I should always do, I checked it against the raw data. I picked one day in September and counted the bookings myself straight from the database.

*   Dashboard Said: 7 bookings
    
*   I counted: 11 bookings
    

Four bookings were missing, My first worry was that payments were failing, or a webhook was silently dropping records. That would have been a much scarier bug.

But the four bookings were right in the table, paid and confirmed. they hadnt been lost. They had been counted under the wrong day.

### Debugging: what did the "missing" bookings have in common?

I pulled the four bookings out and put them next to each other. What stood out wasn't the user, the trek or the amount. It was the **time**:

![](https://cdn.hashnode.com/uploads/covers/6783639eb96085e62fc34ca1/6684cfa5-3df5-4732-962d-384eb58c4ced.png align="center")

Every one of them was made **between midnight and 5:30 AM India time**, and in the database every one of them said **24 Sept**.

That was the clue. The number 5:30 is exactly India's offset from UTC (**UTC+5:30**). Something was deciding which day a booking belonged to using UTC instead of India time.

Then the next question hit me: **why had I never seen this while developing?** I'd been testing the dashboard for weeks.

The answer was my laptop.

![](https://cdn.hashnode.com/uploads/covers/6783639eb96085e62fc34ca1/935cf6fd-b936-4467-aab9-242ef2c23a40.png align="center")

My laptop is set to India time, so every date calculation came out right on my machine. Vercel's servers run on UTC. Supabase's Postgres runs on UTC. Even my VPS, which physically sits in a **Mumbai datacentre**, was running on UTC, because that's the default for almost every cloud server image. (Run `timedatectl` on yours and check.)

A server's location tells you nothing about its timezone.

To confirm the theory, I ran the app locally while pretending to be a server:

```shell
TZ=UTC pnpm dev
```

The dashboard immediately showed the same wrong number as production. The bug was reproducible on my machine, and I knew exactly what caused it.

### The concept I had been ignoring, a moment is not a date

A database stores time as a **moment**, a single point on the global timeline:

```shell
2026-09-24T19:42:11Z
```

That `Z` means UTC. The moment is the same everywhere on Earth. When it's 7:42 PM in London, it's 1:12 AM the next day in Pune. Same instant, different clocks.

A **date** like "25 September" isn't a point. It's a **24-hour window**, and where that window starts and ends depends on where you're standing.

![](https://cdn.hashnode.com/uploads/covers/6783639eb96085e62fc34ca1/556515ed-5acd-4a07-8e23-dbd683df2c1d.png align="center")

So "what day did this booking happen?" has **no answer** until you say *where*. If your code doesn't say where, the machine running it decides for you.

Because India is 5½ hours ahead, the two calendars disagree for the first 5½ hours of **every single Indian day**:

![](https://cdn.hashnode.com/uploads/covers/6783639eb96085e62fc34ca1/8d087e73-4389-4e28-94e6-0fb872509737.png align="center")

| India time | UTC time | Date in India | Date in UTC |
| --- | --- | --- | --- |
| 25 Sept, 12:30 AM | 24 Sept, 7:00 PM | **25 Sept** | **24 Sept** |
| 25 Sept, 5:29 AM | 24 Sept, 11:59 PM | **25 Sept** | **24 Sept** |
| 25 Sept, 5:30 AM | 25 Sept, 12:00 AM | 25 Sept | 25 Sept |

Once I understood this, I stopped looking only at the dashboard. I searched the codebase for **every place a moment was being turned into a day**. I found four bugs, all with the same cause.

## The four bugs:

### **Bug 1: Revenue counted on the wrong way**

The dashboard grouped bookings by day like this:

```sql
date_trunc('day', created_at)
```

The Postgres session via Supabase runs in UTC, so this cut the timeline at **UTC midnight**, which is 5:30 AM in India. A booking at 1 AM IST on the 25th went into the 24th's bucket.

![](https://cdn.hashnode.com/uploads/covers/6783639eb96085e62fc34ca1/adab51da-1207-4537-95eb-5fed8e3937f1.png align="center")

When I checked the whole table, about **half of our test bookings** had been made between midnight and 5:30 AM. That's what happens when you test at night. "Today", "This Month" and every revenue chart were skewed.

### **Bug 2: Coupons that expired at breakfast**

When an admin picked "31 Oct" as a coupon's expiry date, my code did this:

```typescript
new Date("2026-10-31") // → 2026-10-31T00:00:00Z
```

This one genuinely surprised me. In JavaScript, a **date-only string** (`YYYY-MM-DD` with no time) is parsed as **midnight UTC**, which is **5:30 AM in India**.

![](https://cdn.hashnode.com/uploads/covers/6783639eb96085e62fc34ca1/c5e4ad92-7e88-4967-9af4-cbc8c515165b.png align="center")

A coupon "valid till 31 Oct" stopped working at 5:30 AM on 31 Oct. Anyone trying it that morning would be told it had expired.

### **Bug 3: The same date showing differently on different screens**

I had code like this copy-pasted across the app:

```typescript
new Date(iso).toLocaleDateString("en-IN")
```

I'd assumed `"en-IN"` meant "show this in Indian time". It doesn't. It only controls the **format** (day before month, and so on). The timezone still comes from whichever machine runs the code.

With Next.js, that can be two different machines for the same page:

![](https://cdn.hashnode.com/uploads/covers/6783639eb96085e62fc34ca1/a4be96e2-b870-4512-82ea-1815e40b83db.png align="center")

So a blog post could show as published on two different days depending on where it was rendered.

### **Bug 4: Treks vanishing on the morning of the trek**

Trek dates were saved as midnight UTC, and the homepage hid any event whose end time had passed. A one-day trek on 10 October "ended" at `2026-10-10T00:00:00Z`, which is **5:30 AM IST on 10 October**.

The trek would disappear from the website on the morning it was actually happening, while trekkers were probably sharing the link in their WhatsApp group.

## How I fixed it

My first instinct was "just make everything India time". I didn't do that, and I'll explain why in the next section.

Storing moments in UTC wasn't the problem. Postgres, Vercel and almost every library expect it. The problem was the places where I turned a moment into a day **without saying which day I meant**. So I adopted one rule:

> Store in UTC. Every time a moment becomes a *day*, say "in India" explicitly.

![](https://cdn.hashnode.com/uploads/covers/6783639eb96085e62fc34ca1/7c073340-38f3-4cd2-9b88-984a3effa0cd.png align="center")

**One set of helpers instead of twelve copy-pasted snippets**

I put these in the shared package so the frontend and backend both use them:

```typescript
export const INDIA_TIME_ZONE = "Asia/Kolkata";

// The first and last moment of an Indian calendar day
export function indiaDayStart(date: string) {
  return new Date(`${date}T00:00:00.000+05:30`);
}
export function indiaDayEnd(date: string) {
  return new Date(`${date}T23:59:59.999+05:30`);
}

// Display a date as India sees it, wherever the code runs
export function formatIndiaDate(value: string | Date) {
  return new Date(value).toLocaleDateString("en-IN", {
    day: "numeric",
    month: "short",
    year: "numeric",
    timeZone: INDIA_TIME_ZONE,
  });
}
```

The `+05:30` written into the string is the important part. It tells JavaScript exactly which midnight I mean, so the machine's timezone has nothing left to decide.

> 💡 **Is hardcoding** `+05:30` **safe?** For India, yes: there's no daylight saving time, so the offset never changes. If you're building for the US, UK or most of Europe, don't hardcode offsets. Their offsets change twice a year, so use a timezone-aware library (more on that below).

**What changes for each bug**

**Dashboard:** Date filters now run from India midnight to India midnight, and Postgres groups by India's calendar:

```sql
date_trunc('day', created_at AT TIME ZONE 'Asia/Kolkata')
```

`AT TIME ZONE` converts the UTC moment into India's wall-clock time *before* cutting it into days. The day that showed 7 bookings now shows 11.

**Coupons.** An expiry date now means "valid through the end of that day in India" (11:59:59 PM IST, via `indiaDayEnd()`). I also ran a one-off migration to move existing coupons onto the same rule.

Before: 31 Oct ├──05:30✗───────────────────────────────┤ After: 31 Oct ├───────────────────────────────23:59:59✓┤

**Dates on screen.** About a dozen copy-pasted formatters were replaced with `formatIndiaDate`, so a date looks the same whether Vercel or a browser renders it.

**Upcoming treks.** I stopped hiding events by date altogether. A trek stays listed until GTribe's staff mark it **completed**. That's how the business actually works: a trek is over when the team says it's over, not when a timestamp passes.

### Checking what was already right

Not everything was broken. Booking IDs like `GTB09260001` (September '26, first booking of the month) and invoice numbers (which reset with India's financial year on 1 April) had been built on India time from the start.

"It looks right" isn't proof, so I tested them with the clock forced to UTC, at exactly the moments where the two calendars disagree:

| Moment (IST) | Same moment in UTC | Expected | Result |
| --- | --- | --- | --- |
| 1 Oct, 12:01 AM | 30 Sept, 6:31 PM | New October booking series | ✅ |
| 1 Apr, 12:30 AM | 31 Mar, 7:00 PM | New financial year for invoices | ✅ |

## Was this the right way to fix it? The other options

After fixing it, I wanted to know whether I'd solved it properly or just patched it. Here are the approaches I looked at.

### Option 1: Set every server's timezone to India

This was my first instinct: set `TZ=Asia/Kolkata` on the VPS, change the Postgres timezone, and the bugs go away.

Laptop ✅ IST VPS ✅ IST Postgres ✅ IST Vercel ❓ ...

**Why I didn't do it:**

*   **I don't control every machine.** Vercel's serverless functions run in UTC, and as far as I know you can't change that by setting `TZ` there (it's a reserved variable). One environment out of four is enough to bring the bugs back.
    
*   **Pooled database connections make it fragile.** We connect to Supabase through Supavisor in transaction mode. A `SET TIME ZONE` on a session isn't reliable there because each transaction can land on a different connection. You'd have to set it at the database or role level instead.
    
*   **It hides the problem instead of fixing it.** The code would still be saying "use whatever timezone the machine has". It would just be correct by accident. A new server, a new region or a Docker image with default settings breaks it again, silently.
    

### Option 2: Store UTC, convert at the edges (what I did)

Keep every moment in UTC. Convert to India time only at the boundaries: when grouping, filtering, checking expiry or displaying.

**Pros:** works regardless of where code runs; it's the standard approach most backend engineers will recommend; it scales if GTribe ever serves users in another timezone. **Cons:** you have to remember to use the helpers. That's why centralising them matters: one place to get it right, instead of twelve places to get it wrong.

**Verdict:** this is the correct default for **moments** — things that happened at a specific instant, like a payment or a booking.

### Option 3: Use a real `date` type for things that are actually dates

Looking back, this is the one I'd consider more seriously next time.

Some things in GTribe are **not moments at all**. "The trek is on 10 October" or "this coupon is valid till 31 October" are **calendar dates**. Nobody means "10 October at 00:00:00.000 UTC".

Postgres has a `date` column type for exactly this. It stores `2026-10-10` with no time and no timezone, so there's nothing to shift:

```sql
-- a moment: when something happened
created_at   timestamptz   -- 2026-09-24 19:42:11+00

-- a calendar date: a day on India's calendar
trek_date    date          -- 2026-10-10
valid_until  date          -- 2026-10-31
```

You still need India time for one thing: knowing what **today** is when comparing (`valid_until >= today in India`). But the stored value can't drift.

**Verdict:** arguably more correct for trek dates and coupon expiries. My end-of-day-timestamp approach works fine for GTribe, but if I were designing the schema from scratch, I'd use `date` for these.

### Option 4: Use a date library (or Temporal)

Libraries like **date-fns** with its timezone add-on, **Luxon** or **Day.js** with the timezone plugin all handle timezone conversion for you, including daylight saving for countries that have it.

There's also **Temporal**, a new built-in JavaScript date API designed to fix exactly this class of bug. It has separate types for "a moment" (`Temporal.Instant`) and "a calendar date with no time" (`Temporal.PlainDate`), so you can't mix them up by accident. Browser and runtime support is still rolling out, so check compatibility or use a polyfill.

**Verdict:** for a single-timezone app with no daylight saving, my three small helpers were enough. If GTribe ever goes multi-country, I'd switch to a library rather than grow the helpers.

### Option 5: Store each user's timezone

Big global apps (calendars, airline booking) store a timezone per user and convert per person.

**Verdict:** overkill for a Pune trekking company where every customer, trek and coupon lives on India's calendar. It's worth knowing about, though.

## Summary

| Approach | Fixes the cause? | Survives new servers? | Good for GTribe? |
| --- | --- | --- | --- |
| Set server TZ to IST | ❌ hides it | ❌ | Only as a safety net |
| **Store UTC, convert at edges** | ✅ | ✅ | ✅ **what I did** |
| `date` column for calendar dates | ✅ | ✅ | ✅ better for trek/coupon dates |
| Date library / Temporal | ✅ | ✅ | When going multi-timezone |
| Per-user timezone | ✅ | ✅ | Overkill for now |

So my fix for this use case was correct, but not the only correct answer. For moments, "store UTC, convert at the edges" is right. For things that are really calendar dates, a `date` column is cleaner.

## What I learned

1.  **Moments and dates are different things.** Store moments in UTC. The instant you turn one into a *day*, pick a timezone on purpose.
    
2.  **A server's location tells you nothing about its timezone.** My VPS is in Mumbai and still ran on UTC.
    
3.  **"Works on my machine" can be a timezone bug.** My laptop was in IST; production wasn't. `TZ=UTC pnpm dev` would have caught this weeks earlier.
    
4.  `new Date("2026-10-31")` **is midnight UTC.** In India, that's 5:30 AM, which is almost never what you meant.
    
5.  `"en-IN"` **sets the format, not the timezone.** Pass `timeZone` explicitly.
    
6.  **Centralise date logic.** One set of helpers beats twelve copy-pasted snippets.
    
7.  **Test the 5½-hour window.** Midnight to 5:30 AM IST is where every one of these bugs lived.
    

*If you're building for Indian users, try this right now: search your codebase for* `new Date("` *and* `toLocaleDateString(`*, then run your app with* `TZ=UTC`*. If the numbers change, you have this bug too.*
