Engineering

kotlinx-datetime has no Calendar, and the timezone database ships per platform

Instant is unambiguous. LocalDateTime is not. The same arithmetic can disagree across targets.

MobileByteSensei 9 min read
kmpdatetimearchitecture

TL;DR

kotlinx-datetime deliberately does not give you a Calendar. It gives you Instant (a point on the timeline, unambiguous), LocalDateTime (wall-clock fields, meaningless without a zone), and TimeZone (the thing that converts between them).

That split is the library teaching you something java.util.Calendar let you ignore: most date bugs are a LocalDateTime being treated as a moment. Keep instants for anything you store, compare, or sort; use local types only at the edges where a human reads them.

Then the multiplatform part: the timezone database is supplied per platform — the JVM's, the system's on Darwin, a bundled copy on wasm. When those versions differ, the same conversion can return different answers on different targets, for a narrow and genuinely important set of dates.

The case this post is about

A habit tracker with a daily streak. The rule is ordinary: a streak continues if the user logged something "yesterday" in their own timezone.

// commonMain — the first version
fun streakContinues(lastLog: Instant, now: Instant, zone: TimeZone): Boolean {
    val lastDay = lastLog.toLocalDateTime(zone).date
    val today   = now.toLocalDateTime(zone).date
    return lastDay == today.minus(1, DateTimeUnit.DAY) || lastDay == today
}

Correct-looking, and it is nearly right. It passed every test we wrote, because every test we wrote used UTC.

The symptom

Two bug reports a month apart, which turned out to be the same shape.

A streak lost overnight, for a user in São Paulo. Logged at 23:40, logged again at 00:20 the next day, streak reset to zero.

A streak lost in Auckland, on a different date. Same complaint, and the dates had nothing in common except that both were in the week a DST transition happened in that region.

The reproduction is uncomfortably simple once you have the right zone:

val zone = TimeZone.of("America/Sao_Paulo")
// the instant corresponding to 00:30 local, the morning after a spring-forward
val now = Instant.parse("2026-10-18T03:30:00Z")
now.toLocalDateTime(zone).date      // 2026-10-18
// and the day before, computed naively:
now.toLocalDateTime(zone).date.minus(1, DateTimeUnit.DAY)

Then a third report that was genuinely different, and is the reason this post is about multiplatform and not just about time: a user whose streak was correct on their phone and wrong on the web dashboard, on the same account, at the same moment.

Why the obvious fix failed

"Add 24 hours instead of subtracting a day." This is the classic wrong fix and it is worth naming precisely: a calendar day is not 24 hours. On a DST transition it is 23 or 25. Arithmetic in hours answers a different question from arithmetic in days, and for "did they log yesterday" the day is the right unit.

"Store everything in UTC and compare UTC dates." We did store instants in UTC, which is correct. Comparing UTC dates is not — a user in Auckland logging at 10am local is logging on the previous UTC date, so their streak would break every single day.

"Use the device's default timezone." The device's zone is the right default and the wrong source of truth. A user who travels gets their streak recomputed under a new zone, and the same account on a laptop in another country disagrees with the phone. Which is exactly the third bug report.

"The library's minus must be buggy." It is not. LocalDate.minus(1, DAY) on a date is exactly right; the bug was that I was doing date arithmetic and then implicitly assuming the result corresponded to a moment. The library had given me two types precisely so I would notice, and I had flattened them back together.

The actual mechanism

Three things, and the third is the multiplatform one.

Instant and LocalDateTime are different kinds of thing. An Instant is a count from an epoch — the same value everywhere, orderable, subtractable. A LocalDateTime is a tuple of calendar fields. "2026-10-18T00:30" is not a moment: in São Paulo on a spring-forward night it may not exist at all, and on a fall-back night it happens twice.

Conversion needs a zone, and the zone makes it lossy in both directions. Instant → LocalDateTime is always well-defined. LocalDateTime → Instant is not: it can have zero solutions (a skipped hour) or two (a repeated hour). kotlinx-datetime makes you pass the zone precisely so this is visible, and the library resolves the ambiguous cases by documented rule rather than by accident.

The timezone database is a per-platform dependency. The rules — when a region changes offset, and by how much — live in the IANA tzdb, which is updated several times a year as governments change their minds. On the JVM you get the JDK's bundled copy. On Darwin, the system's. On wasm/JS, a copy bundled with your build. These versions are not the same, and they differ exactly on the regions that changed recently.

Instant as a single unambiguous point versus LocalDateTime as calendar fields that may name no moment or two moments, with the conversion between them requiring a zone — and beneath it the timezone database supplied separately by the JVM, by the Darwin system, and by a bundled copy on wasm, so a region whose rules changed recently can resolve differently per target

So:

Instant is portable. LocalDateTime is a rendering of an instant under a zone, and the zone rules come from a database your platform supplies. Any logic that routes through local types inherits a per-platform dependency you did not declare.

Brazil is the pointed example: it abolished DST in 2019, so the answer for a 2019 São Paulo date depends on whether the tzdb in use predates that change. A device on an old OS and a server on a current JDK genuinely disagree, and both are faithfully applying the rules they have.

The fix

Three changes: store instants, do day arithmetic in an explicit zone, and make the user's zone data rather than an environment reading.

The streak rule, expressed as day boundaries rather than date subtraction:

// commonMain
fun streakContinues(lastLog: Instant, now: Instant, zone: TimeZone): Boolean {
    val today     = now.toLocalDateTime(zone).date
    val yesterday = today.minus(1, DateTimeUnit.DAY)

    // The instant at which "yesterday" began, in THIS zone. atStartOfDayIn resolves
    // a skipped midnight by documented rule — which is the case that bit us.
    val windowStart = yesterday.atStartOfDayIn(zone)
    return lastLog >= windowStart
}

The important line is atStartOfDayIn(zone). It exists because "midnight" is not guaranteed to be a real local time, and it is the function that handles the night my original code got wrong. The comparison is then between two Instants, which is total and unambiguous.

The user's timezone is stored, not sensed:

// commonMain — part of the user record, synced across devices
@Serializable
data class UserPrefs(val timeZoneId: String = "UTC")

fun UserPrefs.zone(): TimeZone =
    runCatching { TimeZone.of(timeZoneId) }.getOrDefault(TimeZone.UTC)

Seeded from the device on first run, changed only when the user changes it. This is what makes the phone and the web dashboard agree, and it is a product decision as much as a technical one: a streak should be evaluated in the timezone the user considers theirs, not the one they happen to be standing in.

The runCatching is not defensive noise. A stored zone id can become invalid — a region is renamed, or the stored string came from an older tzdb than the one now reading it — and a crash on startup for a rare zone is a worse outcome than falling back with a logged warning.

Pin the tzdb where you can, and know you cannot everywhere. On JVM targets you can depend on a specific tzdb artifact. On Darwin you get the system's. The honest conclusion is not "make them identical" — it is keep zone-dependent logic small and on the server where the answer must be authoritative. A streak displayed per-device can tolerate a rare disagreement; a streak that awards something cannot, and that computation belongs in one place.

How we proved it

Three separate claims, three measurements.

That the original rule failed on transitions. A parameterised test over real transition dates in several zones — this is the test that should have existed:

// commonTest
private val CASES = listOf(
    "America/Sao_Paulo" to "2026-10-18T03:30:00Z",
    "Pacific/Auckland"  to "2026-09-27T14:30:00Z",
    "Europe/Dublin"     to "2026-10-25T01:30:00Z",   // fall back — the hour repeats
    "Australia/Lord_Howe" to "2026-10-04T16:45:00Z", // 30-minute DST shift
)

@Test
fun streakSurvivesTransitions() {
    CASES.forEach { (zoneId, nowIso) ->
        val zone = TimeZone.of(zoneId)
        val now = Instant.parse(nowIso)
        val loggedYesterdayEvening = now - 5.hours
        assertTrue(streakContinues(loggedYesterdayEvening, now, zone), "failed in $zoneId")
    }
}
before:  4 of 4 cases FAILED
after:   4 of 4 cases PASSED

Lord Howe is in there deliberately — a 30-minute DST shift breaks any code that assumes offsets are whole hours, and it is the cheapest way to catch that class.

That the platforms disagreed. Printing a resolved offset for an older Brazilian date, same code, four targets:

Instant.parse("2019-11-05T02:00:00Z").toLocalDateTime(TimeZone.of("America/Sao_Paulo"))

jvmTest                2019-11-04T23:00
androidUnitTest        2019-11-04T23:00
iosSimulatorArm64Test  2019-11-04T23:00
wasmJsTest             2019-11-05T00:00   ← a different tzdb vintage

One target out of four, on one historical date. That is the shape of this whole class of bug: rare, regional, and completely invisible until a user is in the affected region on the affected date.

That storing the zone fixed the cross-device case. Two clients, one account, zones forced to differ:

                         phone (America/Sao_Paulo)   web (device UTC)
before (device zone)     streak 7                    streak 6
after  (stored zone)     streak 7                    streak 7

One limit I want to state rather than imply away. I did not reconcile the tzdb versions — I verified they differ and moved the authoritative computation server-side. If your product needs identical client-side historical conversions on every target, that is a harder problem than this post solves, and it starts with bundling one tzdb everywhere rather than inheriting four.

What this generalizes to

A type that refuses to be convenient is usually telling you something. kotlinx-datetime not shipping a Calendar is a design decision, and the friction of passing a zone everywhere is the library making a real distinction visible. When an API seems to be making you work, check whether the work is the point.

Store the unambiguous form; render the local one. Instants in the database, local types at the UI boundary. This is the same rule as storing bytes and rendering text, or storing minor currency units and rendering a formatted amount — keep the canonical form canonical.

Environment readings are not user data. Device timezone, device locale, device clock: all reasonable defaults and all wrong as a source of truth for anything the user expects to be stable across their devices. If two clients must agree, the value has to be stored.

Reference data has a version, and the version is a dependency. The tzdb, a currency table, a postal-code list, an emoji set — each ships per platform, updates on someone else's schedule, and produces defensible disagreement. Any logic that reads one has a dependency it probably has not declared.

Keep reading

This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS