Timestamps, windows and time
Store instants (epoch seconds or UTC timestamps), not local wall-clock times. Represent every span as half-open [start, end): consecutive windows then tile time with no gaps and no double counting, and the length is just end − start. Convert to a time zone only at the edges, and pass the clock in as a dependency so tests can control it. Containment and overlap checks are O(1).
The idea in plain words
Section titled “The idea in plain words”Time in software is two different things that look alike:
- An instant: a point on the universal timeline, such as
1772953200seconds since 1970-01-01T00:00Z. Everyone on Earth agrees on it. - A wall-clock time: what a clock on a particular wall shows. That same instant is “March 8 2026, 3:00 am” in New York and “7:00 am” in London. A wall-clock time only means something together with a time zone, and some wall-clock times happen twice a year while others never happen at all (2:30 am in New York on that very morning).
Most bugs come from mixing them. Keep instants in your data and your arithmetic; turn them into wall-clock strings only when a human reads them or a business rule is defined in local terms (“the store closes at 9 pm local”).
The second habit is half-open intervals. A window [10:00, 11:00) includes 10:00 and excludes 11:00. The next window is [11:00, 12:00). An event at exactly 11:00 belongs to exactly one of them. Closed windows [10:00, 11:00] would count it twice; open ones would count it nowhere. Dijkstra argued for this convention for array ranges in 1982 for the same reason: adjacent ranges share an endpoint, and lengths subtract cleanly.
See it
Section titled “See it”Move events onto boundaries (multiples of the bucket size) and switch the interval type. With [a, b) the bucket counts always add up to the number of events. With [a, b] boundary events are counted twice; with (a, b] an event at the very start is lost.
Now the time-zone side. Each column below is exactly 30 minutes of real time apart. Watch the local clock jump forward an hour in spring (some local times never exist) and step back in autumn (some local times happen twice). The labels come from your browser’s time-zone database, live.
The chips at the bottom are the classic “tomorrow at the same time” trap: adding 24 hours of elapsed time and adding one calendar day give different answers on a DST day.
When to reach for it
Section titled “When to reach for it”| The problem says… | What to apply |
|---|---|
“valid from start until expiry”, “TTL”, “lease” |
Half-open start <= t < expiry; store absolute end, not “remaining”. |
| “per minute / per hour / per day buckets” | Tumbling buckets [k·size, (k+1)·size), bucket = floor(t / size). |
| “in the last N seconds” | A trailing window (now − N, now] or [now − N, now); ask which. |
| “timestamps never decrease” / “may arrive out of order” | Decide what late data does: reject, accept within a grace period, or re-bucket. |
| “at 9 am in the customer’s time zone”, “daily at midnight local” | Store the zone id (America/New_York), compute instants per occurrence. |
| “expires after 30 seconds” in code you must test | Inject a clock; never sleep in tests. |
How it works
Section titled “How it works”Intervals and buckets
Section titled “Intervals and buckets”- Contains:
start <= t < end. Start belongs, end doesn’t. - Overlap length:
max(0, min(end₁, end₂) − max(start₁, start₂)). Touching intervals overlap by 0. - Bucket: the bucket holding
tstarts atfloor(t / size) · size. Use real floor division: C#/and JSMath.truncround toward zero, which putst = −1in the wrong bucket.
def contains(start: int, end: int, t: int) -> bool: """1. Half-open [start, end): the start belongs, the end does not.""" return start <= t < end
def overlap(a: tuple[int, int], b: tuple[int, int]) -> int: """2. Length shared by two half-open intervals; 0 if they only touch.""" return max(0, min(a[1], b[1]) - max(a[0], b[0]))
def bucket_start(t: int, size: int) -> int: """3. Tumbling bucket [k*size, (k+1)*size) holding t. Python's // floors, even for t < 0.""" return (t // size) * size
if __name__ == "__main__": # Back-to-back windows share a boundary but never an instant. assert contains(0, 60, 59) and not contains(0, 60, 60) and contains(60, 120, 60) assert overlap((0, 60), (60, 120)) == 0 # touching is not overlapping assert overlap((10, 50), (40, 90)) == 10 assert overlap((0, 100), (20, 30)) == 10 # nested assert bucket_start(119, 60) == 60 and bucket_start(120, 60) == 120 assert bucket_start(-1, 60) == -60 # floor, not truncation toward zero # Lengths add up with no off-by-one: [0,60) + [60,120) is exactly [0,120). assert (60 - 0) + (120 - 60) == 120 - 0 print("ok")import assert from "node:assert/strict";
/** 1. Half-open [start, end): the start belongs, the end does not. */export const contains = (start: number, end: number, t: number): boolean => start <= t && t < end;
/** 2. Length shared by two half-open intervals; 0 if they only touch. */export function overlap(a: [number, number], b: [number, number]): number { return Math.max(0, Math.min(a[1], b[1]) - Math.max(a[0], b[0]));}
/** 3. Tumbling bucket holding t. Math.floor, not Math.trunc or `| 0`, so t < 0 works. */export const bucketStart = (t: number, size: number): number => Math.floor(t / size) * size;
assert.ok(contains(0, 60, 59) && !contains(0, 60, 60) && contains(60, 120, 60));assert.equal(overlap([0, 60], [60, 120]), 0); // touching is not overlappingassert.equal(overlap([10, 50], [40, 90]), 10);assert.equal(overlap([0, 100], [20, 30]), 10); // nestedassert.equal(bucketStart(119, 60), 60);assert.equal(bucketStart(120, 60), 120);assert.equal(bucketStart(-1, 60), -60); // Math.trunc would give 0 (and -0 * 60 is -0)console.log("ok");if (!(Iv.Contains(0, 60, 59) && !Iv.Contains(0, 60, 60) && Iv.Contains(60, 120, 60))) throw new Exception("half-open");if (Iv.Overlap((0, 60), (60, 120)) != 0) throw new Exception("touching is not overlapping");if (Iv.Overlap((10, 50), (40, 90)) != 10) throw new Exception("partial");if (Iv.Overlap((0, 100), (20, 30)) != 10) throw new Exception("nested");if (Iv.BucketStart(119, 60) != 60 || Iv.BucketStart(120, 60) != 120) throw new Exception("bucket");if (Iv.BucketStart(-1, 60) != -60) throw new Exception("floor for negatives");if (-1 / 60 != 0) throw new Exception("C# integer division truncates toward zero");Console.WriteLine("ok");
static class Iv{ // 1. Half-open [start, end): the start belongs, the end does not. public static bool Contains(long start, long end, long t) => start <= t && t < end;
// 2. Length shared by two half-open intervals; 0 if they only touch. public static long Overlap((long S, long E) a, (long S, long E) b) => Math.Max(0, Math.Min(a.E, b.E) - Math.Max(a.S, b.S));
// 3. Tumbling bucket holding t. `/` truncates toward zero in C#, so floor it by hand. public static long BucketStart(long t, long size) { long q = t / size; if (t % size != 0 && (t < 0) != (size < 0)) q--; // round the quotient down return q * size; }}An injected clock
Section titled “An injected clock”Code that calls now() directly is hard to test: the test has to sleep, and it’s flaky near boundaries. Pass the clock in.
- The object reads the clock once at creation and stores an absolute end instant.
- In tests, pass a fake clock you advance by hand. Tests become instant and deterministic.
- Test the exact boundary: one tick before the end (alive) and exactly at the end (expired).
import timefrom typing import Callable
Clock = Callable[[], float] # seconds; time.monotonic in production
class Deadline: """Expires `seconds` after creation. Takes the clock as a parameter, never calls time.* itself."""
def __init__(self, seconds: float, clock: Clock = time.monotonic) -> None: self._clock = clock self._end = clock() + seconds # 1. read the clock once, store an absolute end
def remaining(self) -> float: return max(0.0, self._end - self._clock())
def expired(self) -> bool: return self._clock() >= self._end # 2. half-open: alive on [start, end)
class FakeClock: """2. A test clock you move by hand: deterministic, instant, no sleep()."""
def __init__(self, t: float = 0.0) -> None: self.t = t
def __call__(self) -> float: return self.t
def advance(self, dt: float) -> None: self.t += dt
if __name__ == "__main__": clk = FakeClock(1_000.0) d = Deadline(30, clk) assert not d.expired() and d.remaining() == 30 clk.advance(29.5) assert not d.expired() clk.advance(0.5) # 3. exactly at the end: expired assert d.expired() and d.remaining() == 0 assert not Deadline(5).expired() # the real clock still works print("ok")import assert from "node:assert/strict";
/** Milliseconds; performance.now() (monotonic) in production, not Date.now(). */export interface Clock { now(): number }export const systemClock: Clock = { now: () => performance.now() };
/** Expires `ms` after creation. Takes the clock as a parameter, never reads time itself. */export class Deadline { private readonly end: number; constructor(ms: number, private readonly clock: Clock = systemClock) { this.end = clock.now() + ms; // 1. read the clock once, store an absolute end } remaining(): number { return Math.max(0, this.end - this.clock.now()); } expired(): boolean { return this.clock.now() >= this.end; } // half-open: alive on [start, end)}
/** 2. A test clock you move by hand: deterministic, instant, no setTimeout. */export class FakeClock implements Clock { constructor(private t = 0) {} now(): number { return this.t; } advance(ms: number): void { this.t += ms; }}
const clk = new FakeClock(1_000_000);const d = new Deadline(30_000, clk);assert.ok(!d.expired());assert.equal(d.remaining(), 30_000);clk.advance(29_999);assert.ok(!d.expired());clk.advance(1); // 3. exactly at the end: expiredassert.ok(d.expired());assert.equal(d.remaining(), 0);assert.ok(!new Deadline(5_000).expired()); // the real clock still worksconsole.log("ok");// .NET 8+ ships the abstraction: System.TimeProvider. Tests can use// Microsoft.Extensions.Time.Testing.FakeTimeProvider; a hand-rolled one is shown here.var clk = new ManualTime(new DateTimeOffset(2026, 3, 8, 6, 0, 0, TimeSpan.Zero));var d = new Deadline(TimeSpan.FromSeconds(30), clk);if (d.Expired || d.Remaining != TimeSpan.FromSeconds(30)) throw new Exception("fresh");clk.Advance(TimeSpan.FromSeconds(29.5));if (d.Expired) throw new Exception("not yet");clk.Advance(TimeSpan.FromSeconds(0.5)); // 3. exactly at the end: expiredif (!d.Expired || d.Remaining != TimeSpan.Zero) throw new Exception("expired at end");if (new Deadline(TimeSpan.FromSeconds(5)).Expired) throw new Exception("real clock");Console.WriteLine("ok");
/// Expires a fixed time after creation. Takes the clock as a dependency, never calls DateTime.Now.class Deadline(TimeSpan span, TimeProvider? clock = null){ readonly TimeProvider _clock = clock ?? TimeProvider.System; readonly DateTimeOffset _end = (clock ?? TimeProvider.System).GetUtcNow() + span; // 1. absolute end
public TimeSpan Remaining => _end - _clock.GetUtcNow() is var r && r > TimeSpan.Zero ? r : TimeSpan.Zero; public bool Expired => _clock.GetUtcNow() >= _end; // half-open: alive on [start, end)}
/// 2. A test clock you move by hand: deterministic, instant, no Thread.Sleep.class ManualTime(DateTimeOffset start) : TimeProvider{ DateTimeOffset _now = start; public override DateTimeOffset GetUtcNow() => _now; public void Advance(TimeSpan dt) => _now += dt;}Per-language toolkit
Section titled “Per-language toolkit”| Python | TypeScript / JS | C# / .NET | |
|---|---|---|---|
| Current instant | datetime.now(timezone.utc), time.time() |
Date.now() (ms) |
DateTimeOffset.UtcNow, TimeProvider.System.GetUtcNow() |
| Elapsed time | time.monotonic() |
performance.now() |
Stopwatch, TimeProvider.GetElapsedTime |
| Time zones | zoneinfo.ZoneInfo("America/New_York") (3.9+) |
Intl.DateTimeFormat({ timeZone }); Temporal.ZonedDateTime where available |
TimeZoneInfo.FindSystemTimeZoneById (IANA ids work cross-platform since .NET 6) |
| Ambiguous / missing local times | fold attribute (PEP 495) |
Temporal’s disambiguation option |
TimeZoneInfo.IsAmbiguousTime, IsInvalidTime |
| Test clock | pass a callable | pass a { now() } object |
TimeProvider + FakeTimeProvider (package Microsoft.Extensions.TimeProvider.Testing) |
Language-specific traps worth knowing by heart:
- Python: a naive
datetime(notzinfo) is not an instant. Comparing naive with aware raisesTypeError;datetime.utcnow()returns a naive value and is deprecated since 3.12. Use aware datetimes everywhere. - JavaScript:
Datemonths are 0-based (new Date(2026, 2, 8)is March 8).Date.parse("2026-03-08")is UTC midnight, butDate.parse("2026-03-08T10:00")(noZ) is local time.Temporalfixes most of this; it’s shipping in newer browsers but not in Node 22, so check your runtime. - C#:
DateTime.Kindcan beUnspecified, which silently becomes local or UTC depending on the call. PreferDateTimeOffsetfor instants.
What it costs
Section titled “What it costs”| Operation | Time |
|---|---|
| contains / overlap / bucket of one timestamp | O(1) |
| Count events in a window over a sorted list | O(log n) with two binary searches |
| Merge or intersect n intervals | O(n log n) to sort, O(n) to sweep; see intervals |
| Local ↔ instant conversion | O(log transitions) lookup in the zone’s rule table; effectively O(1) |
The real cost of time bugs isn’t CPU: it’s the event counted twice in a billing report, the coupon that expired a second early, the cron job that ran twice at 1:30 am in November.
Variations you’ll meet
Section titled “Variations you’ll meet”- Tumbling vs sliding vs session windows. Tumbling: fixed, non-overlapping buckets. Sliding: a window that moves with each event or each query. Session: windows that close after a gap of inactivity. See streaming.
- Event time vs processing time. When did it happen vs when did we see it. Late events need a policy: drop, accept within a grace period, or correct a past bucket.
- Inclusive end dates from humans. “Valid through March 31” means
[Mar 1, Apr 1)in code. Convert at the boundary and say so. - Calendar arithmetic. “Add one month” to January 31 has no single right answer (Feb 28? Mar 3?). Ask, and use the library’s rule explicitly.
- Leap seconds. Unix time ignores them; every day is exactly 86,400 seconds. In interviews, say you’re assuming that.
Pitfalls
Section titled “Pitfalls”- Closed intervals that double count at boundaries, or
<where<=was needed at the start. - Storing local times without an offset or zone. Twice a year you can’t tell which instant you meant.
- Adding 86,400 seconds to mean “tomorrow” in a zone with DST.
- Truncating division for bucket indices on negative timestamps.
- Mixing units: JS milliseconds vs Unix seconds vs .NET ticks (100 ns). Name variables
ts_ms,ts_s. - Reading the clock several times in one operation, so checks disagree with each other. Read once, pass the value down.
- Storing “remaining TTL” instead of an absolute expiry: it goes stale the moment you store it.
In the interview
Section titled “In the interview”- Ask: Are timestamps seconds or milliseconds? Integers? Can they decrease or arrive out of order? Is the window end inclusive? Is “a day” UTC or the user’s zone?
- Say your convention up front: “I’ll treat every range as half-open,
start <= t < end, so adjacent windows don’t overlap.” - If the API takes
tsas a parameter, say why that’s good: it’s an injected clock, the code is deterministic and replayable. - Test the boundary instants explicitly:
end − 1,end,start. - Follow-ups to expect: late or out-of-order events, per-user time zones, expiry with a grace period, scaling a windowed counter (see rate limiting).
Check yourself
Section titled “Check yourself”Practice on your own
Section titled “Practice on your own”Classic exercises, not gym questions:
- Given a list of meetings as
[start, end), compute total busy time with overlaps merged. - Write
local_midnights(zone, start_date, n)that returns the UTC instants of the next n local midnights, and check one week across a DST change has a 23-hour day. - Convert human “valid from Mar 1 through Mar 31” ranges to half-open instants in a given zone.
- Unit-test a session timeout class with a fake clock: alive at
end − 1, expired atend, nosleep.
Go deeper
Section titled “Go deeper”- E. W. Dijkstra, Why numbering should start at zero (EWD831): the argument for half-open ranges.
- Python docs,
zoneinfoand PEP 495 (local time disambiguation,fold). - Microsoft Learn, TimeProvider: the .NET 8 clock abstraction and
FakeTimeProvider. - TC39, Temporal documentation: instants vs zoned date-times and DST disambiguation, explained clearly even if you never use the API.