Yashveer Singh
Connect
<- All posts
SaaS Architecture and Scaling12 min read

Time Zones, Locales, and Currencies: The Three Horsemen of SaaS Apocalypse

Time zones, locales, and currencies are the three infrastructure concerns that look solved until your first international customer logs in. Each one has a correct pattern and a dozen incorrect patterns that compound over years. I've seen teams lose months to retrofitting these after the fact. The right design stores UTC, renders locally, stores amounts in minor units, delegates tax, and separates formatting from logic.

Written by Yashveer Singh, founder of Yashveer Labs.

What you actually need to know

  • Store all timestamps in UTC. Convert at the rendering layer using the user's IANA time zone identifier.
  • Store monetary amounts as integers in the currency's minor unit. Never float.
  • Separate locale-specific formatting from business logic. The format belongs in the view, not the model.
  • Delegate tax calculation to a managed service. The rules change faster than you will ship updates.
  • In my experience, teams that defer these decisions past the first international customer spend three to six weeks cleaning up data corruption they could have prevented in three days.
ConcernWrong approachRight approachTools
TimestampsStore in local time or with UTC offsetStore UTC, convert on renderdate-fns-tz, Luxon, Temporal API
Currency amountsStore as float or stringStore as integer minor unitsDinero.js, custom Money type
Locale formattingHardcoded format stringsIntl API with user localeIntl.NumberFormat, Intl.DateTimeFormat
Tax calculationHand-rolled rules engineManaged tax serviceStripe Tax, TaxJar, Avalara
Currency conversionConvert at storage timeStore rate at transaction timeFixer, Open Exchange Rates

The core argument

I have worked on three SaaS products that launched internationally without a clean time zone strategy. Every single one had a data corruption incident within six months. The pattern is the same. The team stored timestamps in their local time zone, or mixed UTC with offset-aware values, or let the ORM quietly convert UTC to local before writing. The database ends up with a mix of formats. Reports disagree. Scheduled jobs fire at the wrong time in summer because someone forgot that UTC+5:30 does not observe daylight saving. The audit log timestamps are wrong.

Currency is the same story with a different disaster. A team stores prices as floats. The application rounds correctly at display time. The billing system rounds slightly differently. After ten thousand invoices, the totals in the reporting database are a few dollars off from the totals in the billing system. Nobody notices until a financial audit or an enterprise customer runs their own reconciliation.

Locales are the quietest failure. The application formats dates as MM/DD/YYYY. A German customer sees the 3rd of October and reads it as the 10th of March. A financial user in a German locale sees 1,234.56 and reads it as a different number from what an American user sees with the same string. These bugs are usually reported as user confusion rather than bugs, so they do not get fixed.

The good news is that all three have clean solutions. The patterns are well-established. The libraries exist. The managed services exist. The cost of doing it right from day one is one week of careful architecture work. The cost of doing it wrong is months of data correction under production pressure.

How each one actually breaks

Time zones

The specific failure modes are worth naming. Storing timestamps with an offset like 2024-03-15 14:30:00+05:30 looks fine until the offset changes. The offset at the moment of storage is frozen. The user's actual time zone might have changed the offset. UTC avoids this entirely because UTC never changes.

Scheduled jobs are the most common victim. A job configured to run at 9am in the user's time zone needs the IANA identifier to recalculate the correct UTC time each day. Store America/New_York, not -05:00. The offset changes twice a year. The IANA name does not.

The other failure mode is the ORM silently converting. Postgres and MySQL both have timezone awareness at the column level. An application that connects from a server with a non-UTC system clock can write corrupted timestamps to a UTC column without throwing an error. Set the database server, the application server, and the ORM connection to UTC explicitly.

Currencies

Floats fail because IEEE 754 floating point arithmetic is not decimal arithmetic. The number 0.1 cannot be represented exactly in binary floating point. Add enough 0.1s together and you get something like 0.30000000000000004. For a display value, this rounds away. For a billing total, it accumulates.

The integer minor unit pattern is simple. One hundred dollars is stored as 10000. Ten dollars and fifty cents is stored as 1050. Arithmetic is exact. Division for display is the only operation that requires care, and it happens only at render time with explicit rounding rules.

Zero-subunit currencies like Japanese yen are stored as the face value integer. A price of 1500 yen is stored as 1500. No conversion needed.

Locales

The Intl API is available in every modern browser and in Node.js. There is no good reason to write a date or number format string by hand. Pass the locale and let the API do the work.

The edge case to handle is the locale preference hierarchy. The user's stored preference beats the browser's Accept-Language header, which beats the IP geolocation, which beats the application default. Store the preference explicitly after the user sets it. Do not re-derive it on every request from a header that can change.

What it requires

TaskEngineering effortOngoing maintenance
UTC-everywhere database migrationOne to three daysNear zero
IANA time zone storage and render conversionOne to two daysNear zero
Integer minor unit currency storageOne to three days if retrofittingNear zero
Intl API locale formatting layerHalf a day to one dayNear zero
Managed tax service integrationOne to two daysVendor handles rule changes
Exchange rate storage at transaction timeOne dayNear zero

Features to demand from the implementation

  • UTC-only storage with explicit IANA time zone identifier per user, not per session.
  • An integer money type in the data model with a currency code stored alongside the amount.
  • A rendering layer that never formats a date or number without an explicit locale argument.
  • Managed tax service integration with fallback handling for API outages.
  • Exchange rate recording at transaction creation time, not at report time.
  • Integration tests that run in a non-local time zone to catch implicit conversions.
  • A locale preview tool in the admin panel so support staff can see what a user sees.

Expert opinion

These three concerns share a property that makes them dangerous: they work fine in development and fail in production in ways that are hard to reproduce. The developer runs on a laptop in one time zone, tests with one currency, and never thinks about German number formatting. The bug ships. The international customer finds it. The team learns an expensive lesson. The fix is to treat UTC, minor unit currency, and locale-first formatting as non-negotiable defaults from day one, the same way you treat input validation.

>

Yashveer Singh, founder of Yashveer Labs

How this played out on a real project

A client SaaS had been running for eighteen months with timestamp storage in the application server's local time zone. The server was in UTC+5:30. When the first European customer signed up and set their time zone to Europe/Berlin, their scheduled reports started arriving at the wrong time, and their audit log showed activity timestamps that were off by five and a half hours.

The migration was three days of work. We added an explicit UTC conversion at the ORM layer, updated the existing timestamps in the database with a one-time script, and added integration tests that ran with the server clock set to UTC and verified that user-facing times converted correctly. The fix was not hard. The diagnosis took two days because the team had not expected a time zone problem.

The currency issue was discovered in the same audit. The product stored subscription amounts as floats. The discrepancy was less than a cent per transaction but visible in aggregate reporting. We migrated to integer minor units, added a Money value object to the domain model, and the reconciliation reports matched from the first run. For more on the related data integrity work, see the outbox pattern a SaaS reliability cheat code and why your SaaS should treat its database like a product.

Common mistakes teams make

  1. Storing timestamps with a UTC offset instead of an IANA time zone identifier. The offset is stale the moment daylight saving time changes.
  2. Storing monetary amounts as floats. The rounding errors are invisible in development and visible in production reconciliation.
  3. Hardcoding date and number format strings. The format that works for one locale is wrong for another.
  4. Writing a custom tax calculation engine. Tax rules across jurisdictions change faster than a small team can maintain.
  5. Converting currencies at storage time. The exchange rate at storage is not the rate at transaction time.
  6. Inferring the user's locale from IP address alone. IP geolocation is approximate and changes. Explicit user preference is exact.
  7. Skipping integration tests in non-local time zones. The bug only appears when the system time and the user time zone are different.
  8. Treating zero-subunit currencies like yen the same as decimal currencies. One yen is 1, not 100.

A two week plan to get this right

  1. Day one. Audit the existing schema. Find every timestamp column and every currency column. Document the current format.
  2. Days two and three. Write the migration for timestamp columns to UTC. Write integration tests for the before and after state.
  3. Day four. Run the timestamp migration on a staging copy. Verify the test suite passes with the server clock set to UTC.
  4. Day five. Add the IANA time zone identifier column to the user table. Write the render conversion layer.
  5. Days six and seven. Audit the currency columns. Write the migration to integer minor units. Add a Money value object.
  6. Day eight. Integrate the Intl API for all date and number formatting. Remove hardcoded format strings.
  7. Days nine and ten. Integrate a managed tax service. Test with sample transactions in multiple jurisdictions.
  8. Days eleven and twelve. Add exchange rate recording to the transaction model. Verify reporting uses stored rates.
  9. Day thirteen. Load test the time zone conversion layer under read-heavy conditions.
  10. Day fourteen. Document the patterns. Add lint rules to prevent future regressions.

For deeper reading on the architecture decisions that surround this work, the stateless API building backends that scale horizontally covers the server design context, and zero downtime database migrations a step-by-step guide covers the migration execution pattern.

FAQ

Frequently asked

Author

My approach to this kind of work

I approach this kind of work the way I would want someone to approach a system I depended on. With care, with rigor, with a sense that the next person who touches it should be able to understand it without my help. Yashveer Singh, founder of Yashveer Labs. That is the standard. If it is the standard you are looking for, I am the engineer to hire.

Related reading