Merge "tp: add documentation for most slice tables"
diff --git a/Android.bp b/Android.bp
index 99e833d..344d596 100644
--- a/Android.bp
+++ b/Android.bp
@@ -10360,8 +10360,10 @@
     name: "perfetto_src_trace_processor_stdlib_gen_amalgamated_stdlib",
     srcs: [
         "src/trace_processor/stdlib/android/battery.sql",
+        "src/trace_processor/stdlib/android/battery_stats.sql",
         "src/trace_processor/stdlib/android/binder.sql",
         "src/trace_processor/stdlib/android/monitor_contention.sql",
+        "src/trace_processor/stdlib/android/network_packets.sql",
         "src/trace_processor/stdlib/android/process_metadata.sql",
         "src/trace_processor/stdlib/android/slices.sql",
         "src/trace_processor/stdlib/android/startup/internal_startups_maxsdk28.sql",
diff --git a/BUILD b/BUILD
index c22f37c..8015a7c 100644
--- a/BUILD
+++ b/BUILD
@@ -2167,8 +2167,10 @@
     name = "src_trace_processor_stdlib_android_android",
     srcs = [
         "src/trace_processor/stdlib/android/battery.sql",
+        "src/trace_processor/stdlib/android/battery_stats.sql",
         "src/trace_processor/stdlib/android/binder.sql",
         "src/trace_processor/stdlib/android/monitor_contention.sql",
+        "src/trace_processor/stdlib/android/network_packets.sql",
         "src/trace_processor/stdlib/android/process_metadata.sql",
         "src/trace_processor/stdlib/android/slices.sql",
     ],
diff --git a/docs/contributing/perfetto-in-the-press.md b/docs/contributing/perfetto-in-the-press.md
index 108849a..4546bd6 100644
--- a/docs/contributing/perfetto-in-the-press.md
+++ b/docs/contributing/perfetto-in-the-press.md
@@ -2,6 +2,8 @@
 
 This a partial collection of the talks, blogposts, presentations, and articles that mention Perfetto.
 
+- [Google IO 2023 - What's new in Dart and Flutter](https://youtu.be/yRlwOdCK7Ho?t=798)
+- [Google IO 2023 - Debugging Jetpack Compose](https://youtu.be/Kp-aiSU8qCU?t=1092)
 - [Performance: Perfetto Traceviewer - MAD Skills](https://www.youtube.com/watch?v=phhLFicMacY)
 "On this episode of the MAD Skills series on Performance, Android Performance Engineer Carmen Jackson discusses the Perfetto traceviewer, an alternative to Android Studio for viewing system traces."
 - [Performance and optimisation on the Meta Quest Platform](https://m.facebook.com/RealityLabs/videos/performance-and-optimization-on-meta-quest-platform/488126049869673/)
diff --git a/src/profiling/perf/perf_producer.cc b/src/profiling/perf/perf_producer.cc
index 0fb7be3..fec5b6d 100644
--- a/src/profiling/perf/perf_producer.cc
+++ b/src/profiling/perf/perf_producer.cc
@@ -16,8 +16,10 @@
 
 #include "src/profiling/perf/perf_producer.h"
 
+#include <optional>
 #include <random>
 #include <utility>
+#include <vector>
 
 #include <unistd.h>
 
@@ -26,7 +28,9 @@
 
 #include "perfetto/base/logging.h"
 #include "perfetto/base/task_runner.h"
+#include "perfetto/ext/base/file_utils.h"
 #include "perfetto/ext/base/metatrace.h"
+#include "perfetto/ext/base/string_utils.h"
 #include "perfetto/ext/base/utils.h"
 #include "perfetto/ext/base/weak_ptr.h"
 #include "perfetto/ext/tracing/core/basic_types.h"
@@ -80,6 +84,35 @@
   return static_cast<size_t>(sysconf(_SC_NPROCESSORS_CONF));
 }
 
+std::vector<uint32_t> GetOnlineCpus() {
+  size_t cpu_count = NumberOfCpus();
+  if (cpu_count == 0) {
+    return {};
+  }
+
+  static constexpr char kOnlineValue[] = "1\n";
+  std::vector<uint32_t> online_cpus;
+  online_cpus.reserve(cpu_count);
+  for (uint32_t cpu = 0; cpu < cpu_count; ++cpu) {
+    std::string res;
+    base::StackString<1024> path("/sys/devices/system/cpu/cpu%u/online", cpu);
+    if (!base::ReadFile(path.c_str(), &res)) {
+      // Always consider CPU 0 to be online if the "online" file does not exist
+      // for it. There seem to be several assumptions in the kernel which make
+      // CPU 0 special so this is a pretty safe bet.
+      if (cpu != 0) {
+        return {};
+      }
+      res = kOnlineValue;
+    }
+    if (res != kOnlineValue) {
+      continue;
+    }
+    online_cpus.push_back(cpu);
+  }
+  return online_cpus;
+}
+
 int32_t ToBuiltinClock(int32_t clockid) {
   switch (clockid) {
     case CLOCK_REALTIME:
@@ -394,9 +427,14 @@
     return;
   }
 
-  size_t num_cpus = NumberOfCpus();
+  std::vector<uint32_t> online_cpus = GetOnlineCpus();
+  if (online_cpus.empty()) {
+    PERFETTO_ELOG("No online CPUs found.");
+    return;
+  }
+
   std::vector<EventReader> per_cpu_readers;
-  for (uint32_t cpu = 0; cpu < num_cpus; cpu++) {
+  for (uint32_t cpu : online_cpus) {
     std::optional<EventReader> event_reader =
         EventReader::ConfigureEvents(cpu, event_config.value());
     if (!event_reader.has_value()) {
diff --git a/src/trace_processor/importers/proto/proto_trace_parser.cc b/src/trace_processor/importers/proto/proto_trace_parser.cc
index fa1ffd6..b18bdea 100644
--- a/src/trace_processor/importers/proto/proto_trace_parser.cc
+++ b/src/trace_processor/importers/proto/proto_trace_parser.cc
@@ -357,7 +357,7 @@
     // args in arrays.
     std::stable_sort(interned.begin(), interned.end(),
                      [](const Arg& a, const Arg& b) {
-                       return a.first.raw_id() < b.second.raw_id();
+                       return a.first.raw_id() < b.first.raw_id();
                      });
 
     // Compute the correct key for each arg, possibly adding an index to
@@ -373,20 +373,16 @@
         inserter->AddArg(key, Variadic::String(it->second));
       } else {
         constexpr size_t kMaxIndexSize = 20;
-        base::StringView key_str = context_->storage->GetString(key);
+        NullTermStringView key_str = context_->storage->GetString(key);
         if (key_str.size() >= sizeof(buffer) - kMaxIndexSize) {
           PERFETTO_DLOG("Ignoring arg with unreasonbly large size");
           continue;
         }
 
-        base::StringWriter writer(buffer, sizeof(buffer));
-        writer.AppendString(key_str);
-        writer.AppendChar('[');
-        writer.AppendUnsignedInt(current_idx);
-        writer.AppendChar(']');
-
+        base::StackString<2048> array_key("%s[%u]", key_str.c_str(),
+                                          current_idx);
         StringId new_key =
-            context_->storage->InternString(writer.GetStringView());
+            context_->storage->InternString(array_key.string_view());
         inserter->AddArg(key, new_key, Variadic::String(it->second));
 
         current_idx = key == next_key ? current_idx + 1 : 0;
diff --git a/src/trace_processor/stdlib/android/BUILD.gn b/src/trace_processor/stdlib/android/BUILD.gn
index 86bdf6d..693cabc 100644
--- a/src/trace_processor/stdlib/android/BUILD.gn
+++ b/src/trace_processor/stdlib/android/BUILD.gn
@@ -18,8 +18,10 @@
   deps = [ "startup" ]
   sources = [
     "battery.sql",
+    "battery_stats.sql",
     "binder.sql",
     "monitor_contention.sql",
+    "network_packets.sql",
     "process_metadata.sql",
     "slices.sql",
   ]
diff --git a/src/trace_processor/stdlib/android/battery_stats.sql b/src/trace_processor/stdlib/android/battery_stats.sql
new file mode 100644
index 0000000..753fd61
--- /dev/null
+++ b/src/trace_processor/stdlib/android/battery_stats.sql
@@ -0,0 +1,210 @@
+--
+-- Copyright 2023 The Android Open Source Project
+--
+-- Licensed under the Apache License, Version 2.0 (the "License");
+-- you may not use this file except in compliance with the License.
+-- You may obtain a copy of the License at
+--
+--     https://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing, software
+-- distributed under the License is distributed on an "AS IS" BASIS,
+-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+-- See the License for the specific language governing permissions and
+-- limitations under the License.
+
+SELECT IMPORT('common.timestamps');
+
+-- Converts a battery_stats counter value to human readable string.
+--
+-- @arg track STRING  The counter track name (e.g. 'battery_stats.audio').
+-- @arg value LONG    The counter value.
+-- @ret STRING        The human-readable name for the counter value.
+SELECT CREATE_FUNCTION(
+  'BATTERY_STATS_COUNTER_TO_STRING(track STRING, value LONG)',
+  'STRING',
+  '
+  SELECT
+    CASE
+      WHEN ($track = "battery_stats.wifi_scan" OR
+            $track = "battery_stats.wifi_radio" OR
+            $track = "battery_stats.mobile_radio" OR
+            $track = "battery_stats.audio" OR
+            $track = "battery_stats.video" OR
+            $track = "battery_stats.camera" OR
+            $track = "battery_stats.power_save" OR
+            $track = "battery_stats.phone_in_call")
+        THEN
+          CASE $value
+            WHEN 0 THEN "inactive"
+            WHEN 1 THEN "active"
+            ELSE "unknown"
+          END
+      WHEN $track = "battery_stats.wifi"
+        THEN
+          CASE $value
+            WHEN 0 THEN "off"
+            WHEN 1 THEN "on"
+            ELSE "unknown"
+          END
+      WHEN $track = "battery_stats.phone_state"
+        THEN
+          CASE $value
+            WHEN 0 THEN "in"
+            WHEN 1 THEN "out"
+            WHEN 2 THEN "emergency"
+            WHEN 3 THEN "off"
+            ELSE "unknown"
+          END
+      WHEN ($track = "battery_stats.phone_signal_strength" OR
+            $track = "battery_stats.wifi_signal_strength")
+        THEN
+          CASE $value
+            WHEN 0 THEN "none"
+            WHEN 1 THEN "poor"
+            WHEN 2 THEN "moderate"
+            WHEN 3 THEN "good"
+            WHEN 4 THEN "great"
+            ELSE "unknown"
+          END
+      WHEN $track = "battery_stats.wifi_suppl"
+        THEN
+          CASE $value
+            WHEN 0 THEN "invalid"
+            WHEN 1 THEN "disconn"
+            WHEN 2 THEN "disabled"
+            WHEN 3 THEN "inactive"
+            WHEN 4 THEN "scanning"
+            WHEN 5 THEN "authenticating"
+            WHEN 6 THEN "associating"
+            WHEN 7 THEN "associated"
+            WHEN 8 THEN "4-way-handshake"
+            WHEN 9 THEN "group-handshake"
+            WHEN 10 THEN "completed"
+            WHEN 11 THEN "dormant"
+            WHEN 12 THEN "uninit"
+            ELSE "unknown"
+          END
+      WHEN $track = "battery_stats.data_conn"
+        THEN
+          CASE $value
+            WHEN 0 THEN "oos"
+            WHEN 1 THEN "gprs"
+            WHEN 2 THEN "edge"
+            WHEN 3 THEN "umts"
+            WHEN 4 THEN "cdma"
+            WHEN 5 THEN "evdo_0"
+            WHEN 6 THEN "evdo_A"
+            WHEN 7 THEN "1xrtt"
+            WHEN 8 THEN "hsdpa"
+            WHEN 9 THEN "hsupa"
+            WHEN 10 THEN "hspa"
+            WHEN 11 THEN "iden"
+            WHEN 12 THEN "evdo_b"
+            WHEN 13 THEN "lte"
+            WHEN 14 THEN "ehrpd"
+            WHEN 15 THEN "hspap"
+            WHEN 16 THEN "gsm"
+            WHEN 17 THEN "td_scdma"
+            WHEN 18 THEN "iwlan"
+            WHEN 19 THEN "lte_ca"
+            WHEN 20 THEN "nr"
+            WHEN 21 THEN "emngcy"
+            WHEN 22 THEN "other"
+            ELSE "unknown"
+          END
+      ELSE CAST($value AS text)
+    END
+  '
+);
+
+
+-- View of human readable battery stats counter-based states. These are recorded
+-- by BatteryStats as a bitmap where each 'category' has a unique value at any
+-- given time.
+--
+-- @column ts                  Timestamp in nanoseconds.
+-- @column dur                 The duration the state was active.
+-- @column name                The name of the counter track.
+-- @column value               The counter value as a number.
+-- @column value_name          The counter value as a human-readable string.
+CREATE VIEW android_battery_stats_state AS
+SELECT
+  ts,
+  name,
+  value,
+  BATTERY_STATS_VALUE_TO_STRING(name, value) AS value_name,
+  LEAD(ts, 1, TRACE_END()) OVER (PARTITION BY track_id ORDER BY ts) - ts AS dur
+FROM counter
+JOIN counter_track
+  ON counter.track_id = counter_track.id
+WHERE counter_track.name GLOB 'battery_stats.*';
+
+
+-- View of slices derived from battery_stats events. Battery stats records all
+-- events as instants, however some may indicate whether something started or
+-- stopped with a '+' or '-' prefix. Events such as jobs, top apps, foreground
+-- apps or long wakes include these details and allow drawing slices between
+-- instant events found in a trace.
+--
+-- For example, we may see an event like the following on 'battery_stats.top':
+--
+--     -top=10215:"com.google.android.apps.nexuslauncher"
+--
+-- This view will find the associated start ('+top') with the matching suffix
+-- (everything after the '=') to construct a slice. It computes the timestamp
+-- and duration from the events and extract the details as follows:
+--
+--     track_name='battery_stats.top'
+--     str_value='com.google.android.apps.nexuslauncher'
+--     int_value=10215
+--
+-- @column track_name          The battery stats track name.
+-- @column ts                  Timestamp in nanoseconds.
+-- @column dur                 The duration of the event.
+-- @column str_value           The string part of the event identifier.
+-- @column int_value           The integer part of the event identifier.
+CREATE VIEW android_battery_stats_event_slices AS
+WITH
+  event_markers AS (
+    SELECT
+      ts,
+      track.name AS track_name,
+      str_split(slice.name, '=', 1) AS key,
+      substr(slice.name, 1, 1) = '+' AS start
+    FROM slice
+    JOIN track
+      ON slice.track_id = track.id
+    WHERE
+      track_name GLOB 'battery_stats.*'
+      AND substr(slice.name, 1, 1) IN ('+', '-')
+  ),
+  with_neighbors AS (
+    SELECT
+      *,
+      LAG(ts) OVER (PARTITION BY track_name, key ORDER BY ts) AS last_ts,
+      LEAD(ts) OVER (PARTITION BY track_name, key ORDER BY ts) AS next_ts
+    FROM event_markers
+  ),
+  -- Note: query performance depends on the ability to push down filters on
+  -- the track_name. It would be more clear below to have two queries and union
+  -- them, but doing so prevents push down through the above window functions.
+  event_spans AS (
+    SELECT
+      track_name, key,
+      IIF(start, ts, TRACE_START()) AS ts,
+      IIF(start, next_ts, ts) AS end_ts
+    FROM with_neighbors
+    -- For the majority of events, we take the `start` event and compute the dur
+    -- based on next_ts. In the off chance we get an end event with no prior
+    -- start (matched by the second half of this where), we can create an event
+    -- starting from the beginning of the trace ending at the current event.
+    WHERE (start OR last_ts IS NULL)
+  )
+SELECT
+  ts,
+  IFNULL(end_ts-ts, -1) AS dur,
+  track_name,
+  str_split(key, '"', 1) AS str_value,
+  CAST(str_split(key, ':', 0) AS INT64) AS int_value
+FROM event_spans;
diff --git a/src/trace_processor/stdlib/android/network_packets.sql b/src/trace_processor/stdlib/android/network_packets.sql
new file mode 100644
index 0000000..a88615a
--- /dev/null
+++ b/src/trace_processor/stdlib/android/network_packets.sql
@@ -0,0 +1,52 @@
+--
+-- Copyright 2023 The Android Open Source Project
+--
+-- Licensed under the Apache License, Version 2.0 (the "License");
+-- you may not use this file except in compliance with the License.
+-- You may obtain a copy of the License at
+--
+--     https://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing, software
+-- distributed under the License is distributed on an "AS IS" BASIS,
+-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+-- See the License for the specific language governing permissions and
+-- limitations under the License.
+
+-- Android network packet events (from android.network_packets data source).
+--
+-- @column ts                  Timestamp in nanoseconds.
+-- @column dur                 Duration (non-zero only in aggregate events)
+-- @column track_name          The track name (interface and direction)
+-- @column package_name        Traffic package source (or uid=$X if not found)
+-- @column iface               Traffic interface name (linux interface name)
+-- @column direction           Traffic direction ('Transmitted' or 'Received')
+-- @column packet_count        Number of packets in this event
+-- @column packet_length       Number of bytes in this event (wire size)
+-- @column packet_transport    Transport used for traffic in this event
+-- @column packet_tcp_flags    TCP flags used by tcp frames in this event
+-- @column socket_tag          The Android traffic tag of the network socket
+-- @column socket_uid          The Linux user id of the network socket
+-- @column local_port          The local port number (for udp or tcp only)
+-- @column remote_port         The remote port number (for udp or tcp only)
+CREATE VIEW android_network_packets AS
+SELECT
+  ts,
+  dur,
+  track.name AS track_name,
+  slice.name AS package_name,
+  str_split(track.name, ' ', 0) AS iface,
+  str_split(track.name, ' ', 1) AS direction,
+  ifnull(extract_arg(arg_set_id, 'packet_count'), 1) AS packet_count,
+  extract_arg(arg_set_id, 'packet_length') AS packet_length,
+  extract_arg(arg_set_id, 'packet_transport') AS packet_transport,
+  extract_arg(arg_set_id, 'packet_tcp_flags') AS packet_tcp_flags,
+  extract_arg(arg_set_id, 'socket_tag') AS socket_tag,
+  extract_arg(arg_set_id, 'socket_uid') AS socket_uid,
+  extract_arg(arg_set_id, 'local_port') AS local_port,
+  extract_arg(arg_set_id, 'remote_port') AS remote_port
+FROM slice
+JOIN track
+  ON slice.track_id = track.id
+WHERE (track.name GLOB '* Transmitted' OR
+       track.name GLOB '* Received');
diff --git a/test/data/ui-screenshots/ui-android_trace_30s_expand_camera.png.sha256 b/test/data/ui-screenshots/ui-android_trace_30s_expand_camera.png.sha256
index b78630c..adfa483 100644
--- a/test/data/ui-screenshots/ui-android_trace_30s_expand_camera.png.sha256
+++ b/test/data/ui-screenshots/ui-android_trace_30s_expand_camera.png.sha256
@@ -1 +1 @@
-59f5feeab8b216aab64795b4fb620d9386d45769e46cd3b7df12e0b8c85dca9a
\ No newline at end of file
+37ce86e92a72fe05c24acfcadf1393f3407a99e3dcdda6b3c883f380ffd00b41
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-android_trace_30s_load.png.sha256 b/test/data/ui-screenshots/ui-android_trace_30s_load.png.sha256
index 39ac1e4..ba42f34 100644
--- a/test/data/ui-screenshots/ui-android_trace_30s_load.png.sha256
+++ b/test/data/ui-screenshots/ui-android_trace_30s_load.png.sha256
@@ -1 +1 @@
-f146aec8fe3dff85764c2760a8726e38f085006c1a217aca89b0a847390684cf
\ No newline at end of file
+0c7f3705f1d29c0b16756b66cb748635352fef3928a173b34d995356e6d3d58e
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-chrome_missing_track_names_load.png.sha256 b/test/data/ui-screenshots/ui-chrome_missing_track_names_load.png.sha256
index cf2b69d..4c65000 100644
--- a/test/data/ui-screenshots/ui-chrome_missing_track_names_load.png.sha256
+++ b/test/data/ui-screenshots/ui-chrome_missing_track_names_load.png.sha256
@@ -1 +1 @@
-a238887636bc86b86e179fc5bc11024cd2b5151797365146b4f94c6de6994280
\ No newline at end of file
+c8277a777519e7c1bf762d4e0e299a79a66da88ce54ed26e6d27939dca6128c9
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-chrome_rendering_desktop_expand_browser_proc.png.sha256 b/test/data/ui-screenshots/ui-chrome_rendering_desktop_expand_browser_proc.png.sha256
index 29aeeb0..f504faf 100644
--- a/test/data/ui-screenshots/ui-chrome_rendering_desktop_expand_browser_proc.png.sha256
+++ b/test/data/ui-screenshots/ui-chrome_rendering_desktop_expand_browser_proc.png.sha256
@@ -1 +1 @@
-40026a723cf944adb25668cc6618513271aff14cd7271804b4e208bf6a9817d8
\ No newline at end of file
+dc246b0bf68834a63464c0c98e35a060cb3698947084cdb9d976207a37bf8156
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-chrome_rendering_desktop_load.png.sha256 b/test/data/ui-screenshots/ui-chrome_rendering_desktop_load.png.sha256
index 4af29fe..0ee6b17 100644
--- a/test/data/ui-screenshots/ui-chrome_rendering_desktop_load.png.sha256
+++ b/test/data/ui-screenshots/ui-chrome_rendering_desktop_load.png.sha256
@@ -1 +1 @@
-81154f3a88aee01576eba17432269818f1431166cb6071256ee1c35e14851271
\ No newline at end of file
+e79c53f02bed0a629c76fd3d044492dc8e619fe9f58c597db5f4417cbe91f805
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-chrome_rendering_desktop_select_slice_with_flows.png.sha256 b/test/data/ui-screenshots/ui-chrome_rendering_desktop_select_slice_with_flows.png.sha256
index 2a17dfb..0e50d9b 100644
--- a/test/data/ui-screenshots/ui-chrome_rendering_desktop_select_slice_with_flows.png.sha256
+++ b/test/data/ui-screenshots/ui-chrome_rendering_desktop_select_slice_with_flows.png.sha256
@@ -1 +1 @@
-3ae58540e40e9597d92aa15735e253d14ed9fc87753e543695226fce51481004
\ No newline at end of file
+cc543d6db95f56863f5d6c90dbbb8b07e8dff539534c53d595b1faee807903da
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_open_invalid_trace_from_blank_page.png.sha256 b/test/data/ui-screenshots/ui-routing_open_invalid_trace_from_blank_page.png.sha256
index d26043d..b3b1619 100644
--- a/test/data/ui-screenshots/ui-routing_open_invalid_trace_from_blank_page.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_open_invalid_trace_from_blank_page.png.sha256
@@ -1 +1 @@
-ef5dfa6588634af3b6ac4950d751f7f8b73eeaae7f74c9e45d65ede4134451fe
\ No newline at end of file
+ae78e1f372a6a052a650974464f2e706ab8cb665c639ca4b0b15b48dce95d185
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_access_subpage_then_go_back.png.sha256 b/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_access_subpage_then_go_back.png.sha256
index ad17707..9287876 100644
--- a/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_access_subpage_then_go_back.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_access_subpage_then_go_back.png.sha256
@@ -1 +1 @@
-a16bc9d707e713ef1ce9110a74790bbbeaf27e1fc7f798152c18e9c81bcedbbf
\ No newline at end of file
+30faf223bfc57acbbcca2308c716064d3447dee2673db6329ad9188bf08a59e6
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_open_second_trace_from_url.png.sha256 b/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_open_second_trace_from_url.png.sha256
index ad17707..9287876 100644
--- a/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_open_second_trace_from_url.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_open_second_trace_from_url.png.sha256
@@ -1 +1 @@
-a16bc9d707e713ef1ce9110a74790bbbeaf27e1fc7f798152c18e9c81bcedbbf
\ No newline at end of file
+30faf223bfc57acbbcca2308c716064d3447dee2673db6329ad9188bf08a59e6
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_start_from_no_trace_go_back_to_first_trace.png.sha256 b/test/data/ui-screenshots/ui-routing_start_from_no_trace_go_back_to_first_trace.png.sha256
index 796c14f..9287876 100644
--- a/test/data/ui-screenshots/ui-routing_start_from_no_trace_go_back_to_first_trace.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_start_from_no_trace_go_back_to_first_trace.png.sha256
@@ -1 +1 @@
-7c2317088fd3ada6af276ab517e67163b091f7e9bd98e443794397fe58b61ae3
\ No newline at end of file
+30faf223bfc57acbbcca2308c716064d3447dee2673db6329ad9188bf08a59e6
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_invalid_trace.png.sha256 b/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_invalid_trace.png.sha256
index 060bb57..535b9b1 100644
--- a/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_invalid_trace.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_invalid_trace.png.sha256
@@ -1 +1 @@
-da73a7a9d403491d5de58ae013e8b66337c9bf48422ae2ce212ab91bc33a6d5c
\ No newline at end of file
+b0d5928fbcf0b7adfc06386ea1961d301ee19f66c61b190f9d00fd5acef824c0
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_trace_.png.sha256 b/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_trace_.png.sha256
index ad17707..9287876 100644
--- a/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_trace_.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_trace_.png.sha256
@@ -1 +1 @@
-a16bc9d707e713ef1ce9110a74790bbbeaf27e1fc7f798152c18e9c81bcedbbf
\ No newline at end of file
+30faf223bfc57acbbcca2308c716064d3447dee2673db6329ad9188bf08a59e6
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_start_from_no_trace_refresh.png.sha256 b/test/data/ui-screenshots/ui-routing_start_from_no_trace_refresh.png.sha256
index ad17707..9287876 100644
--- a/test/data/ui-screenshots/ui-routing_start_from_no_trace_refresh.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_start_from_no_trace_refresh.png.sha256
@@ -1 +1 @@
-a16bc9d707e713ef1ce9110a74790bbbeaf27e1fc7f798152c18e9c81bcedbbf
\ No newline at end of file
+30faf223bfc57acbbcca2308c716064d3447dee2673db6329ad9188bf08a59e6
\ No newline at end of file
diff --git a/test/trace_processor/diff_tests/android/android_battery_stats_event_slices.out b/test/trace_processor/diff_tests/android/android_battery_stats_event_slices.out
new file mode 100644
index 0000000..82cd36c
--- /dev/null
+++ b/test/trace_processor/diff_tests/android/android_battery_stats_event_slices.out
@@ -0,0 +1,4 @@
+"ts","dur","track_name","str_value","int_value"
+1000,8000,"battery_stats.top","mail",123
+3000,-1,"battery_stats.job","mail_job",456
+1000,3000,"battery_stats.job","video_job",789
diff --git a/test/trace_processor/diff_tests/android/tests.py b/test/trace_processor/diff_tests/android/tests.py
index 25190b0..cec98ae 100644
--- a/test/trace_processor/diff_tests/android/tests.py
+++ b/test/trace_processor/diff_tests/android/tests.py
@@ -111,6 +111,54 @@
         """,
         out=Path('android_system_property_slice.out'))
 
+  def test_android_battery_stats_event_slices(self):
+    # The following has three events
+    # * top (123, mail) from 1000 to 9000 explicit
+    # * job (456, mail_job) starting at 3000 (end is inferred as trace end)
+    # * job (789, video_job) ending at 4000 (start is inferred as trace start)
+    return DiffTestBlueprint(
+        trace=TextProto(r"""
+        packet {
+          ftrace_events {
+            cpu: 1
+            event {
+              timestamp: 1000
+              pid: 1
+              print {
+                buf: "N|1000|battery_stats.top|+top=123:\"mail\"\n"
+              }
+            }
+            event {
+              timestamp: 3000
+              pid: 1
+              print {
+                buf: "N|1000|battery_stats.job|+job=456:\"mail_job\"\n"
+              }
+            }
+            event {
+              timestamp: 4000
+              pid: 1
+              print {
+                buf: "N|1000|battery_stats.job|-job=789:\"video_job\"\n"
+              }
+            }
+            event {
+              timestamp: 9000
+              pid: 1
+              print {
+                buf: "N|1000|battery_stats.top|-top=123:\"mail\"\n"
+              }
+            }
+          }
+        }
+        """),
+        query="""
+        SELECT IMPORT('android.battery_stats');
+        SELECT * FROM android_battery_stats_event_slices
+        ORDER BY str_value;
+        """,
+        out=Path('android_battery_stats_event_slices.out'))
+
   def test_binder_sync_binder_metrics(self):
     return DiffTestBlueprint(
         trace=DataPath('android_binder_metric_trace.atr'),
diff --git a/ui/src/base/bigint_math.ts b/ui/src/base/bigint_math.ts
new file mode 100644
index 0000000..1860afe
--- /dev/null
+++ b/ui/src/base/bigint_math.ts
@@ -0,0 +1,77 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+export class BigintMath {
+  static INT64_MAX: bigint = (2n ** 63n) - 1n;
+
+  // Returns the smallest integral power of 2 that is not smaller than n.
+  // If n is less than or equal to 0, returns 1.
+  static bitCeil(n: bigint): bigint {
+    let result = 1n;
+    while (result < n) {
+      result <<= 1n;
+    }
+    return result;
+  };
+
+  // Returns the largest integral power of 2 which is not greater than n.
+  // If n is less than or equal to 0, returns 1.
+  static bitFloor(n: bigint): bigint {
+    let result = 1n;
+    while ((result << 1n) <= n) {
+      result <<= 1n;
+    }
+    return result;
+  };
+
+  // Returns the largest integral multiple of step which is not larger than n.
+  // If step is less than or equal to 0, returns n.
+  static quantizeFloor(n: bigint, step: bigint): bigint {
+    step = BigintMath.max(1n, step);
+    return step * (n / step);
+  }
+
+  // Return the integral multiple of step which is closest to n.
+  // If step is less than or equal to 0, returns n.
+  static quantize(n: bigint, step: bigint): bigint {
+    step = BigintMath.max(1n, step);
+    const halfStep = step / 2n;
+    return step * ((n + halfStep) / step);
+  }
+
+  // Return the greater of a and b
+  static max(a: bigint, b: bigint): bigint {
+    return a > b ? a : b;
+  }
+
+  // Return the smaller of a and b
+  static min(a: bigint, b: bigint): bigint {
+    return a < b ? a : b;
+  }
+
+  // Returns the number of 1 bits in n
+  static popcount(n: bigint): number {
+    if (n < 0n) {
+      throw Error(`Can\'t get popcount of negative number ${n}`);
+    }
+    let count = 0;
+    while (n) {
+      if (n & 1n) {
+        ++count;
+      }
+      n >>= 1n;
+    }
+    return count;
+  }
+}
diff --git a/ui/src/base/bigint_math_unittest.ts b/ui/src/base/bigint_math_unittest.ts
new file mode 100644
index 0000000..291eb32
--- /dev/null
+++ b/ui/src/base/bigint_math_unittest.ts
@@ -0,0 +1,141 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {
+  BigintMath as BIM,
+} from './bigint_math';
+
+describe('BigIntMath.bitCeil', () => {
+  it('rounds powers of 2 to themselves', () => {
+    expect(BIM.bitCeil(1n)).toBe(1n);
+    expect(BIM.bitCeil(2n)).toBe(2n);
+    expect(BIM.bitCeil(4n)).toBe(4n);
+    expect(BIM.bitCeil(4294967296n)).toBe(4294967296n);
+    expect(BIM.bitCeil(2305843009213693952n)).toBe(2305843009213693952n);
+  });
+
+  it('rounds non powers of 2 up to nearest power of 2', () => {
+    expect(BIM.bitCeil(3n)).toBe(4n);
+    expect(BIM.bitCeil(11n)).toBe(16n);
+    expect(BIM.bitCeil(33n)).toBe(64n);
+    expect(BIM.bitCeil(63n)).toBe(64n);
+    expect(BIM.bitCeil(1234567890123456789n)).toBe(2305843009213693952n);
+  });
+
+  it('rounds 0 or negative values up to 1', () => {
+    expect(BIM.bitCeil(0n)).toBe(1n);
+    expect(BIM.bitCeil(-123n)).toBe(1n);
+  });
+});
+
+describe('BigIntMath.bigFloor', () => {
+  it('rounds powers of 2 to themselves', () => {
+    expect(BIM.bitFloor(1n)).toBe(1n);
+    expect(BIM.bitFloor(2n)).toBe(2n);
+    expect(BIM.bitFloor(4n)).toBe(4n);
+    expect(BIM.bitFloor(4294967296n)).toBe(4294967296n);
+    expect(BIM.bitFloor(2305843009213693952n)).toBe(2305843009213693952n);
+  });
+
+  it('rounds non powers of 2 down to nearest power of 2', () => {
+    expect(BIM.bitFloor(3n)).toBe(2n);
+    expect(BIM.bitFloor(11n)).toBe(8n);
+    expect(BIM.bitFloor(33n)).toBe(32n);
+    expect(BIM.bitFloor(63n)).toBe(32n);
+    expect(BIM.bitFloor(1234567890123456789n)).toBe(1152921504606846976n);
+  });
+
+  it('rounds 0 or negative values up to 1', () => {
+    expect(BIM.bitFloor(0n)).toBe(1n);
+    expect(BIM.bitFloor(-123n)).toBe(1n);
+  });
+});
+
+describe('quantize', () => {
+  it('should quantize a number to the nearest multiple of a stepsize', () => {
+    expect(BIM.quantizeFloor(10n, 2n)).toEqual(10n);
+    expect(BIM.quantizeFloor(11n, 2n)).toEqual(10n);
+    expect(BIM.quantizeFloor(12n, 2n)).toEqual(12n);
+    expect(BIM.quantizeFloor(13n, 2n)).toEqual(12n);
+
+    expect(BIM.quantizeFloor(9n, 4n)).toEqual(8n);
+    expect(BIM.quantizeFloor(10n, 4n)).toEqual(8n);
+    expect(BIM.quantizeFloor(11n, 4n)).toEqual(8n);
+    expect(BIM.quantizeFloor(12n, 4n)).toEqual(12n);
+    expect(BIM.quantizeFloor(13n, 4n)).toEqual(12n);
+  });
+
+  it('should return value if stepsize is smaller than 1', () => {
+    expect(BIM.quantizeFloor(123n, 0n)).toEqual(123n);
+    expect(BIM.quantizeFloor(123n, -10n)).toEqual(123n);
+  });
+});
+
+describe('quantizeRound', () => {
+  it('should quantize a number to the nearest multiple of a stepsize', () => {
+    expect(BIM.quantize(0n, 2n)).toEqual(0n);
+    expect(BIM.quantize(1n, 2n)).toEqual(2n);
+    expect(BIM.quantize(2n, 2n)).toEqual(2n);
+    expect(BIM.quantize(3n, 2n)).toEqual(4n);
+    expect(BIM.quantize(4n, 2n)).toEqual(4n);
+
+    expect(BIM.quantize(0n, 3n)).toEqual(0n);
+    expect(BIM.quantize(1n, 3n)).toEqual(0n);
+    expect(BIM.quantize(2n, 3n)).toEqual(3n);
+    expect(BIM.quantize(3n, 3n)).toEqual(3n);
+    expect(BIM.quantize(4n, 3n)).toEqual(3n);
+    expect(BIM.quantize(5n, 3n)).toEqual(6n);
+    expect(BIM.quantize(6n, 3n)).toEqual(6n);
+  });
+
+  it('should return value if stepsize is smaller than 1', () => {
+    expect(BIM.quantize(123n, 0n)).toEqual(123n);
+    expect(BIM.quantize(123n, -10n)).toEqual(123n);
+  });
+});
+
+describe('max', () => {
+  it('should return the greater of two numbers', () => {
+    expect(BIM.max(5n, 8n)).toEqual(8n);
+    expect(BIM.max(3n, 7n)).toEqual(7n);
+    expect(BIM.max(6n, 6n)).toEqual(6n);
+    expect(BIM.max(-7n, -12n)).toEqual(-7n);
+  });
+});
+
+describe('min', () => {
+  it('should return the smaller of two numbers', () => {
+    expect(BIM.min(5n, 8n)).toEqual(5n);
+    expect(BIM.min(3n, 7n)).toEqual(3n);
+    expect(BIM.min(6n, 6n)).toEqual(6n);
+    expect(BIM.min(-7n, -12n)).toEqual(-12n);
+  });
+});
+
+describe('popcount', () => {
+  it('should return the number of set bits in an integer', () => {
+    expect(BIM.popcount(0n)).toBe(0);
+    expect(BIM.popcount(1n)).toBe(1);
+    expect(BIM.popcount(2n)).toBe(1);
+    expect(BIM.popcount(3n)).toBe(2);
+    expect(BIM.popcount(4n)).toBe(1);
+    expect(BIM.popcount(5n)).toBe(2);
+    expect(BIM.popcount(3462151285050974216n)).toBe(10);
+  });
+
+  it('should throw when presented with a negative integer', () => {
+    expect(() => BIM.popcount(-1n))
+        .toThrowError('Can\'t get popcount of negative number -1');
+  });
+});
diff --git a/ui/src/base/math_utils.ts b/ui/src/base/math_utils.ts
deleted file mode 100644
index f1c1816..0000000
--- a/ui/src/base/math_utils.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-// Copyright (C) 2023 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-// Round a number up to the nearest stepsize.
-export function roundUpNearest(val: number, stepsize: number): number {
-  return stepsize * Math.ceil(val / stepsize);
-}
-
-// Round a number down to the nearest stepsize.
-export function roundDownNearest(val: number, stepsize: number): number {
-  return stepsize * Math.floor(val / stepsize);
-}
diff --git a/ui/src/base/math_utils_unittest.ts b/ui/src/base/math_utils_unittest.ts
deleted file mode 100644
index 169b793..0000000
--- a/ui/src/base/math_utils_unittest.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2023 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import {roundDownNearest, roundUpNearest} from './math_utils';
-
-describe('roundUpNearest()', () => {
-  it('rounds decimal values up to the right step size', () => {
-    expect(roundUpNearest(0.1, 0.5)).toBeCloseTo(0.5);
-    expect(roundUpNearest(17.2, 0.5)).toBeCloseTo(17.5);
-  });
-});
-
-describe('roundDownNearest()', () => {
-  it('rounds decimal values down to the right step size', () => {
-    expect(roundDownNearest(0.4, 0.5)).toBeCloseTo(0.0);
-    expect(roundDownNearest(17.4, 0.5)).toBeCloseTo(17.0);
-  });
-});
diff --git a/ui/src/common/actions.ts b/ui/src/common/actions.ts
index 554722c..286f82f 100644
--- a/ui/src/common/actions.ts
+++ b/ui/src/common/actions.ts
@@ -61,7 +61,7 @@
   UtidToTrackSortKey,
   VisibleState,
 } from './state';
-import {toNs} from './time';
+import {TPDuration, TPTime} from './time';
 
 export const DEBUG_SLICE_TRACK_KIND = 'DebugSliceTrack';
 
@@ -89,8 +89,8 @@
 }
 
 export interface PostedScrollToRange {
-  timeStart: number;
-  timeEnd: number;
+  timeStart: TPTime;
+  timeEnd: TPTime;
   viewPercentage?: number;
 }
 
@@ -552,7 +552,7 @@
 
   addAutomaticNote(
       state: StateDraft,
-      args: {timestamp: number, color: string, text: string}): void {
+      args: {timestamp: TPTime, color: string, text: string}): void {
     const id = generateNextId(state);
     state.notes[id] = {
       noteType: 'DEFAULT',
@@ -563,7 +563,7 @@
     };
   },
 
-  addNote(state: StateDraft, args: {timestamp: number, color: string}): void {
+  addNote(state: StateDraft, args: {timestamp: TPTime, color: string}): void {
     const id = generateNextId(state);
     state.notes[id] = {
       noteType: 'DEFAULT',
@@ -606,14 +606,10 @@
   },
 
   markArea(state: StateDraft, args: {area: Area, persistent: boolean}): void {
+    const {start, end, tracks} = args.area;
+    assertTrue(start <= end);
     const areaId = generateNextId(state);
-    assertTrue(args.area.endSec >= args.area.startSec);
-    state.areas[areaId] = {
-      id: areaId,
-      startSec: args.area.startSec,
-      endSec: args.area.endSec,
-      tracks: args.area.tracks,
-    };
+    state.areas[areaId] = {id: areaId, start, end, tracks};
     const noteId = args.persistent ? generateNextId(state) : '0';
     const color = args.persistent ? randomColor() : '#344596';
     state.notes[noteId] = {
@@ -667,7 +663,7 @@
 
   selectCounter(
       state: StateDraft,
-      args: {leftTs: number, rightTs: number, id: number, trackId: string}):
+      args: {leftTs: TPTime, rightTs: TPTime, id: number, trackId: string}):
       void {
         state.currentSelection = {
           kind: 'COUNTER',
@@ -680,7 +676,7 @@
 
   selectHeapProfile(
       state: StateDraft,
-      args: {id: number, upid: number, ts: number, type: ProfileType}): void {
+      args: {id: number, upid: number, ts: TPTime, type: ProfileType}): void {
     state.currentSelection = {
       kind: 'HEAP_PROFILE',
       id: args.id,
@@ -690,8 +686,8 @@
     };
     this.openFlamegraph(state, {
       type: args.type,
-      startNs: toNs(state.traceTime.startSec),
-      endNs: args.ts,
+      start: state.traceTime.start,
+      end: args.ts,
       upids: [args.upid],
       viewingOption: DEFAULT_VIEWING_OPTION,
     });
@@ -700,8 +696,8 @@
   selectPerfSamples(state: StateDraft, args: {
     id: number,
     upid: number,
-    leftTs: number,
-    rightTs: number,
+    leftTs: TPTime,
+    rightTs: TPTime,
     type: ProfileType
   }): void {
     state.currentSelection = {
@@ -714,8 +710,8 @@
     };
     this.openFlamegraph(state, {
       type: args.type,
-      startNs: args.leftTs,
-      endNs: args.rightTs,
+      start: args.leftTs,
+      end: args.rightTs,
       upids: [args.upid],
       viewingOption: PERF_SAMPLES_KEY,
     });
@@ -723,16 +719,16 @@
 
   openFlamegraph(state: StateDraft, args: {
     upids: number[],
-    startNs: number,
-    endNs: number,
+    start: TPTime,
+    end: TPTime,
     type: ProfileType,
     viewingOption: FlamegraphStateViewingOption
   }): void {
     state.currentFlamegraphState = {
       kind: 'FLAMEGRAPH_STATE',
       upids: args.upids,
-      startNs: args.startNs,
-      endNs: args.endNs,
+      start: args.start,
+      end: args.end,
       type: args.type,
       viewingOption: args.viewingOption,
       focusRegex: '',
@@ -784,16 +780,16 @@
   selectDebugSlice(state: StateDraft, args: {
     id: number,
     sqlTableName: string,
-    startS: number,
-    durationS: number,
+    start: TPTime,
+    duration: TPDuration,
     trackId: string,
   }): void {
     state.currentSelection = {
       kind: 'DEBUG_SLICE',
       id: args.id,
       sqlTableName: args.sqlTableName,
-      startS: args.startS,
-      durationS: args.durationS,
+      start: args.start,
+      duration: args.duration,
       trackId: args.trackId,
     };
   },
@@ -801,16 +797,16 @@
   selectTopLevelScrollSlice(state: StateDraft, args: {
     id: number,
     sqlTableName: string,
-    startS: number,
-    durationS: number,
+    start: TPTime,
+    duration: TPTime,
     trackId: string,
   }): void {
     state.currentSelection = {
       kind: 'TOP_LEVEL_SCROLL',
       id: args.id,
       sqlTableName: args.sqlTableName,
-      startS: args.startS,
-      durationS: args.durationS,
+      start: args.start,
+      duration: args.duration,
       trackId: args.trackId,
     };
   },
@@ -910,25 +906,17 @@
   },
 
   selectArea(state: StateDraft, args: {area: Area}): void {
+    const {start, end, tracks} = args.area;
+    assertTrue(start <= end);
     const areaId = generateNextId(state);
-    assertTrue(args.area.endSec >= args.area.startSec);
-    state.areas[areaId] = {
-      id: areaId,
-      startSec: args.area.startSec,
-      endSec: args.area.endSec,
-      tracks: args.area.tracks,
-    };
+    state.areas[areaId] = {id: areaId, start, end, tracks};
     state.currentSelection = {kind: 'AREA', areaId};
   },
 
   editArea(state: StateDraft, args: {area: Area, areaId: string}): void {
-    assertTrue(args.area.endSec >= args.area.startSec);
-    state.areas[args.areaId] = {
-      id: args.areaId,
-      startSec: args.area.startSec,
-      endSec: args.area.endSec,
-      tracks: args.area.tracks,
-    };
+    const {start, end, tracks} = args.area;
+    assertTrue(start <= end);
+    state.areas[args.areaId] = {id: args.areaId, start, end, tracks};
   },
 
   reSelectArea(state: StateDraft, args: {areaId: string, noteId: string}):
@@ -1048,11 +1036,11 @@
     state.searchIndex = args.index;
   },
 
-  setHoverCursorTimestamp(state: StateDraft, args: {ts: number}) {
+  setHoverCursorTimestamp(state: StateDraft, args: {ts: TPTime}) {
     state.hoverCursorTimestamp = args.ts;
   },
 
-  setHoveredNoteTimestamp(state: StateDraft, args: {ts: number}) {
+  setHoveredNoteTimestamp(state: StateDraft, args: {ts: TPTime}) {
     state.hoveredNoteTimestamp = args.ts;
   },
 
diff --git a/ui/src/common/actions_unittest.ts b/ui/src/common/actions_unittest.ts
index 6702e72..b613777 100644
--- a/ui/src/common/actions_unittest.ts
+++ b/ui/src/common/actions_unittest.ts
@@ -446,9 +446,13 @@
   const state = createEmptyState();
 
   const afterSelectingPerf = produce(state, (draft) => {
-    StateActions.selectPerfSamples(
-        draft,
-        {id: 0, upid: 0, leftTs: 0, rightTs: 0, type: ProfileType.PERF_SAMPLE});
+    StateActions.selectPerfSamples(draft, {
+      id: 0,
+      upid: 0,
+      leftTs: 0n,
+      rightTs: 0n,
+      type: ProfileType.PERF_SAMPLE,
+    });
   });
 
   expect(assertExists(afterSelectingPerf.currentFlamegraphState).type)
@@ -460,7 +464,7 @@
 
   const afterSelectingPerf = produce(state, (draft) => {
     StateActions.selectHeapProfile(
-        draft, {id: 0, upid: 0, ts: 0, type: ProfileType.JAVA_HEAP_GRAPH});
+        draft, {id: 0, upid: 0, ts: 0n, type: ProfileType.JAVA_HEAP_GRAPH});
   });
 
   expect(assertExists(afterSelectingPerf.currentFlamegraphState).type)
diff --git a/ui/src/common/empty_state.ts b/ui/src/common/empty_state.ts
index bd1e6b6..6d806a2 100644
--- a/ui/src/common/empty_state.ts
+++ b/ui/src/common/empty_state.ts
@@ -110,7 +110,7 @@
       visibleState: {
         ...defaultTraceTime,
         lastUpdate: 0,
-        resolution: 0,
+        resolution: 0n,
       },
     },
 
@@ -142,8 +142,8 @@
     sidebarVisible: true,
     hoveredUtid: -1,
     hoveredPid: -1,
-    hoverCursorTimestamp: -1,
-    hoveredNoteTimestamp: -1,
+    hoverCursorTimestamp: -1n,
+    hoveredNoteTimestamp: -1n,
     highlightedSliceId: -1,
     focusedFlowIdLeft: -1,
     focusedFlowIdRight: -1,
diff --git a/ui/src/common/engine.ts b/ui/src/common/engine.ts
index 8b6cb46..861601f 100644
--- a/ui/src/common/engine.ts
+++ b/ui/src/common/engine.ts
@@ -24,18 +24,20 @@
   QueryArgs,
   ResetTraceProcessorArgs,
 } from './protos';
-import {NUM, NUM_NULL, STR} from './query_result';
+import {LONG, LONG_NULL, NUM, STR} from './query_result';
 import {
   createQueryResult,
   QueryError,
   QueryResult,
   WritableQueryResult,
 } from './query_result';
-import {TimeSpan} from './time';
+import {TPTime, TPTimeSpan} from './time';
 
 import TraceProcessorRpc = perfetto.protos.TraceProcessorRpc;
 import TraceProcessorRpcStream = perfetto.protos.TraceProcessorRpcStream;
 import TPM = perfetto.protos.TraceProcessorRpc.TraceProcessorMethod;
+import {Span} from '../common/time';
+import {BigintMath} from '../base/bigint_math';
 
 export interface LoadingTracker {
   beginLoading(): void;
@@ -410,38 +412,38 @@
     return result.firstRow({cnt: NUM}).cnt;
   }
 
-  async getTraceTimeBounds(): Promise<TimeSpan> {
+  async getTraceTimeBounds(): Promise<Span<TPTime>> {
     const result = await this.query(
         `select start_ts as startTs, end_ts as endTs from trace_bounds`);
     const bounds = result.firstRow({
-      startTs: NUM,
-      endTs: NUM,
+      startTs: LONG,
+      endTs: LONG,
     });
-    return new TimeSpan(bounds.startTs / 1e9, bounds.endTs / 1e9);
+    return new TPTimeSpan(bounds.startTs, bounds.endTs);
   }
 
-  async getTracingMetadataTimeBounds(): Promise<TimeSpan> {
+  async getTracingMetadataTimeBounds(): Promise<Span<TPTime>> {
     const queryRes = await this.query(`select
          name,
          int_value as intValue
          from metadata
          where name = 'tracing_started_ns' or name = 'tracing_disabled_ns'
          or name = 'all_data_source_started_ns'`);
-    let startBound = -Infinity;
-    let endBound = Infinity;
-    const it = queryRes.iter({'name': STR, 'intValue': NUM_NULL});
+    let startBound = 0n;
+    let endBound = BigintMath.INT64_MAX;
+    const it = queryRes.iter({'name': STR, 'intValue': LONG_NULL});
     for (; it.valid(); it.next()) {
       const columnName = it.name;
       const timestamp = it.intValue;
       if (timestamp === null) continue;
       if (columnName === 'tracing_disabled_ns') {
-        endBound = Math.min(endBound, timestamp / 1e9);
+        endBound = BigintMath.min(endBound, timestamp);
       } else {
-        startBound = Math.max(startBound, timestamp / 1e9);
+        startBound = BigintMath.max(startBound, timestamp);
       }
     }
 
-    return new TimeSpan(startBound, endBound);
+    return new TPTimeSpan(startBound, endBound);
   }
 
   getProxy(tag: string): EngineProxy {
diff --git a/ui/src/common/high_precision_time.ts b/ui/src/common/high_precision_time.ts
new file mode 100644
index 0000000..927b7df
--- /dev/null
+++ b/ui/src/common/high_precision_time.ts
@@ -0,0 +1,258 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {assertTrue} from '../base/logging';
+import {Span, TPTime} from './time';
+
+export type RoundMode = 'round'|'floor'|'ceil';
+
+// Stores a time as a bigint and an offset which is capable of:
+// - Storing and reproducing "TPTime"s without losing precision.
+// - Storing time with sub-nanosecond precision.
+// This class is immutable - each operation returns a new object.
+export class HighPrecisionTime {
+  // Time in nanoseconds == base + offset
+  // offset is kept in the range 0 <= x < 1 to avoid losing precision
+  readonly base: bigint;
+  readonly offset: number;
+
+  static get ZERO(): HighPrecisionTime {
+    return new HighPrecisionTime(0n);
+  }
+
+  constructor(base: bigint = 0n, offset: number = 0) {
+    // Normalize offset to sit in the range 0.0 <= x < 1.0
+    const offsetFloor = Math.floor(offset);
+    this.base = base + BigInt(offsetFloor);
+    this.offset = offset - offsetFloor;
+  }
+
+  static fromTPTime(timestamp: TPTime): HighPrecisionTime {
+    return new HighPrecisionTime(timestamp, 0);
+  }
+
+  static fromNanos(nanos: number|bigint) {
+    if (typeof nanos === 'number') {
+      return new HighPrecisionTime(0n, nanos);
+    } else if (typeof nanos === 'bigint') {
+      return new HighPrecisionTime(nanos);
+    } else {
+      const value: never = nanos;
+      throw new Error(`Value ${value} is neither a number nor a bigint`);
+    }
+  }
+
+  static fromSeconds(seconds: number) {
+    const nanos = seconds * 1e9;
+    const offset = nanos - Math.floor(nanos);
+    return new HighPrecisionTime(BigInt(Math.floor(nanos)), offset);
+  }
+
+  static max(a: HighPrecisionTime, b: HighPrecisionTime): HighPrecisionTime {
+    return a.isGreaterThan(b) ? a : b;
+  }
+
+  static min(a: HighPrecisionTime, b: HighPrecisionTime): HighPrecisionTime {
+    return a.isLessThan(b) ? a : b;
+  }
+
+  toTPTime(roundMode: RoundMode = 'floor'): TPTime {
+    switch (roundMode) {
+      case 'round':
+        return this.base + BigInt(Math.round(this.offset));
+      case 'floor':
+        return this.base;
+      case 'ceil':
+        return this.base + BigInt(Math.ceil(this.offset));
+      default:
+        const exhaustiveCheck: never = roundMode;
+        throw new Error(`Unhandled roundMode case: ${exhaustiveCheck}`);
+    }
+  }
+
+  get nanos(): number {
+    // WARNING: Number(bigint) can be surprisingly slow.
+    // WARNING: Precision may be lost here.
+    return Number(this.base) + this.offset;
+  }
+
+  get seconds(): number {
+    // WARNING: Number(bigint) can be surprisingly slow.
+    // WARNING: Precision may be lost here.
+    return (Number(this.base) + this.offset) / 1e9;
+  }
+
+  add(other: HighPrecisionTime): HighPrecisionTime {
+    return new HighPrecisionTime(
+        this.base + other.base, this.offset + other.offset);
+  }
+
+  addNanos(nanos: number|bigint): HighPrecisionTime {
+    return this.add(HighPrecisionTime.fromNanos(nanos));
+  }
+
+  addSeconds(seconds: number): HighPrecisionTime {
+    return new HighPrecisionTime(this.base, this.offset + seconds * 1e9);
+  }
+
+  addTPTime(ts: TPTime): HighPrecisionTime {
+    return new HighPrecisionTime(this.base + ts, this.offset);
+  }
+
+  subtract(other: HighPrecisionTime): HighPrecisionTime {
+    return new HighPrecisionTime(
+        this.base - other.base, this.offset - other.offset);
+  }
+
+  subtractTPTime(ts: TPTime): HighPrecisionTime {
+    return this.addTPTime(-ts);
+  }
+
+  subtractNanos(nanos: number|bigint): HighPrecisionTime {
+    return this.add(HighPrecisionTime.fromNanos(-nanos));
+  }
+
+  divide(divisor: number): HighPrecisionTime {
+    return this.multiply(1 / divisor);
+  }
+
+  multiply(factor: number): HighPrecisionTime {
+    const factorFloor = Math.floor(factor);
+    const newBase = this.base * BigInt(factorFloor);
+    const additionalBit = Number(this.base) * (factor - factorFloor);
+    const newOffset = factor * this.offset + additionalBit;
+    return new HighPrecisionTime(newBase, newOffset);
+  }
+
+  // Return true if other time is within some epsilon, default 1 femtosecond
+  equals(other: HighPrecisionTime, epsilon: number = 1e-6): boolean {
+    return Math.abs(this.subtract(other).nanos) < epsilon;
+  }
+
+  isLessThan(other: HighPrecisionTime): boolean {
+    if (this.base < other.base) {
+      return true;
+    } else if (this.base === other.base) {
+      return this.offset < other.offset;
+    } else {
+      return false;
+    }
+  }
+
+  isLessThanOrEqual(other: HighPrecisionTime): boolean {
+    if (this.equals(other)) {
+      return true;
+    } else {
+      return this.isLessThan(other);
+    }
+  }
+
+  isGreaterThan(other: HighPrecisionTime): boolean {
+    return !this.isLessThanOrEqual(other);
+  }
+
+  isGreaterThanOrEqual(other: HighPrecisionTime): boolean {
+    return !this.isLessThan(other);
+  }
+
+  clamp(lower: HighPrecisionTime, upper: HighPrecisionTime): HighPrecisionTime {
+    if (this.isLessThan(lower)) {
+      return lower;
+    } else if (this.isGreaterThan(upper)) {
+      return upper;
+    } else {
+      return this;
+    }
+  }
+
+  toString(): string {
+    const offsetAsString = this.offset.toString();
+    if (offsetAsString === '0') {
+      return this.base.toString();
+    } else {
+      return `${this.base}${offsetAsString.substring(1)}`;
+    }
+  }
+}
+
+export class HighPrecisionTimeSpan implements Span<HighPrecisionTime> {
+  readonly start: HighPrecisionTime;
+  readonly end: HighPrecisionTime;
+
+  constructor(start: TPTime|HighPrecisionTime, end: TPTime|HighPrecisionTime) {
+    this.start = (start instanceof HighPrecisionTime) ?
+        start :
+        HighPrecisionTime.fromTPTime(start);
+    this.end = (end instanceof HighPrecisionTime) ?
+        end :
+        HighPrecisionTime.fromTPTime(end);
+    assertTrue(
+        this.start.isLessThanOrEqual(this.end),
+        `TimeSpan start [${this.start}] cannot be greater than end [${
+            this.end}]`);
+  }
+
+  static fromTpTime(start: TPTime, end: TPTime): HighPrecisionTimeSpan {
+    return new HighPrecisionTimeSpan(
+        HighPrecisionTime.fromTPTime(start),
+        HighPrecisionTime.fromTPTime(end),
+    );
+  }
+
+  static get ZERO(): HighPrecisionTimeSpan {
+    return new HighPrecisionTimeSpan(
+        HighPrecisionTime.ZERO,
+        HighPrecisionTime.ZERO,
+    );
+  }
+
+  get duration(): HighPrecisionTime {
+    return this.end.subtract(this.start);
+  }
+
+  get midpoint(): HighPrecisionTime {
+    return this.start.add(this.end).divide(2);
+  }
+
+  equals(other: Span<HighPrecisionTime>): boolean {
+    return this.start.equals(other.start) && this.end.equals(other.end);
+  }
+
+  contains(x: HighPrecisionTime|Span<HighPrecisionTime>): boolean {
+    if (x instanceof HighPrecisionTime) {
+      return this.start.isLessThanOrEqual(x) && x.isLessThan(this.end);
+    } else {
+      return this.start.isLessThanOrEqual(x.start) &&
+          x.end.isLessThanOrEqual(this.end);
+    }
+  }
+
+  intersects(x: Span<HighPrecisionTime>): boolean {
+    return !(
+        x.end.isLessThanOrEqual(this.start) ||
+        x.start.isGreaterThanOrEqual(this.end));
+  }
+
+  add(time: HighPrecisionTime): Span<HighPrecisionTime> {
+    return new HighPrecisionTimeSpan(this.start.add(time), this.end.add(time));
+  }
+
+  // Move the start and end away from each other a certain amount
+  pad(time: HighPrecisionTime): Span<HighPrecisionTime> {
+    return new HighPrecisionTimeSpan(
+        this.start.subtract(time),
+        this.end.add(time),
+    );
+  }
+}
diff --git a/ui/src/common/high_precision_time_unittest.ts b/ui/src/common/high_precision_time_unittest.ts
new file mode 100644
index 0000000..c3a2033
--- /dev/null
+++ b/ui/src/common/high_precision_time_unittest.ts
@@ -0,0 +1,308 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {
+  HighPrecisionTime as HPTime,
+  HighPrecisionTimeSpan as HPTimeSpan,
+} from './high_precision_time';
+import {TPTime} from './time';
+
+// Quick 'n' dirty function to convert a string to a HPtime
+// Used to make tests more readable
+// E.g. '1.3' -> {base: 1, offset: 0.3}
+// E.g. '-0.3' -> {base: -1, offset: 0.7}
+function mkTime(time: string): HPTime {
+  const array = time.split('.');
+  if (array.length > 2) throw new Error(`Bad time format ${time}`);
+  const [base, fractions] = array;
+  const negative = time.startsWith('-');
+  const numBase = BigInt(base);
+
+  if (fractions) {
+    const numFractions = Number(`0.${fractions}`);
+    if (negative) {
+      return new HPTime(numBase - 1n, 1.0 - numFractions);
+    } else {
+      return new HPTime(numBase, numFractions);
+    }
+  } else {
+    return new HPTime(numBase);
+  }
+}
+
+function mkSpan(t1: string, t2: string): HPTimeSpan {
+  return new HPTimeSpan(mkTime(t1), mkTime(t2));
+}
+
+describe('Time', () => {
+  it('should create a new Time object with the given base and offset', () => {
+    const time = new HPTime(136n, 0.3);
+    expect(time.base).toBe(136n);
+    expect(time.offset).toBeCloseTo(0.3);
+  });
+
+  it('should normalize when offset is >= 1', () => {
+    let time = new HPTime(1n, 2.3);
+    expect(time.base).toBe(3n);
+    expect(time.offset).toBeCloseTo(0.3);
+
+    time = new HPTime(1n, 1);
+    expect(time.base).toBe(2n);
+    expect(time.offset).toBeCloseTo(0);
+  });
+
+  it('should normalize when offset is < 0', () => {
+    const time = new HPTime(1n, -0.4);
+    expect(time.base).toBe(0n);
+    expect(time.offset).toBeCloseTo(0.6);
+  });
+
+  it('should store timestamps without losing precision', () => {
+    let time = HPTime.fromTPTime(123n as TPTime);
+    expect(time.toTPTime()).toBe(123n as TPTime);
+
+    time = HPTime.fromTPTime(1152921504606846976n as TPTime);
+    expect(time.toTPTime()).toBe(1152921504606846976n as TPTime);
+  });
+
+  it('should store and manipulate timestamps without losing precision', () => {
+    let time = HPTime.fromTPTime(123n as TPTime);
+    time = time.addTPTime(456n);
+    expect(time.toTPTime()).toBe(579n);
+
+    time = HPTime.fromTPTime(2315700508990407843n as TPTime);
+    time = time.addTPTime(2315718101717517451n as TPTime);
+    expect(time.toTPTime()).toBe(4631418610707925294n);
+  });
+
+  it('should add time', () => {
+    const time1 = mkTime('1.3');
+    const time2 = mkTime('3.1');
+    const result = time1.add(time2);
+    expect(result.base).toEqual(4n);
+    expect(result.offset).toBeCloseTo(0.4);
+  });
+
+  it('should subtract time', () => {
+    const time1 = mkTime('3.1');
+    const time2 = mkTime('1.3');
+    const result = time1.subtract(time2);
+    expect(result.base).toEqual(1n);
+    expect(result.offset).toBeCloseTo(0.8);
+  });
+
+  it('should add nanoseconds', () => {
+    const time = mkTime('1.3');
+    const result = time.addNanos(0.8);
+    expect(result.base).toEqual(2n);
+    expect(result.offset).toBeCloseTo(0.1);
+  });
+
+  it('should add seconds', () => {
+    const time = mkTime('1.3');
+    const result = time.addSeconds(0.008);
+    expect(result.base).toEqual(8000001n);
+    expect(result.offset).toBeCloseTo(0.3);
+  });
+
+  it('should perform gt comparisions', () => {
+    const time = mkTime('1.2');
+    expect(time.isGreaterThanOrEqual(mkTime('0.5'))).toBeTruthy();
+    expect(time.isGreaterThanOrEqual(mkTime('1.1'))).toBeTruthy();
+    expect(time.isGreaterThanOrEqual(mkTime('1.2'))).toBeTruthy();
+    expect(time.isGreaterThanOrEqual(mkTime('1.5'))).toBeFalsy();
+    expect(time.isGreaterThanOrEqual(mkTime('5.5'))).toBeFalsy();
+  });
+
+  it('should perform gte comparisions', () => {
+    const time = mkTime('1.2');
+    expect(time.isGreaterThan(mkTime('0.5'))).toBeTruthy();
+    expect(time.isGreaterThan(mkTime('1.1'))).toBeTruthy();
+    expect(time.isGreaterThan(mkTime('1.2'))).toBeFalsy();
+    expect(time.isGreaterThan(mkTime('1.5'))).toBeFalsy();
+    expect(time.isGreaterThan(mkTime('5.5'))).toBeFalsy();
+  });
+
+  it('should perform lt comparisions', () => {
+    const time = mkTime('1.2');
+    expect(time.isLessThan(mkTime('0.5'))).toBeFalsy();
+    expect(time.isLessThan(mkTime('1.1'))).toBeFalsy();
+    expect(time.isLessThan(mkTime('1.2'))).toBeFalsy();
+    expect(time.isLessThan(mkTime('1.5'))).toBeTruthy();
+    expect(time.isLessThan(mkTime('5.5'))).toBeTruthy();
+  });
+
+  it('should perform lte comparisions', () => {
+    const time = mkTime('1.2');
+    expect(time.isLessThanOrEqual(mkTime('0.5'))).toBeFalsy();
+    expect(time.isLessThanOrEqual(mkTime('1.1'))).toBeFalsy();
+    expect(time.isLessThanOrEqual(mkTime('1.2'))).toBeTruthy();
+    expect(time.isLessThanOrEqual(mkTime('1.5'))).toBeTruthy();
+    expect(time.isLessThanOrEqual(mkTime('5.5'))).toBeTruthy();
+  });
+
+  it('should detect equality', () => {
+    const time = new HPTime(1n, 0.2);
+    expect(time.equals(new HPTime(1n, 0.2))).toBeTruthy();
+    expect(time.equals(new HPTime(0n, 1.2))).toBeTruthy();
+    expect(time.equals(new HPTime(-100n, 101.2))).toBeTruthy();
+    expect(time.equals(new HPTime(1n, 0.3))).toBeFalsy();
+    expect(time.equals(new HPTime(2n, 0.2))).toBeFalsy();
+  });
+
+  it('should clamp a time to a range', () => {
+    const time1 = mkTime('1.2');
+    const time2 = mkTime('5.4');
+    const time3 = mkTime('2.8');
+    const lower = mkTime('2.3');
+    const upper = mkTime('4.5');
+    expect(time1.clamp(lower, upper)).toEqual(lower);
+    expect(time2.clamp(lower, upper)).toEqual(upper);
+    expect(time3.clamp(lower, upper)).toEqual(time3);
+  });
+
+  it('should convert to seconds', () => {
+    expect(new HPTime(1n, .2).seconds).toBeCloseTo(0.0000000012);
+    expect(new HPTime(1000000000n, .0).seconds).toBeCloseTo(1);
+  });
+
+  it('should convert to nanos', () => {
+    expect(new HPTime(1n, .2).nanos).toBeCloseTo(1.2);
+    expect(new HPTime(1000000000n, .0).nanos).toBeCloseTo(1e9);
+  });
+
+  it('should convert to timestamps', () => {
+    expect(new HPTime(1n, .2).toTPTime('round')).toBe(1n);
+    expect(new HPTime(1n, .5).toTPTime('round')).toBe(2n);
+    expect(new HPTime(1n, .2).toTPTime('floor')).toBe(1n);
+    expect(new HPTime(1n, .5).toTPTime('floor')).toBe(1n);
+    expect(new HPTime(1n, .2).toTPTime('ceil')).toBe(2n);
+    expect(new HPTime(1n, .5).toTPTime('ceil')).toBe(2n);
+  });
+
+  it('should divide', () => {
+    let result = mkTime('1').divide(2);
+    expect(result.base).toBe(0n);
+    expect(result.offset).toBeCloseTo(0.5);
+
+    result = mkTime('1.6').divide(2);
+    expect(result.base).toBe(0n);
+    expect(result.offset).toBeCloseTo(0.8);
+
+    result = mkTime('-0.5').divide(2);
+    expect(result.base).toBe(-1n);
+    expect(result.offset).toBeCloseTo(0.75);
+
+    result = mkTime('123.1').divide(123);
+    expect(result.base).toBe(1n);
+    expect(result.offset).toBeCloseTo(0.000813, 6);
+  });
+
+  it('should multiply', () => {
+    let result = mkTime('1').multiply(2);
+    expect(result.base).toBe(2n);
+    expect(result.offset).toBeCloseTo(0);
+
+    result = mkTime('1').multiply(2.5);
+    expect(result.base).toBe(2n);
+    expect(result.offset).toBeCloseTo(0.5);
+
+    result = mkTime('-0.5').multiply(2);
+    expect(result.base).toBe(-1n);
+    expect(result.offset).toBeCloseTo(0.0);
+
+    result = mkTime('123.1').multiply(25.5);
+    expect(result.base).toBe(3139n);
+    expect(result.offset).toBeCloseTo(0.05);
+  });
+
+  it('should convert to string', () => {
+    expect(mkTime('1.3').toString()).toBe('1.3');
+    expect(mkTime('12983423847.332533').toString()).toBe('12983423847.332533');
+    expect(new HPTime(234n).toString()).toBe('234');
+  });
+});
+
+describe('HighPrecisionTimeSpan', () => {
+  it('can be constructed from HP time', () => {
+    const span = new HPTimeSpan(mkTime('10'), mkTime('20'));
+    expect(span.start).toEqual(mkTime('10'));
+    expect(span.end).toEqual(mkTime('20'));
+  });
+
+  it('can be constructed from integer time', () => {
+    const span = new HPTimeSpan(10n, 20n);
+    expect(span.start).toEqual(mkTime('10'));
+    expect(span.end).toEqual(mkTime('20'));
+  });
+
+  it('throws when start is later than end', () => {
+    expect(() => new HPTimeSpan(mkTime('0.1'), mkTime('0'))).toThrow();
+    expect(() => new HPTimeSpan(mkTime('1124.0001'), mkTime('1124'))).toThrow();
+  });
+
+  it('can calc duration', () => {
+    let dur = mkSpan('10', '20').duration;
+    expect(dur.base).toBe(10n);
+    expect(dur.offset).toBeCloseTo(0);
+
+    dur = mkSpan('10.123', '20.456').duration;
+    expect(dur.base).toBe(10n);
+    expect(dur.offset).toBeCloseTo(0.333);
+  });
+
+  it('can calc midpoint', () => {
+    let mid = mkSpan('10', '20').midpoint;
+    expect(mid.base).toBe(15n);
+    expect(mid.offset).toBeCloseTo(0);
+
+    mid = mkSpan('10.25', '16.75').midpoint;
+    expect(mid.base).toBe(13n);
+    expect(mid.offset).toBeCloseTo(0.5);
+  });
+
+  it('can be compared', () => {
+    expect(mkSpan('0.1', '34.2').equals(mkSpan('0.1', '34.2'))).toBeTruthy();
+    expect(mkSpan('0.1', '34.5').equals(mkSpan('0.1', '34.2'))).toBeFalsy();
+    expect(mkSpan('0.9', '34.2').equals(mkSpan('0.1', '34.2'))).toBeFalsy();
+  });
+
+  it('checks if span contains another span', () => {
+    const x = mkSpan('10', '20');
+
+    expect(x.contains(mkTime('9'))).toBeFalsy();
+    expect(x.contains(mkTime('10'))).toBeTruthy();
+    expect(x.contains(mkTime('15'))).toBeTruthy();
+    expect(x.contains(mkTime('20'))).toBeFalsy();
+    expect(x.contains(mkTime('21'))).toBeFalsy();
+
+    expect(x.contains(mkSpan('12', '18'))).toBeTruthy();
+    expect(x.contains(mkSpan('5', '25'))).toBeFalsy();
+    expect(x.contains(mkSpan('5', '15'))).toBeFalsy();
+    expect(x.contains(mkSpan('15', '25'))).toBeFalsy();
+    expect(x.contains(mkSpan('0', '10'))).toBeFalsy();
+    expect(x.contains(mkSpan('20', '30'))).toBeFalsy();
+  });
+
+  it('checks if span intersects another span', () => {
+    const x = mkSpan('10', '20');
+
+    expect(x.intersects(mkSpan('0', '10'))).toBeFalsy();
+    expect(x.intersects(mkSpan('5', '15'))).toBeTruthy();
+    expect(x.intersects(mkSpan('12', '18'))).toBeTruthy();
+    expect(x.intersects(mkSpan('15', '25'))).toBeTruthy();
+    expect(x.intersects(mkSpan('20', '30'))).toBeFalsy();
+    expect(x.intersects(mkSpan('5', '25'))).toBeTruthy();
+  });
+});
diff --git a/ui/src/common/logs.ts b/ui/src/common/logs.ts
index 0fc2fae..04cf065 100644
--- a/ui/src/common/logs.ts
+++ b/ui/src/common/logs.ts
@@ -12,6 +12,8 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {TPTime} from './time';
+
 export const LogExistsKey = 'log-exists';
 export const LogBoundsKey = 'log-bounds';
 export const LogEntriesKey = 'log-entries';
@@ -19,16 +21,16 @@
 export interface LogExists { exists: boolean; }
 
 export interface LogBounds {
-  startTs: number;
-  endTs: number;
-  firstRowTs: number;
-  lastRowTs: number;
-  total: number;
+  firstLogTs: TPTime;
+  lastLogTs: TPTime;
+  firstVisibleLogTs: TPTime;
+  lastVisibleLogTs: TPTime;
+  totalVisibleLogs: number;
 }
 
 export interface LogEntries {
   offset: number;
-  timestamps: number[];
+  timestamps: TPTime[];
   priorities: number[];
   tags: string[];
   messages: string[];
diff --git a/ui/src/common/plugin_api.ts b/ui/src/common/plugin_api.ts
index 89c1af7..0dc66d6 100644
--- a/ui/src/common/plugin_api.ts
+++ b/ui/src/common/plugin_api.ts
@@ -19,6 +19,8 @@
 
 export {EngineProxy} from '../common/engine';
 export {
+  LONG,
+  LONG_NULL,
   NUM,
   NUM_NULL,
   STR,
diff --git a/ui/src/common/state.ts b/ui/src/common/state.ts
index 0a0c772..91ca358 100644
--- a/ui/src/common/state.ts
+++ b/ui/src/common/state.ts
@@ -21,6 +21,7 @@
 import {TopLevelScrollSelection} from '../tracks/scroll_jank/scroll_track';
 
 import {Direction} from './event_set';
+import {TPDuration, TPTime} from './time';
 
 /**
  * A plain js object, holding objects of type |Class| keyed by string id.
@@ -41,9 +42,9 @@
 }
 
 export interface VisibleState extends Timestamped {
-  startSec: number;
-  endSec: number;
-  resolution: number;
+  start: TPTime;
+  end: TPTime;
+  resolution: TPDuration;
 }
 
 export interface AreaSelection {
@@ -61,8 +62,8 @@
 export type AreaById = Area&{id: string};
 
 export interface Area {
-  startSec: number;
-  endSec: number;
+  start: TPTime;
+  end: TPTime;
   tracks: string[];
 }
 
@@ -102,7 +103,8 @@
 // 28. Add a boolean indicating if non matching log entries are hidden.
 // 29. Add ftrace state. <-- Borked, state contains a non-serializable object.
 // 30. Convert ftraceFilter.excludedNames from Set<string> to string[].
-export const STATE_VERSION = 30;
+// 31. Convert all timestamps to bigints.
+export const STATE_VERSION = 31;
 
 export const SCROLLING_TRACK_GROUP = 'ScrollingTracks';
 
@@ -270,8 +272,8 @@
 }
 
 export interface TraceTime {
-  startSec: number;
-  endSec: number;
+  start: TPTime;
+  end: TPTime;
 }
 
 export interface FrontendLocalState {
@@ -286,7 +288,7 @@
 export interface Note {
   noteType: 'DEFAULT';
   id: string;
-  timestamp: number;
+  timestamp: TPTime;
   color: string;
   text: string;
 }
@@ -313,14 +315,14 @@
   kind: 'DEBUG_SLICE';
   id: number;
   sqlTableName: string;
-  startS: number;
-  durationS: number;
+  start: TPTime;
+  duration: TPDuration;
 }
 
 export interface CounterSelection {
   kind: 'COUNTER';
-  leftTs: number;
-  rightTs: number;
+  leftTs: TPTime;
+  rightTs: TPTime;
   id: number;
 }
 
@@ -328,7 +330,7 @@
   kind: 'HEAP_PROFILE';
   id: number;
   upid: number;
-  ts: number;
+  ts: TPTime;
   type: ProfileType;
 }
 
@@ -336,16 +338,16 @@
   kind: 'PERF_SAMPLES';
   id: number;
   upid: number;
-  leftTs: number;
-  rightTs: number;
+  leftTs: TPTime;
+  rightTs: TPTime;
   type: ProfileType;
 }
 
 export interface FlamegraphState {
   kind: 'FLAMEGRAPH_STATE';
   upids: number[];
-  startNs: number;
-  endNs: number;
+  start: TPTime;
+  end: TPTime;
   type: ProfileType;
   viewingOption: FlamegraphStateViewingOption;
   focusRegex: string;
@@ -572,8 +574,8 @@
   // Hovered and focused events
   hoveredUtid: number;
   hoveredPid: number;
-  hoverCursorTimestamp: number;
-  hoveredNoteTimestamp: number;
+  hoverCursorTimestamp: TPTime;
+  hoveredNoteTimestamp: TPTime;
   highlightedSliceId: number;
   focusedFlowIdLeft: number;
   focusedFlowIdRight: number;
@@ -610,8 +612,8 @@
 }
 
 export const defaultTraceTime = {
-  startSec: 0,
-  endSec: 10,
+  start: 0n,
+  end: BigInt(10e9),
 };
 
 export declare type RecordMode =
diff --git a/ui/src/common/time.ts b/ui/src/common/time.ts
index ea5e9d8..5608d3f 100644
--- a/ui/src/common/time.ts
+++ b/ui/src/common/time.ts
@@ -13,8 +13,7 @@
 // limitations under the License.
 
 import {assertTrue} from '../base/logging';
-
-const EPSILON = 0.0000000001;
+import {ColumnType} from './query_result';
 
 // TODO(hjd): Combine with timeToCode.
 export function timeToString(sec: number) {
@@ -29,6 +28,10 @@
   return `${sign < 0 ? '-' : ''}${Math.round(n * 10) / 10} ${units[u]}`;
 }
 
+export function tpTimeToString(time: TPTime) {
+  return timeToString(tpTimeToSeconds(time));
+}
+
 export function fromNs(ns: number) {
   return ns / 1e9;
 }
@@ -52,6 +55,10 @@
   return parts.join('.');
 }
 
+export function formatTPTime(time: TPTime) {
+  return formatTimestamp(tpTimeToSeconds(time));
+}
+
 // TODO(hjd): Rename to formatTimestampWithUnits
 // 1000000023ns -> "1s 23ns"
 export function timeToCode(sec: number): string {
@@ -77,44 +84,128 @@
   return result.slice(0, -1);
 }
 
+export function tpTimeToCode(time: TPTime) {
+  return timeToCode(tpTimeToSeconds(time));
+}
+
 export function currentDateHourAndMinute(): string {
   const date = new Date();
   return `${date.toISOString().substr(0, 10)}-${date.getHours()}-${
       date.getMinutes()}`;
 }
 
-export class TimeSpan {
-  readonly start: number;
-  readonly end: number;
+// Aliased "Trace Processor" time and duration types.
+// Note(stevegolton): While it might be nice to type brand these in the future,
+// for now we're going to keep things simple. We do a lot of maths with these
+// timestamps and type branding requires a lot of jumping through hoops to
+// coerse the type back to the correct format.
+export type TPTime = bigint;
+export type TPDuration = bigint;
 
-  constructor(start: number, end: number) {
-    assertTrue(start <= end);
+export function tpTimeFromNanos(nanos: number): TPTime {
+  return BigInt(Math.floor(nanos));
+}
+
+export function tpTimeFromSeconds(seconds: number): TPTime {
+  return BigInt(Math.floor(seconds * 1e9));
+}
+
+export function tpTimeToNanos(time: TPTime): number {
+  return Number(time);
+}
+
+export function tpTimeToMillis(time: TPTime): number {
+  return Number(time) / 1e6;
+}
+
+export function tpTimeToSeconds(time: TPTime): number {
+  return Number(time) / 1e9;
+}
+
+// Create a TPTime from an arbitrary SQL value.
+// Throws if the value cannot be reasonably converted to a bigint.
+// Assumes value is in nanoseconds.
+export function tpTimeFromSql(value: ColumnType): TPTime {
+  if (typeof value === 'bigint') {
+    return value;
+  } else if (typeof value === 'number') {
+    return tpTimeFromNanos(value);
+  } else if (value === null) {
+    return 0n;
+  } else {
+    throw Error(`Refusing to create Timestamp from unrelated type ${value}`);
+  }
+}
+
+export function tpDurationToSeconds(dur: TPDuration): number {
+  return tpTimeToSeconds(dur);
+}
+
+export function tpDurationToNanos(dur: TPDuration): number {
+  return tpTimeToSeconds(dur);
+}
+
+export function tpDurationFromNanos(nanos: number): TPDuration {
+  return tpTimeFromNanos(nanos);
+}
+
+export function tpDurationFromSql(nanos: ColumnType): TPDuration {
+  return tpTimeFromSql(nanos);
+}
+
+export interface Span<Unit, Duration = Unit> {
+  get start(): Unit;
+  get end(): Unit;
+  get duration(): Duration;
+  get midpoint(): Unit;
+  contains(span: Unit|Span<Unit, Duration>): boolean;
+  intersects(x: Span<Unit>): boolean;
+  equals(span: Span<Unit, Duration>): boolean;
+  add(offset: Duration): Span<Unit, Duration>;
+  pad(padding: Duration): Span<Unit, Duration>;
+}
+
+export class TPTimeSpan implements Span<TPTime, TPDuration> {
+  readonly start: TPTime;
+  readonly end: TPTime;
+
+  constructor(start: TPTime, end: TPTime) {
+    assertTrue(
+        start <= end,
+        `Span start [${start}] cannot be greater than end [${end}]`);
     this.start = start;
     this.end = end;
   }
 
-  clone() {
-    return new TimeSpan(this.start, this.end);
-  }
-
-  equals(other: TimeSpan): boolean {
-    return Math.abs(this.start - other.start) < EPSILON &&
-        Math.abs(this.end - other.end) < EPSILON;
-  }
-
-  get duration() {
+  get duration(): TPDuration {
     return this.end - this.start;
   }
 
-  isInBounds(sec: number) {
-    return this.start <= sec && sec <= this.end;
+  get midpoint(): TPTime {
+    return (this.start + this.end) / 2n;
   }
 
-  add(sec: number): TimeSpan {
-    return new TimeSpan(this.start + sec, this.end + sec);
+  contains(x: TPTime|Span<TPTime, TPDuration>): boolean {
+    if (typeof x === 'bigint') {
+      return this.start <= x && x < this.end;
+    } else {
+      return this.start <= x.start && x.end <= this.end;
+    }
   }
 
-  contains(other: TimeSpan) {
-    return this.start <= other.start && other.end <= this.end;
+  intersects(x: Span<TPTime, TPDuration>): boolean {
+    return !(x.end <= this.start || x.start >= this.end);
+  }
+
+  equals(span: Span<TPTime, TPDuration>): boolean {
+    return this.start === span.start && this.end === span.end;
+  }
+
+  add(x: TPTime): Span<TPTime, TPDuration> {
+    return new TPTimeSpan(this.start + x, this.end + x);
+  }
+
+  pad(padding: TPDuration): Span<TPTime, TPDuration> {
+    return new TPTimeSpan(this.start - padding, this.end + padding);
   }
 }
diff --git a/ui/src/common/time_unittest.ts b/ui/src/common/time_unittest.ts
index 7bfead1..b9e6bd9 100644
--- a/ui/src/common/time_unittest.ts
+++ b/ui/src/common/time_unittest.ts
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {TimeSpan, timeToCode} from './time';
+import {timeToCode, TPTime, TPTimeSpan} from './time';
 
 test('seconds to code', () => {
   expect(timeToCode(3)).toEqual('3s');
@@ -29,9 +29,67 @@
   expect(timeToCode(0)).toEqual('0s');
 });
 
-test('Time span equality', () => {
-  expect((new TimeSpan(0, 1)).equals(new TimeSpan(0, 1))).toBe(true);
-  expect((new TimeSpan(0, 1)).equals(new TimeSpan(0, 2))).toBe(false);
-  expect((new TimeSpan(0, 1)).equals(new TimeSpan(0, 1 + Number.EPSILON)))
-      .toBe(true);
+function mkSpan(start: TPTime, end: TPTime) {
+  return new TPTimeSpan(start, end);
+}
+
+describe('TPTimeSpan', () => {
+  it('throws when start is later than end', () => {
+    expect(() => mkSpan(1n, 0n)).toThrow();
+  });
+
+  it('can calc duration', () => {
+    expect(mkSpan(10n, 20n).duration).toBe(10n);
+  });
+
+  it('can calc midpoint', () => {
+    expect(mkSpan(10n, 20n).midpoint).toBe(15n);
+    expect(mkSpan(10n, 19n).midpoint).toBe(14n);
+    expect(mkSpan(10n, 10n).midpoint).toBe(10n);
+  });
+
+  it('can be compared', () => {
+    const x = mkSpan(10n, 20n);
+    expect(x.equals(mkSpan(10n, 20n))).toBeTruthy();
+    expect(x.equals(mkSpan(11n, 20n))).toBeFalsy();
+    expect(x.equals(mkSpan(10n, 19n))).toBeFalsy();
+  });
+
+  it('checks containment', () => {
+    const x = mkSpan(10n, 20n);
+
+    expect(x.contains(9n)).toBeFalsy();
+    expect(x.contains(10n)).toBeTruthy();
+    expect(x.contains(15n)).toBeTruthy();
+    expect(x.contains(20n)).toBeFalsy();
+    expect(x.contains(21n)).toBeFalsy();
+
+    expect(x.contains(mkSpan(12n, 18n))).toBeTruthy();
+    expect(x.contains(mkSpan(5n, 25n))).toBeFalsy();
+    expect(x.contains(mkSpan(5n, 15n))).toBeFalsy();
+    expect(x.contains(mkSpan(15n, 25n))).toBeFalsy();
+    expect(x.contains(mkSpan(0n, 10n))).toBeFalsy();
+    expect(x.contains(mkSpan(20n, 30n))).toBeFalsy();
+  });
+
+  it('checks intersection', () => {
+    const x = mkSpan(10n, 20n);
+
+    expect(x.intersects(mkSpan(0n, 10n))).toBeFalsy();
+    expect(x.intersects(mkSpan(5n, 15n))).toBeTruthy();
+    expect(x.intersects(mkSpan(12n, 18n))).toBeTruthy();
+    expect(x.intersects(mkSpan(15n, 25n))).toBeTruthy();
+    expect(x.intersects(mkSpan(20n, 30n))).toBeFalsy();
+    expect(x.intersects(mkSpan(5n, 25n))).toBeTruthy();
+  });
+
+  it('can add', () => {
+    const x = mkSpan(10n, 20n);
+    expect(x.add(5n)).toEqual(mkSpan(15n, 25n));
+  });
+
+  it('can pad', () => {
+    const x = mkSpan(10n, 20n);
+    expect(x.pad(5n)).toEqual(mkSpan(5n, 25n));
+  });
 });
diff --git a/ui/src/common/track_data.ts b/ui/src/common/track_data.ts
index 9af7fff..a49a56d 100644
--- a/ui/src/common/track_data.ts
+++ b/ui/src/common/track_data.ts
@@ -12,12 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {TPDuration, TPTime} from './time';
+
 // TODO(hjd): Refactor into method on TrackController
 export const LIMIT = 10000;
 
 export interface TrackData {
-  start: number;
-  end: number;
-  resolution: number;
+  start: TPTime;
+  end: TPTime;
+  resolution: TPDuration;
   length: number;
 }
diff --git a/ui/src/controller/aggregation/counter_aggregation_controller.ts b/ui/src/controller/aggregation/counter_aggregation_controller.ts
index dd7f14a..e632f8e 100644
--- a/ui/src/controller/aggregation/counter_aggregation_controller.ts
+++ b/ui/src/controller/aggregation/counter_aggregation_controller.ts
@@ -15,7 +15,7 @@
 import {ColumnDef} from '../../common/aggregation_data';
 import {Engine} from '../../common/engine';
 import {Area, Sorting} from '../../common/state';
-import {toNs} from '../../common/time';
+import {tpDurationToSeconds} from '../../common/time';
 import {globals} from '../../frontend/globals';
 import {Config, COUNTER_TRACK_KIND} from '../../tracks/counter';
 
@@ -38,21 +38,22 @@
       }
     }
     if (ids.length === 0) return false;
+    const duration = area.end - area.start;
+    const durationSec = tpDurationToSeconds(duration);
 
     const query = `create view ${this.kind} as select
     name,
     count(1) as count,
-    round(sum(weighted_value)/${
-        toNs(area.endSec) - toNs(area.startSec)}, 2) as avg_value,
+    round(sum(weighted_value)/${duration}, 2) as avg_value,
     last as last_value,
     first as first_value,
     max(last) - min(first) as delta_value,
-    round((max(last) - min(first))/${area.endSec - area.startSec}, 2) as rate,
+    round((max(last) - min(first))/${durationSec}, 2) as rate,
     min(value) as min_value,
     max(value) as max_value
     from
         (select *,
-        (min(ts + dur, ${toNs(area.endSec)}) - max(ts,${toNs(area.startSec)}))
+        (min(ts + dur, ${area.end}) - max(ts,${area.start}))
         * value as weighted_value,
         first_value(value) over
         (partition by track_id order by ts) as first,
@@ -61,8 +62,8 @@
             range between unbounded preceding and unbounded following) as last
         from experimental_counter_dur
         where track_id in (${ids})
-        and ts + dur >= ${toNs(area.startSec)} and
-        ts <= ${toNs(area.endSec)})
+        and ts + dur >= ${area.start} and
+        ts <= ${area.end})
     join counter_track
     on track_id = counter_track.id
     group by track_id`;
diff --git a/ui/src/controller/aggregation/cpu_aggregation_controller.ts b/ui/src/controller/aggregation/cpu_aggregation_controller.ts
index 94d3950..452ca75 100644
--- a/ui/src/controller/aggregation/cpu_aggregation_controller.ts
+++ b/ui/src/controller/aggregation/cpu_aggregation_controller.ts
@@ -15,7 +15,6 @@
 import {ColumnDef} from '../../common/aggregation_data';
 import {Engine} from '../../common/engine';
 import {Area, Sorting} from '../../common/state';
-import {toNs} from '../../common/time';
 import {globals} from '../../frontend/globals';
 import {Config, CPU_SLICE_TRACK_KIND} from '../../tracks/cpu_slices';
 
@@ -46,8 +45,8 @@
         JOIN thread_state USING(utid)
         WHERE cpu IN (${selectedCpus}) AND
         state = "Running" AND
-        thread_state.ts + thread_state.dur > ${toNs(area.startSec)} AND
-        thread_state.ts < ${toNs(area.endSec)} group by utid`;
+        thread_state.ts + thread_state.dur > ${area.start} AND
+        thread_state.ts < ${area.end} group by utid`;
 
     await engine.query(query);
     return true;
diff --git a/ui/src/controller/aggregation/cpu_by_process_aggregation_controller.ts b/ui/src/controller/aggregation/cpu_by_process_aggregation_controller.ts
index b28e496..fc24d1e 100644
--- a/ui/src/controller/aggregation/cpu_by_process_aggregation_controller.ts
+++ b/ui/src/controller/aggregation/cpu_by_process_aggregation_controller.ts
@@ -15,7 +15,6 @@
 import {ColumnDef} from '../../common/aggregation_data';
 import {Engine} from '../../common/engine';
 import {Area, Sorting} from '../../common/state';
-import {toNs} from '../../common/time';
 import {globals} from '../../frontend/globals';
 import {Config, CPU_SLICE_TRACK_KIND} from '../../tracks/cpu_slices';
 
@@ -45,8 +44,8 @@
         JOIN thread_state USING(utid)
         WHERE cpu IN (${selectedCpus}) AND
         state = "Running" AND
-        thread_state.ts + thread_state.dur > ${toNs(area.startSec)} AND
-        thread_state.ts < ${toNs(area.endSec)} group by upid`;
+        thread_state.ts + thread_state.dur > ${area.start} AND
+        thread_state.ts < ${area.end} group by upid`;
 
     await engine.query(query);
     return true;
diff --git a/ui/src/controller/aggregation/frame_aggregation_controller.ts b/ui/src/controller/aggregation/frame_aggregation_controller.ts
index 97e1f86..a22a83d 100644
--- a/ui/src/controller/aggregation/frame_aggregation_controller.ts
+++ b/ui/src/controller/aggregation/frame_aggregation_controller.ts
@@ -15,7 +15,6 @@
 import {ColumnDef} from '../../common/aggregation_data';
 import {Engine} from '../../common/engine';
 import {Area, Sorting} from '../../common/state';
-import {toNs} from '../../common/time';
 import {globals} from '../../frontend/globals';
 import {
   ACTUAL_FRAMES_SLICE_TRACK_KIND,
@@ -48,8 +47,8 @@
         MAX(dur) as maxDur
         FROM actual_frame_timeline_slice
         WHERE track_id IN (${selectedSqlTrackIds}) AND
-        ts + dur > ${toNs(area.startSec)} AND
-        ts < ${toNs(area.endSec)} group by jank_type`;
+        ts + dur > ${area.start} AND
+        ts < ${area.end} group by jank_type`;
 
     await engine.query(query);
     return true;
diff --git a/ui/src/controller/aggregation/slice_aggregation_controller.ts b/ui/src/controller/aggregation/slice_aggregation_controller.ts
index ebb8000..8dcaccc 100644
--- a/ui/src/controller/aggregation/slice_aggregation_controller.ts
+++ b/ui/src/controller/aggregation/slice_aggregation_controller.ts
@@ -15,7 +15,6 @@
 import {ColumnDef} from '../../common/aggregation_data';
 import {Engine} from '../../common/engine';
 import {Area, Sorting} from '../../common/state';
-import {toNs} from '../../common/time';
 import {globals} from '../../frontend/globals';
 import {
   ASYNC_SLICE_TRACK_KIND,
@@ -64,8 +63,8 @@
         count(1) as occurrences
         FROM slices
         WHERE track_id IN (${selectedTrackIds}) AND
-        ts + dur > ${toNs(area.startSec)} AND
-        ts < ${toNs(area.endSec)} group by name`;
+        ts + dur > ${area.start} AND
+        ts < ${area.end} group by name`;
 
     await engine.query(query);
     return true;
diff --git a/ui/src/controller/aggregation/thread_aggregation_controller.ts b/ui/src/controller/aggregation/thread_aggregation_controller.ts
index ddfadae..6e288d5 100644
--- a/ui/src/controller/aggregation/thread_aggregation_controller.ts
+++ b/ui/src/controller/aggregation/thread_aggregation_controller.ts
@@ -17,7 +17,6 @@
 import {NUM, NUM_NULL, STR_NULL} from '../../common/query_result';
 import {Area, Sorting} from '../../common/state';
 import {translateState} from '../../common/thread_state';
-import {toNs} from '../../common/time';
 import {globals} from '../../frontend/globals';
 import {
   Config,
@@ -60,8 +59,8 @@
       JOIN thread USING(upid)
       JOIN thread_state USING(utid)
       WHERE utid IN (${this.utids}) AND
-      thread_state.ts + thread_state.dur > ${toNs(area.startSec)} AND
-      thread_state.ts < ${toNs(area.endSec)}
+      thread_state.ts + thread_state.dur > ${area.start} AND
+      thread_state.ts < ${area.end}
       GROUP BY utid, concat_state
     `;
 
@@ -78,8 +77,8 @@
       JOIN thread USING(upid)
       JOIN thread_state USING(utid)
       WHERE utid IN (${this.utids}) AND thread_state.ts + thread_state.dur > ${
-            toNs(area.startSec)} AND
-      thread_state.ts < ${toNs(area.endSec)}
+            area.start} AND
+      thread_state.ts < ${area.end}
       GROUP BY state, io_wait`;
     const result = await engine.query(query);
 
diff --git a/ui/src/controller/area_selection_handler.ts b/ui/src/controller/area_selection_handler.ts
index b1d1c7e..32dcb11 100644
--- a/ui/src/controller/area_selection_handler.ts
+++ b/ui/src/controller/area_selection_handler.ts
@@ -36,10 +36,10 @@
       // where `a ||= b` is formatted to `a || = b`, by inserting a space which
       // breaks the operator.
       // Therefore, we are using the pattern `a = a || b` instead.
-      hasAreaChanged = hasAreaChanged ||
-          selectedArea.startSec !== this.previousArea.startSec;
       hasAreaChanged =
-          hasAreaChanged || selectedArea.endSec !== this.previousArea.endSec;
+          hasAreaChanged || selectedArea.start !== this.previousArea.start;
+      hasAreaChanged =
+          hasAreaChanged || selectedArea.end !== this.previousArea.end;
       hasAreaChanged = hasAreaChanged ||
           selectedArea.tracks.length !== this.previousArea.tracks.length;
       for (let i = 0; i < selectedArea.tracks.length; ++i) {
diff --git a/ui/src/controller/area_selection_handler_unittest.ts b/ui/src/controller/area_selection_handler_unittest.ts
index c5a27c0..caac678 100644
--- a/ui/src/controller/area_selection_handler_unittest.ts
+++ b/ui/src/controller/area_selection_handler_unittest.ts
@@ -20,7 +20,7 @@
 
 test('validAreaAfterUndefinedArea', () => {
   const areaId = '0';
-  const latestArea: AreaById = {startSec: 0, endSec: 1, tracks: [], id: areaId};
+  const latestArea: AreaById = {start: 0n, end: 1n, tracks: [], id: areaId};
   globals.state = createEmptyState();
   globals.state.currentSelection = {kind: 'AREA', areaId};
   globals.state.areas[areaId] = latestArea;
@@ -35,7 +35,7 @@
 test('UndefinedAreaAfterValidArea', () => {
   const previousAreaId = '0';
   const previous:
-      AreaById = {startSec: 0, endSec: 1, tracks: [], id: previousAreaId};
+      AreaById = {start: 0n, end: 1n, tracks: [], id: previousAreaId};
   globals.state = createEmptyState();
   globals.state.currentSelection = {
     kind: 'AREA',
@@ -71,7 +71,7 @@
 test('validAreaAfterValidArea', () => {
   const previousAreaId = '0';
   const previous:
-      AreaById = {startSec: 0, endSec: 1, tracks: [], id: previousAreaId};
+      AreaById = {start: 0n, end: 1n, tracks: [], id: previousAreaId};
   globals.state = createEmptyState();
   globals.state.currentSelection = {
     kind: 'AREA',
@@ -82,8 +82,7 @@
   areaSelectionHandler.getAreaChange();
 
   const currentAreaId = '1';
-  const current:
-      AreaById = {startSec: 1, endSec: 2, tracks: [], id: currentAreaId};
+  const current: AreaById = {start: 1n, end: 2n, tracks: [], id: currentAreaId};
   globals.state.currentSelection = {
     kind: 'AREA',
     areaId: currentAreaId,
@@ -98,7 +97,7 @@
 test('sameAreaSelected', () => {
   const previousAreaId = '0';
   const previous:
-      AreaById = {startSec: 0, endSec: 1, tracks: [], id: previousAreaId};
+      AreaById = {start: 0n, end: 1n, tracks: [], id: previousAreaId};
   globals.state = createEmptyState();
   globals.state.currentSelection = {
     kind: 'AREA',
@@ -109,8 +108,7 @@
   areaSelectionHandler.getAreaChange();
 
   const currentAreaId = '0';
-  const current:
-      AreaById = {startSec: 0, endSec: 1, tracks: [], id: currentAreaId};
+  const current: AreaById = {start: 0n, end: 1n, tracks: [], id: currentAreaId};
   globals.state.currentSelection = {
     kind: 'AREA',
     areaId: currentAreaId,
@@ -128,7 +126,7 @@
   areaSelectionHandler.getAreaChange();
 
   globals.state
-      .currentSelection = {kind: 'COUNTER', leftTs: 0, rightTs: 0, id: 1};
+      .currentSelection = {kind: 'COUNTER', leftTs: 0n, rightTs: 0n, id: 1};
   const [hasAreaChanged, selectedArea] = areaSelectionHandler.getAreaChange();
 
   expect(hasAreaChanged).toEqual(false);
diff --git a/ui/src/controller/flamegraph_controller.ts b/ui/src/controller/flamegraph_controller.ts
index e94531d..31a74c3 100644
--- a/ui/src/controller/flamegraph_controller.ts
+++ b/ui/src/controller/flamegraph_controller.ts
@@ -27,7 +27,7 @@
 } from '../common/flamegraph_util';
 import {NUM, STR} from '../common/query_result';
 import {CallsiteInfo, FlamegraphState, ProfileType} from '../common/state';
-import {toNs} from '../common/time';
+import {tpDurationToSeconds, TPTime} from '../common/time';
 import {FlamegraphDetails, globals} from '../frontend/globals';
 import {publishFlamegraphDetails} from '../frontend/publish';
 import {
@@ -145,8 +145,8 @@
       }
       globals.dispatch(Actions.openFlamegraph({
         upids,
-        startNs: toNs(area.startSec),
-        endNs: toNs(area.endSec),
+        start: area.start,
+        end: area.end,
         type: ProfileType.PERF_SAMPLE,
         viewingOption: PERF_SAMPLES_KEY,
       }));
@@ -169,8 +169,8 @@
     const selectedFlamegraphState = {...selection};
     const flamegraphMetadata = await this.getFlamegraphMetadata(
         selection.type,
-        selectedFlamegraphState.startNs,
-        selectedFlamegraphState.endNs,
+        selectedFlamegraphState.start,
+        selectedFlamegraphState.end,
         selectedFlamegraphState.upids);
     if (flamegraphMetadata !== undefined) {
       Object.assign(this.flamegraphDetails, flamegraphMetadata);
@@ -192,7 +192,7 @@
         selectedFlamegraphState.expandedCallsite.totalSize;
 
     const key = `${selectedFlamegraphState.upids};${
-        selectedFlamegraphState.startNs};${selectedFlamegraphState.endNs}`;
+        selectedFlamegraphState.start};${selectedFlamegraphState.end}`;
 
     try {
       const flamegraphData = await this.getFlamegraphData(
@@ -200,15 +200,15 @@
           selectedFlamegraphState.viewingOption ?
               selectedFlamegraphState.viewingOption :
               DEFAULT_VIEWING_OPTION,
-          selection.startNs,
-          selection.endNs,
+          selection.start,
+          selection.end,
           selectedFlamegraphState.upids,
           selectedFlamegraphState.type,
           selectedFlamegraphState.focusRegex);
       if (flamegraphData !== undefined && selection &&
           selection.kind === selectedFlamegraphState.kind &&
-          selection.startNs === selectedFlamegraphState.startNs &&
-          selection.endNs === selectedFlamegraphState.endNs) {
+          selection.start === selectedFlamegraphState.start &&
+          selection.end === selectedFlamegraphState.end) {
         const expandedFlamegraphData =
             expandCallsites(flamegraphData, expandedId);
         this.prepareAndMergeCallsites(
@@ -230,8 +230,8 @@
   private shouldRequestData(selection: FlamegraphState) {
     return selection.kind === 'FLAMEGRAPH_STATE' &&
         (this.lastSelectedFlamegraphState === undefined ||
-         (this.lastSelectedFlamegraphState.startNs !== selection.startNs ||
-          this.lastSelectedFlamegraphState.endNs !== selection.endNs ||
+         (this.lastSelectedFlamegraphState.start !== selection.start ||
+          this.lastSelectedFlamegraphState.end !== selection.end ||
           this.lastSelectedFlamegraphState.type !== selection.type ||
           !FlamegraphController.areArraysEqual(
               this.lastSelectedFlamegraphState.upids, selection.upids) ||
@@ -267,7 +267,7 @@
   }
 
   async getFlamegraphData(
-      baseKey: string, viewingOption: string, startNs: number, endNs: number,
+      baseKey: string, viewingOption: string, start: TPTime, end: TPTime,
       upids: number[], type: ProfileType,
       focusRegex: string): Promise<CallsiteInfo[]> {
     let currentData: CallsiteInfo[];
@@ -280,8 +280,8 @@
       // Collecting data for drawing flamegraph for selected profile.
       // Data needs to be in following format:
       // id, name, parent_id, depth, total_size
-      const tableName = await this.prepareViewsAndTables(
-          startNs, endNs, upids, type, focusRegex);
+      const tableName =
+          await this.prepareViewsAndTables(start, end, upids, type, focusRegex);
       currentData = await this.getFlamegraphDataFromTables(
           tableName, viewingOption, focusRegex);
       this.flamegraphDatasets.set(key, currentData);
@@ -413,7 +413,7 @@
   }
 
   private async prepareViewsAndTables(
-      startNs: number, endNs: number, upids: number[], type: ProfileType,
+      start: TPTime, end: TPTime, upids: number[], type: ProfileType,
       focusRegex: string): Promise<string> {
     // Creating unique names for views so we can reuse and not delete them
     // for each marker.
@@ -437,8 +437,8 @@
           cumulative_alloc_size, cumulative_count, cumulative_alloc_count,
           size, alloc_size, count, alloc_count, source_file, line_number
           from experimental_flamegraph
-          where profile_type = '${flamegraphType}' and ${startNs} <= ts and
-              ts <= ${endNs} and ${upidConditional}
+          where profile_type = '${flamegraphType}' and ${start} <= ts and
+              ts <= ${end} and ${upidConditional}
           ${focusRegexConditional}`);
     }
     return this.cache.getTableName(
@@ -447,7 +447,7 @@
           size, alloc_size, count, alloc_count, source_file, line_number
           from experimental_flamegraph
           where profile_type = '${flamegraphType}'
-            and ts = ${endNs}
+            and ts = ${end}
             and upid = ${upids[0]}
             ${focusRegexConditional}`);
   }
@@ -455,7 +455,8 @@
   getMinSizeDisplayed(flamegraphData: CallsiteInfo[], rootSize?: number):
       number {
     const timeState = globals.state.frontendLocalState.visibleState;
-    let width = (timeState.endSec - timeState.startSec) / timeState.resolution;
+    const dur = globals.stateVisibleTime().duration;
+    let width = tpDurationToSeconds(dur / timeState.resolution);
     // TODO(168048193): Remove screen size hack:
     width = Math.max(width, 800);
     if (rootSize === undefined) {
@@ -465,11 +466,12 @@
   }
 
   async getFlamegraphMetadata(
-      type: ProfileType, startNs: number, endNs: number, upids: number[]) {
+      type: ProfileType, start: TPTime, end: TPTime,
+      upids: number[]): Promise<FlamegraphDetails|undefined> {
     // Don't do anything if selection of the marker stayed the same.
     if ((this.lastSelectedFlamegraphState !== undefined &&
-         ((this.lastSelectedFlamegraphState.startNs === startNs &&
-           this.lastSelectedFlamegraphState.endNs === endNs &&
+         ((this.lastSelectedFlamegraphState.start === start &&
+           this.lastSelectedFlamegraphState.end === end &&
            FlamegraphController.areArraysEqual(
                this.lastSelectedFlamegraphState.upids, upids))))) {
       return undefined;
@@ -486,7 +488,7 @@
     for (let i = 0; it.valid(); ++i, it.next()) {
       pids.push(it.pid);
     }
-    return {startNs, durNs: endNs - startNs, pids, upids, type};
+    return {start, dur: end - start, pids, upids, type};
   }
 
   private static areArraysEqual(a: number[], b: number[]) {
diff --git a/ui/src/controller/flow_events_controller.ts b/ui/src/controller/flow_events_controller.ts
index d89b514..07125e1 100644
--- a/ui/src/controller/flow_events_controller.ts
+++ b/ui/src/controller/flow_events_controller.ts
@@ -16,7 +16,7 @@
 import {featureFlags} from '../common/feature_flags';
 import {NUM, STR_NULL} from '../common/query_result';
 import {Area} from '../common/state';
-import {fromNs, toNs} from '../common/time';
+import {fromNs} from '../common/time';
 import {Flow, globals} from '../frontend/globals';
 import {publishConnectedFlows, publishSelectedFlows} from '../frontend/publish';
 import {
@@ -241,8 +241,8 @@
     const area = globals.state.areas[areaId];
     if (this.lastSelectedKind === 'AREA' && this.lastSelectedArea &&
         this.lastSelectedArea.tracks.join(',') === area.tracks.join(',') &&
-        this.lastSelectedArea.endSec === area.endSec &&
-        this.lastSelectedArea.startSec === area.startSec) {
+        this.lastSelectedArea.end === area.end &&
+        this.lastSelectedArea.start === area.start) {
       return;
     }
 
@@ -268,8 +268,8 @@
 
     const tracks = `(${trackIds.join(',')})`;
 
-    const startNs = toNs(area.startSec);
-    const endNs = toNs(area.endSec);
+    const startNs = area.start;
+    const endNs = area.end;
 
     const query = `
     select
diff --git a/ui/src/controller/ftrace_controller.ts b/ui/src/controller/ftrace_controller.ts
index f558a4a..a071ec5 100644
--- a/ui/src/controller/ftrace_controller.ts
+++ b/ui/src/controller/ftrace_controller.ts
@@ -13,9 +13,13 @@
 // limitations under the License.
 
 import {Engine} from '../common/engine';
-import {NUM, STR, STR_NULL} from '../common/query_result';
+import {
+  HighPrecisionTime,
+  HighPrecisionTimeSpan,
+} from '../common/high_precision_time';
+import {LONG, NUM, STR, STR_NULL} from '../common/query_result';
 import {FtraceFilterState, Pagination} from '../common/state';
-import {TimeSpan, toNsCeil, toNsFloor} from '../common/time';
+import {Span} from '../common/time';
 import {FtraceEvent, globals} from '../frontend/globals';
 import {publishFtracePanelData} from '../frontend/publish';
 import {ratelimit} from '../frontend/rate_limiters';
@@ -34,7 +38,7 @@
 
 export class FtraceController extends Controller<'main'> {
   private engine: Engine;
-  private oldSpan: TimeSpan = new TimeSpan(0, 0);
+  private oldSpan: Span<HighPrecisionTime> = HighPrecisionTimeSpan.ZERO;
   private oldFtraceFilter?: FtraceFilterState;
   private oldPagination?: Pagination;
 
@@ -45,7 +49,7 @@
 
   run() {
     if (this.shouldUpdate()) {
-      this.oldSpan = globals.frontendLocalState.visibleWindowTime.clone();
+      this.oldSpan = globals.frontendLocalState.visibleWindowTime;
       this.oldFtraceFilter = globals.state.ftraceFilter;
       this.oldPagination = globals.state.ftracePagination;
       if (globals.state.ftracePagination.count > 0) {
@@ -66,8 +70,7 @@
   private shouldUpdate(): boolean {
     // Has the visible window moved?
     const visibleWindow = globals.frontendLocalState.visibleWindowTime;
-    if (this.oldSpan.start !== visibleWindow.start ||
-        this.oldSpan.end !== visibleWindow.end) {
+    if (!this.oldSpan.equals(visibleWindow)) {
       return true;
     }
 
@@ -89,8 +92,8 @@
     const frontendState = globals.frontendLocalState;
     const {start, end} = frontendState.visibleWindowTime;
 
-    const startNs = toNsFloor(start);
-    const endNs = toNsCeil(end);
+    const startNs = start.nanos;
+    const endNs = end.nanos;
 
     const excludeList = appState.ftraceFilter.excludedNames;
     const excludeListSql = excludeList.map((s) => `'${s}'`).join(',');
@@ -132,7 +135,7 @@
     const it = queryRes.iter(
         {
           id: NUM,
-          ts: NUM,
+          ts: LONG,
           name: STR,
           cpu: NUM,
           thread: STR_NULL,
diff --git a/ui/src/controller/logs_controller.ts b/ui/src/controller/logs_controller.ts
index d8f9b3a..c931ce0 100644
--- a/ui/src/controller/logs_controller.ts
+++ b/ui/src/controller/logs_controller.ts
@@ -12,6 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {BigintMath} from '../base/bigint_math';
 import {Engine} from '../common/engine';
 import {
   LogBounds,
@@ -20,62 +21,61 @@
   LogEntriesKey,
   LogExistsKey,
 } from '../common/logs';
-import {NUM, STR} from '../common/query_result';
+import {LONG, LONG_NULL, NUM, STR} from '../common/query_result';
 import {escapeGlob, escapeQuery} from '../common/query_utils';
 import {LogFilteringCriteria} from '../common/state';
-import {fromNs, TimeSpan, toNsCeil, toNsFloor} from '../common/time';
+import {Span} from '../common/time';
+import {
+  TPTime,
+  TPTimeSpan,
+} from '../common/time';
 import {globals} from '../frontend/globals';
 import {publishTrackData} from '../frontend/publish';
 
 import {Controller} from './controller';
 
 async function updateLogBounds(
-    engine: Engine, span: TimeSpan): Promise<LogBounds> {
-  const vizStartNs = toNsFloor(span.start);
-  const vizEndNs = toNsCeil(span.end);
+    engine: Engine, span: Span<TPTime>): Promise<LogBounds> {
+  const vizStartNs = span.start;
+  const vizEndNs = span.end;
 
-  const countResult = await engine.query(`select
-      ifnull(min(ts), 0) as minTs,
-      ifnull(max(ts), 0) as maxTs,
-      count(ts) as countTs
-     from filtered_logs
-        where ts >= ${vizStartNs}
-        and ts <= ${vizEndNs}`);
+  const vizFilter = `ts between ${vizStartNs} and ${vizEndNs}`;
 
-  const countRow = countResult.firstRow({minTs: NUM, maxTs: NUM, countTs: NUM});
+  const result = await engine.query(`select
+      min(ts) as minTs,
+      max(ts) as maxTs,
+      min(case when ${vizFilter} then ts end) as minVizTs,
+      max(case when ${vizFilter} then ts end) as maxVizTs,
+      count(case when ${vizFilter} then ts end) as countTs
+    from filtered_logs`);
 
-  const firstRowNs = countRow.minTs;
-  const lastRowNs = countRow.maxTs;
-  const total = countRow.countTs;
+  const data = result.firstRow({
+    minTs: LONG_NULL,
+    maxTs: LONG_NULL,
+    minVizTs: LONG_NULL,
+    maxVizTs: LONG_NULL,
+    countTs: NUM,
+  });
 
-  const minResult = await engine.query(`
-     select ifnull(max(ts), 0) as maxTs from filtered_logs where ts < ${
-      vizStartNs}`);
-  const startNs = minResult.firstRow({maxTs: NUM}).maxTs;
+  const firstLogTs = data.minTs ?? 0n;
+  const lastLogTs = data.maxTs ?? BigintMath.INT64_MAX;
 
-  const maxResult = await engine.query(`
-     select ifnull(min(ts), 0) as minTs from filtered_logs where ts > ${
-      vizEndNs}`);
-  const endNs = maxResult.firstRow({minTs: NUM}).minTs;
-
-  const startTs = startNs ? fromNs(startNs) : 0;
-  const endTs = endNs ? fromNs(endNs) : Number.MAX_SAFE_INTEGER;
-  const firstRowTs = firstRowNs ? fromNs(firstRowNs) : endTs;
-  const lastRowTs = lastRowNs ? fromNs(lastRowNs) : startTs;
-  return {
-    startTs,
-    endTs,
-    firstRowTs,
-    lastRowTs,
-    total,
+  const bounds: LogBounds = {
+    firstLogTs,
+    lastLogTs,
+    firstVisibleLogTs: data.minVizTs ?? firstLogTs,
+    lastVisibleLogTs: data.maxVizTs ?? lastLogTs,
+    totalVisibleLogs: data.countTs,
   };
+
+  return bounds;
 }
 
 async function updateLogEntries(
-    engine: Engine, span: TimeSpan, pagination: Pagination):
+    engine: Engine, span: Span<TPTime>, pagination: Pagination):
     Promise<LogEntries> {
-  const vizStartNs = toNsFloor(span.start);
-  const vizEndNs = toNsCeil(span.end);
+  const vizStartNs = span.start;
+  const vizEndNs = span.end;
   const vizSqlBounds = `ts >= ${vizStartNs} and ts <= ${vizEndNs}`;
 
   const rowsResult = await engine.query(`
@@ -101,7 +101,7 @@
   const processName = [];
 
   const it = rowsResult.iter({
-    ts: NUM,
+    ts: LONG,
     prio: NUM,
     tag: STR,
     msg: STR,
@@ -179,7 +179,7 @@
  */
 export class LogsController extends Controller<'main'> {
   private engine: Engine;
-  private span: TimeSpan;
+  private span: Span<TPTime>;
   private pagination: Pagination;
   private hasLogs = false;
   private logFilteringCriteria?: LogFilteringCriteria;
@@ -189,7 +189,7 @@
   constructor(args: LogsControllerArgs) {
     super('main');
     this.engine = args.engine;
-    this.span = new TimeSpan(0, 10);
+    this.span = new TPTimeSpan(0n, BigInt(10e9));
     this.pagination = new Pagination(0, 0);
     this.hasAnyLogs().then((exists) => {
       this.hasLogs = exists;
@@ -226,8 +226,7 @@
   }
 
   private async updateLogTracks() {
-    const traceTime = globals.state.frontendLocalState.visibleState;
-    const newSpan = new TimeSpan(traceTime.startSec, traceTime.endSec);
+    const newSpan = globals.stateVisibleTime();
     const oldSpan = this.span;
 
     const pagination = globals.state.logsPagination;
diff --git a/ui/src/controller/search_controller.ts b/ui/src/controller/search_controller.ts
index d1df094..c5968c2 100644
--- a/ui/src/controller/search_controller.ts
+++ b/ui/src/controller/search_controller.ts
@@ -12,13 +12,18 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {BigintMath} from '../base/bigint_math';
 import {sqliteString} from '../base/string_utils';
 import {Engine} from '../common/engine';
 import {NUM, STR} from '../common/query_result';
 import {escapeSearchQuery} from '../common/query_utils';
 import {CurrentSearchResults, SearchSummary} from '../common/search_data';
-import {TimeSpan} from '../common/time';
-import {toNs} from '../common/time';
+import {Span} from '../common/time';
+import {
+  TPDuration,
+  TPTime,
+  TPTimeSpan,
+} from '../common/time';
 import {globals} from '../frontend/globals';
 import {publishSearch, publishSearchResult} from '../frontend/publish';
 
@@ -30,8 +35,8 @@
 
 export class SearchController extends Controller<'main'> {
   private engine: Engine;
-  private previousSpan: TimeSpan;
-  private previousResolution: number;
+  private previousSpan: Span<TPTime>;
+  private previousResolution: TPDuration;
   private previousSearch: string;
   private updateInProgress: boolean;
   private setupInProgress: boolean;
@@ -39,11 +44,11 @@
   constructor(args: SearchControllerArgs) {
     super('main');
     this.engine = args.engine;
-    this.previousSpan = new TimeSpan(0, 1);
+    this.previousSpan = new TPTimeSpan(0n, 1n);
     this.previousSearch = '';
     this.updateInProgress = false;
     this.setupInProgress = true;
-    this.previousResolution = 1;
+    this.previousResolution = 1n;
     this.setup().finally(() => {
       this.setupInProgress = false;
       this.run();
@@ -70,9 +75,9 @@
         omniboxState.mode === 'COMMAND') {
       return;
     }
-    const newSpan = new TimeSpan(visibleState.startSec, visibleState.endSec);
+    const newSpan = globals.stateVisibleTime();
     const newSearch = omniboxState.omnibox;
-    let newResolution = visibleState.resolution;
+    const newResolution = visibleState.resolution;
     if (this.previousSpan.contains(newSpan) &&
         this.previousResolution === newResolution &&
         newSearch === this.previousSearch) {
@@ -83,9 +88,8 @@
     // TODO(hjd): We should restrict this to the start of the trace but
     // that is not easily available here.
     // N.B. Timestamps can be negative.
-    const start = newSpan.start - newSpan.duration;
-    const end = newSpan.end + newSpan.duration;
-    this.previousSpan = new TimeSpan(start, end);
+    const {start, end} = newSpan.pad(newSpan.duration);
+    this.previousSpan = new TPTimeSpan(start, end);
     this.previousResolution = newResolution;
     this.previousSearch = newSearch;
     if (newSearch === '' || newSearch.length < 4) {
@@ -105,25 +109,12 @@
       return;
     }
 
-    let startNs = toNs(newSpan.start);
-    let endNs = toNs(newSpan.end);
-
-    // TODO(hjd): We shouldn't need to be so defensive here:
-    if (!Number.isFinite(startNs)) {
-      startNs = 0;
-    }
-    if (!Number.isFinite(endNs)) {
-      endNs = 1;
-    }
-    if (!Number.isFinite(newResolution)) {
-      newResolution = 1;
-    }
-
     this.updateInProgress = true;
-    const computeSummary = this.update(newSearch, startNs, endNs, newResolution)
-                               .then((summary) => {
-                                 publishSearch(summary);
-                               });
+    const computeSummary =
+        this.update(newSearch, newSpan.start, newSpan.end, newResolution)
+            .then((summary) => {
+              publishSearch(summary);
+            });
 
     const computeResults =
         this.specificSearch(newSearch).then((searchResults) => {
@@ -140,15 +131,14 @@
   onDestroy() {}
 
   private async update(
-      search: string, startNs: number, endNs: number,
-      resolution: number): Promise<SearchSummary> {
-    const quantumNs = Math.round(resolution * 10 * 1e9);
-
+      search: string, startNs: TPTime, endNs: TPTime,
+      resolution: TPDuration): Promise<SearchSummary> {
     const searchLiteral = escapeSearchQuery(search);
 
-    startNs = Math.floor(startNs / quantumNs) * quantumNs;
+    const quantumNs = resolution * 10n;
+    startNs = BigintMath.quantizeFloor(startNs, quantumNs);
 
-    const windowDur = Math.max(endNs - startNs, 1);
+    const windowDur = BigintMath.max(endNs - startNs, 1n);
     await this.query(`update search_summary_window set
       window_start=${startNs},
       window_dur=${windowDur},
diff --git a/ui/src/controller/selection_controller.ts b/ui/src/controller/selection_controller.ts
index 66cf3d0..b5a3907 100644
--- a/ui/src/controller/selection_controller.ts
+++ b/ui/src/controller/selection_controller.ts
@@ -16,14 +16,23 @@
 import {Arg, Args} from '../common/arg_types';
 import {Engine} from '../common/engine';
 import {
+  LONG,
   NUM,
   NUM_NULL,
   STR,
   STR_NULL,
 } from '../common/query_result';
 import {ChromeSliceSelection} from '../common/state';
-import {fromNs, toNs} from '../common/time';
-import {SliceDetails, ThreadStateDetails} from '../frontend/globals';
+import {
+  tpDurationFromSql,
+  TPTime,
+  tpTimeFromSql,
+} from '../common/time';
+import {
+  CounterDetails,
+  SliceDetails,
+  ThreadStateDetails,
+} from '../frontend/globals';
 import {globals} from '../frontend/globals';
 import {
   publishCounterDetails,
@@ -176,10 +185,10 @@
         case 'id':
           break;
         case 'ts':
-          ts = fromNs(Number(v)) - globals.state.traceTime.startSec;
+          ts = tpTimeFromSql(v);
           break;
         case 'thread_ts':
-          threadTs = fromNs(Number(v));
+          threadTs = tpTimeFromSql(v);
           break;
         case 'absTime':
           if (v) absTime = `${v}`;
@@ -188,10 +197,10 @@
           name = `${v}`;
           break;
         case 'dur':
-          dur = fromNs(Number(v));
+          dur = tpDurationFromSql(v);
           break;
         case 'thread_dur':
-          threadDur = fromNs(Number(v));
+          threadDur = tpDurationFromSql(v);
           break;
         case 'category':
         case 'cat':
@@ -326,13 +335,13 @@
     const selection = globals.state.currentSelection;
     if (result.numRows() > 0 && selection) {
       const row = result.firstRow({
-        ts: NUM,
-        dur: NUM,
+        ts: LONG,
+        dur: LONG,
       });
-      const ts = row.ts;
-      const timeFromStart = fromNs(ts) - globals.state.traceTime.startSec;
-      const dur = fromNs(row.dur);
-      const selected: ThreadStateDetails = {ts: timeFromStart, dur};
+      const selected: ThreadStateDetails = {
+        ts: row.ts,
+        dur: row.dur,
+      };
       publishThreadStateDetails(selected);
     }
   }
@@ -353,8 +362,8 @@
     const selection = globals.state.currentSelection;
     if (result.numRows() > 0 && selection) {
       const row = result.firstRow({
-        ts: NUM,
-        dur: NUM,
+        ts: LONG,
+        dur: LONG,
         priority: NUM,
         endState: STR_NULL,
         utid: NUM,
@@ -362,15 +371,14 @@
         threadStateId: NUM_NULL,
       });
       const ts = row.ts;
-      const timeFromStart = fromNs(ts) - globals.state.traceTime.startSec;
-      const dur = fromNs(row.dur);
+      const dur = row.dur;
       const priority = row.priority;
       const endState = row.endState;
       const utid = row.utid;
       const cpu = row.cpu;
       const threadStateId = row.threadStateId || undefined;
       const selected: SliceDetails = {
-        ts: timeFromStart,
+        ts,
         dur,
         priority,
         endState,
@@ -391,7 +399,8 @@
     }
   }
 
-  async counterDetails(ts: number, rightTs: number, id: number) {
+  async counterDetails(ts: TPTime, rightTs: TPTime, id: number):
+      Promise<CounterDetails> {
     const counter = await this.args.engine.query(
         `SELECT value, track_id as trackId FROM counter WHERE id = ${id}`);
     const row = counter.iter({
@@ -407,17 +416,15 @@
           IFNULL(value, 0) as value
         FROM counter WHERE ts < ${ts} and track_id = ${trackId}`);
     const previousValue = previous.firstRow({value: NUM}).value;
-    const endTs =
-        rightTs !== -1 ? rightTs : toNs(globals.state.traceTime.endSec);
+    const endTs = rightTs !== -1n ? rightTs : globals.state.traceTime.end;
     const delta = value - previousValue;
     const duration = endTs - ts;
-    const startTime = fromNs(ts) - globals.state.traceTime.startSec;
     const uiTrackId = globals.state.uiTrackIdByTraceTrackId[trackId];
     const name = uiTrackId ? globals.state.tracks[uiTrackId].name : undefined;
-    return {startTime, value, delta, duration, name};
+    return {startTime: ts, value, delta, duration, name};
   }
 
-  async schedulingDetails(ts: number, utid: number|Long) {
+  async schedulingDetails(ts: TPTime, utid: number|Long) {
     // Find the ts of the first wakeup before the current slice.
     const wakeResult = await this.args.engine.query(`
       select ts, waker_utid as wakerUtid
@@ -430,7 +437,7 @@
       return undefined;
     }
 
-    const wakeFirstRow = wakeResult.firstRow({ts: NUM, wakerUtid: NUM_NULL});
+    const wakeFirstRow = wakeResult.firstRow({ts: LONG, wakerUtid: NUM_NULL});
     const wakeupTs = wakeFirstRow.ts;
     const wakerUtid = wakeFirstRow.wakerUtid;
     if (wakerUtid === null) {
@@ -449,7 +456,7 @@
     // If this is the first sched slice for this utid or if the wakeup found
     // was after the previous slice then we know the wakeup was for this slice.
     if (prevSchedResult.numRows() !== 0 &&
-        wakeupTs < prevSchedResult.firstRow({ts: NUM}).ts) {
+        wakeupTs < prevSchedResult.firstRow({ts: LONG}).ts) {
       return undefined;
     }
 
@@ -468,7 +475,7 @@
     }
 
     const wakerRow = wakerResult.firstRow({cpu: NUM});
-    return {wakeupTs: fromNs(wakeupTs), wakerUtid, wakerCpu: wakerRow.cpu};
+    return {wakeupTs, wakerUtid, wakerCpu: wakerRow.cpu};
   }
 
   async computeThreadDetails(utid: number):
diff --git a/ui/src/controller/trace_controller.ts b/ui/src/controller/trace_controller.ts
index 264ce93..f0e2d02 100644
--- a/ui/src/controller/trace_controller.ts
+++ b/ui/src/controller/trace_controller.ts
@@ -12,6 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {BigintMath} from '../base/bigint_math';
 import {assertExists, assertTrue} from '../base/logging';
 import {
   Actions,
@@ -20,15 +21,30 @@
 import {cacheTrace} from '../common/cache_manager';
 import {Engine} from '../common/engine';
 import {featureFlags, Flag, PERF_SAMPLE_FLAG} from '../common/feature_flags';
+import {
+  HighPrecisionTime,
+  HighPrecisionTimeSpan,
+} from '../common/high_precision_time';
 import {HttpRpcEngine} from '../common/http_rpc_engine';
 import {
   getEnabledMetatracingCategories,
   isMetatracingEnabled,
 } from '../common/metatracing';
-import {NUM, NUM_NULL, QueryError, STR, STR_NULL} from '../common/query_result';
+import {
+  LONG,
+  NUM,
+  NUM_NULL,
+  QueryError,
+  STR,
+  STR_NULL,
+} from '../common/query_result';
 import {onSelectionChanged} from '../common/selection_observer';
 import {defaultTraceTime, EngineMode, ProfileType} from '../common/state';
-import {TimeSpan, toNs, toNsCeil, toNsFloor} from '../common/time';
+import {Span} from '../common/time';
+import {
+  TPTime,
+  TPTimeSpan,
+} from '../common/time';
 import {resetEngineWorker, WasmEngineProxy} from '../common/wasm_engine_proxy';
 import {BottomTabList} from '../frontend/bottom_tab';
 import {
@@ -416,11 +432,11 @@
     const traceUuid = await this.cacheCurrentTrace();
 
     const traceTime = await this.engine.getTraceTimeBounds();
-    const startSec = traceTime.start;
-    const endSec = traceTime.end;
+    const start = traceTime.start;
+    const end = traceTime.end;
     const traceTimeState = {
-      startSec,
-      endSec,
+      start,
+      end,
     };
 
     const shownJsonWarning =
@@ -453,16 +469,16 @@
       Actions.setTraceTime(traceTimeState),
     ];
 
-    const [startVisibleTime, endVisibleTime] =
-      await computeVisibleTime(startSec, endSec, isJsonTrace, this.engine);
+    const visibleTimeSpan = await computeVisibleTime(
+        traceTime.start, traceTime.end, isJsonTrace, this.engine);
     // We don't know the resolution at this point. However this will be
     // replaced in 50ms so a guess is fine.
-    const resolution = (endVisibleTime - startVisibleTime) / 1000;
+    const resolution = visibleTimeSpan.duration.divide(1000).toTPTime();
     actions.push(Actions.setVisibleTraceTime({
-      startSec: startVisibleTime,
-      endSec: endVisibleTime,
+      start: visibleTimeSpan.start.toTPTime(),
+      end: visibleTimeSpan.end.toTPTime(),
       lastUpdate: Date.now() / 1000,
-      resolution,
+      resolution: BigintMath.max(resolution, 1n),
     }));
 
     globals.dispatchMultiple(actions);
@@ -540,8 +556,8 @@
     if (profile.numRows() !== 1) return;
     const row = profile.firstRow({upid: NUM});
     const upid = row.upid;
-    const leftTs = toNs(globals.state.traceTime.startSec);
-    const rightTs = toNs(globals.state.traceTime.endSec);
+    const leftTs = globals.state.traceTime.start;
+    const rightTs = globals.state.traceTime.end;
     globals.dispatch(Actions.selectPerfSamples(
         {id: 0, upid, leftTs, rightTs, type: ProfileType.PERF_SAMPLE}));
   }
@@ -560,7 +576,7 @@
       order by ts limit 1`;
     const profile = await assertExists(this.engine).query(query);
     if (profile.numRows() !== 1) return;
-    const row = profile.firstRow({ts: NUM, type: STR, upid: NUM});
+    const row = profile.firstRow({ts: LONG, type: STR, upid: NUM});
     const ts = row.ts;
     const type = profileType(row.type);
     const upid = row.upid;
@@ -610,33 +626,32 @@
     publishThreads(threads);
   }
 
-  private async loadTimelineOverview(traceTime: TimeSpan) {
+  private async loadTimelineOverview(trace: Span<TPTime>) {
     clearOverviewData();
 
     const engine = assertExists<Engine>(this.engine);
-    const numSteps = 100;
-    const stepSec = traceTime.duration / numSteps;
+    const stepSize = BigintMath.max(1n, trace.duration / 100n);
     let hasSchedOverview = false;
-    for (let step = 0; step < numSteps; step++) {
+    for (let start = trace.start; start < trace.end; start += stepSize) {
+      const progress = start - trace.start;
+      const ratio = Number(progress) / Number(trace.duration);
       this.updateStatus(
-        'Loading overview ' +
-        `${Math.round((step + 1) / numSteps * 1000) / 10}%`);
-      const startSec = traceTime.start + step * stepSec;
-      const startNs = toNsFloor(startSec);
-      const endSec = startSec + stepSec;
-      const endNs = toNsCeil(endSec);
+          'Loading overview ' +
+          `${Math.round(ratio * 100)}%`);
+      const end = start + stepSize;
 
       // Sched overview.
       const schedResult = await engine.query(
-        `select sum(dur)/${stepSec}/1e9 as load, cpu from sched ` +
-        `where ts >= ${startNs} and ts < ${endNs} and utid != 0 ` +
-        'group by cpu order by cpu');
+          `select cast(sum(dur) as float)/${
+              stepSize} as load, cpu from sched ` +
+          `where ts >= ${start} and ts < ${end} and utid != 0 ` +
+          'group by cpu order by cpu');
       const schedData: {[key: string]: QuantizedLoad} = {};
       const it = schedResult.iter({load: NUM, cpu: NUM});
       for (; it.valid(); it.next()) {
         const load = it.load;
         const cpu = it.cpu;
-        schedData[cpu] = {startSec, endSec, load};
+        schedData[cpu] = {start, end, load};
         hasSchedOverview = true;
       }
       publishOverviewData(schedData);
@@ -647,16 +662,15 @@
     }
 
     // Slices overview.
-    const traceStartNs = toNs(traceTime.start);
-    const stepSecNs = toNs(stepSec);
     const sliceResult = await engine.query(`select
            bucket,
            upid,
-           ifnull(sum(utid_sum) / cast(${stepSecNs} as float), 0) as load
+           ifnull(sum(utid_sum) / cast(${stepSize} as float), 0) as load
          from thread
          inner join (
            select
-             ifnull(cast((ts - ${traceStartNs})/${stepSecNs} as int), 0) as bucket,
+             ifnull(cast((ts - ${trace.start})/${
+        stepSize} as int), 0) as bucket,
              sum(dur) as utid_sum,
              utid
            from slice
@@ -667,21 +681,21 @@
          group by bucket, upid`);
 
     const slicesData: {[key: string]: QuantizedLoad[]} = {};
-    const it = sliceResult.iter({bucket: NUM, upid: NUM, load: NUM});
+    const it = sliceResult.iter({bucket: LONG, upid: NUM, load: NUM});
     for (; it.valid(); it.next()) {
       const bucket = it.bucket;
       const upid = it.upid;
       const load = it.load;
 
-      const startSec = traceTime.start + stepSec * bucket;
-      const endSec = startSec + stepSec;
+      const start = trace.start + stepSize * bucket;
+      const end = start + stepSize;
 
       const upidStr = upid.toString();
       let loadArray = slicesData[upidStr];
       if (loadArray === undefined) {
         loadArray = slicesData[upidStr] = [];
       }
-      loadArray.push({startSec, endSec, load});
+      loadArray.push({start, end, load});
     }
     publishOverviewData(slicesData);
   }
@@ -892,48 +906,48 @@
   }
 }
 
-async function computeTraceReliableRangeStart(engine: Engine): Promise<number> {
+async function computeTraceReliableRangeStart(engine: Engine): Promise<TPTime> {
   const result =
     await engine.query(`SELECT RUN_METRIC('chrome/chrome_reliable_range.sql');
        SELECT start FROM chrome_reliable_range`);
-  const bounds = result.firstRow({start: NUM});
-  return bounds.start / 1e9;
+  const bounds = result.firstRow({start: LONG});
+  return bounds.start;
 }
 
 async function computeVisibleTime(
-  traceStartSec: number,
-  traceEndSec: number,
-  isJsonTrace: boolean,
-  engine: Engine): Promise<[number, number]> {
+    traceStart: TPTime, traceEnd: TPTime, isJsonTrace: boolean, engine: Engine):
+    Promise<Span<HighPrecisionTime>> {
   // if we have non-default visible state, update the visible time to it
-  const previousVisibleState = globals.state.frontendLocalState.visibleState;
-  if (!(previousVisibleState.startSec === defaultTraceTime.startSec &&
-    previousVisibleState.endSec === defaultTraceTime.endSec) &&
-    (previousVisibleState.startSec >= traceStartSec &&
-      previousVisibleState.endSec <= traceEndSec)) {
-    return [previousVisibleState.startSec, previousVisibleState.endSec];
+  const previousVisibleState = globals.stateVisibleTime();
+  const defaultTraceSpan =
+      new TPTimeSpan(defaultTraceTime.start, defaultTraceTime.end);
+  if (!(previousVisibleState.start === defaultTraceSpan.start &&
+        previousVisibleState.end === defaultTraceSpan.end) &&
+      (previousVisibleState.start >= traceStart &&
+       previousVisibleState.end <= traceEnd)) {
+    return HighPrecisionTimeSpan.fromTpTime(
+        previousVisibleState.start, previousVisibleState.end);
   }
 
   // initialise visible time to the trace time bounds
-  let visibleStartSec = traceStartSec;
-  let visibleEndSec = traceEndSec;
+  let visibleStartSec = traceStart;
+  let visibleEndSec = traceEnd;
 
   // compare start and end with metadata computed by the trace processor
   const mdTime = await engine.getTracingMetadataTimeBounds();
   // make sure the bounds hold
-  if (Math.max(visibleStartSec, mdTime.start) <
-    Math.min(visibleEndSec, mdTime.end)) {
-    visibleStartSec =
-      Math.max(visibleStartSec, mdTime.start);
-    visibleEndSec = Math.min(visibleEndSec, mdTime.end);
+  if (BigintMath.max(visibleStartSec, mdTime.start) <
+      BigintMath.min(visibleEndSec, mdTime.end)) {
+    visibleStartSec = BigintMath.max(visibleStartSec, mdTime.start);
+    visibleEndSec = BigintMath.min(visibleEndSec, mdTime.end);
   }
 
   // Trace Processor doesn't support the reliable range feature for JSON
   // traces.
   if (!isJsonTrace && ENABLE_CHROME_RELIABLE_RANGE_ZOOM_FLAG.get()) {
     const reliableRangeStart = await computeTraceReliableRangeStart(engine);
-    visibleStartSec = Math.max(visibleStartSec, reliableRangeStart);
+    visibleStartSec = BigintMath.max(visibleStartSec, reliableRangeStart);
   }
 
-  return [visibleStartSec, visibleEndSec];
+  return HighPrecisionTimeSpan.fromTpTime(visibleStartSec, visibleEndSec);
 }
diff --git a/ui/src/controller/track_controller.ts b/ui/src/controller/track_controller.ts
index 46d1f1f..73b9896 100644
--- a/ui/src/controller/track_controller.ts
+++ b/ui/src/controller/track_controller.ts
@@ -12,11 +12,17 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {BigintMath} from '../base/bigint_math';
 import {assertExists, assertTrue} from '../base/logging';
 import {Engine} from '../common/engine';
 import {Registry} from '../common/registry';
 import {TraceTime, TrackState} from '../common/state';
-import {fromNs, toNs} from '../common/time';
+import {
+  TPDuration,
+  TPTime,
+  tpTimeFromSeconds,
+  TPTimeSpan,
+} from '../common/time';
 import {LIMIT, TrackData} from '../common/track_data';
 import {globals} from '../frontend/globals';
 import {publishTrackData} from '../frontend/publish';
@@ -70,7 +76,7 @@
   // Must be overridden by the track implementation. Is invoked when the track
   // frontend runs out of cached data. The derived track controller is expected
   // to publish new track data in response to this call.
-  abstract onBoundsChange(start: number, end: number, resolution: number):
+  abstract onBoundsChange(start: TPTime, end: TPTime, resolution: TPDuration):
       Promise<Data>;
 
   get trackState(): TrackState {
@@ -129,6 +135,7 @@
   }
 
   shouldRequestData(traceTime: TraceTime): boolean {
+    const tspan = new TPTimeSpan(traceTime.start, traceTime.end);
     if (this.data === undefined) return true;
     if (this.shouldReload()) return true;
 
@@ -137,15 +144,14 @@
     if (atLimit) {
       // We request more data than the window, so add window duration to find
       // the previous window.
-      const prevWindowStart =
-          this.data.start + (traceTime.startSec - traceTime.endSec);
-      return traceTime.startSec !== prevWindowStart;
+      const prevWindowStart = this.data.start + tspan.duration;
+      return tspan.start !== prevWindowStart;
     }
 
     // Otherwise request more data only when out of range of current data or
     // resolution has changed.
-    const inRange = traceTime.startSec >= this.data.start &&
-        traceTime.endSec <= this.data.end;
+    const inRange =
+        tspan.start >= this.data.start && tspan.end <= this.data.end;
     return !inRange ||
         this.data.resolution !==
         globals.state.frontendLocalState.visibleState.resolution;
@@ -162,8 +168,7 @@
       return undefined;
     }
 
-    const bounds = globals.state.traceTime;
-    const traceDurNs = toNs(bounds.endSec - bounds.startSec);
+    const traceDuration = globals.stateTraceTime().duration;
 
     // For large traces, going through the raw table in the most zoomed-out
     // states can be very expensive as this can involve going through O(millions
@@ -198,7 +203,7 @@
     // Compute the outermost bucket size. This acts as a starting point for
     // computing the cached size.
     const outermostResolutionLevel =
-        Math.ceil(Math.log2(traceDurNs / approxWidthPx));
+        Math.ceil(Math.log2(traceDuration.nanos / approxWidthPx));
     const outermostBucketNs = Math.pow(2, outermostResolutionLevel);
 
     // This constant decides how many resolution levels down from our outermost
@@ -232,11 +237,11 @@
 
   run() {
     const visibleState = globals.state.frontendLocalState.visibleState;
-    if (visibleState === undefined || visibleState.resolution === undefined ||
-        visibleState.resolution === Infinity) {
+    if (visibleState === undefined) {
       return;
     }
-    const dur = visibleState.endSec - visibleState.startSec;
+    const visibleTimeSpan = globals.stateVisibleTime();
+    const dur = visibleTimeSpan.duration;
     if (globals.state.visibleTracks.includes(this.trackId) &&
         this.shouldRequestData(visibleState)) {
       if (this.requestingData) {
@@ -253,16 +258,14 @@
             .then(() => {
               this.isSetup = true;
               let resolution = visibleState.resolution;
-              // TODO(hjd): We shouldn't have to be so defensive here.
-              if (Math.log2(toNs(resolution)) % 1 !== 0) {
-                // resolution is in pixels per second so 1000 means
-                // 1px = 1ms.
-                resolution =
-                    fromNs(Math.pow(2, Math.floor(Math.log2(toNs(1000)))));
+
+              if (BigintMath.popcount(resolution) !== 1) {
+                resolution = BigintMath.bitFloor(tpTimeFromSeconds(1000));
               }
+
               return this.onBoundsChange(
-                  visibleState.startSec - dur,
-                  visibleState.endSec + dur,
+                  visibleTimeSpan.start - dur,
+                  visibleTimeSpan.end + dur,
                   resolution);
             })
             .then((data) => {
diff --git a/ui/src/frontend/aggregation_panel.ts b/ui/src/frontend/aggregation_panel.ts
index ddcb6ba..6bd6422 100644
--- a/ui/src/frontend/aggregation_panel.ts
+++ b/ui/src/frontend/aggregation_panel.ts
@@ -22,6 +22,7 @@
 } from '../common/aggregation_data';
 import {colorForState, textColorForState} from '../common/colorizer';
 import {translateState} from '../common/thread_state';
+import {tpTimeToMillis} from '../common/time';
 
 import {globals} from './globals';
 import {Panel} from './panel';
@@ -111,7 +112,8 @@
     const selection = globals.state.currentSelection;
     if (selection === null || selection.kind !== 'AREA') return undefined;
     const selectedArea = globals.state.areas[selection.areaId];
-    const rangeDurationMs = (selectedArea.endSec - selectedArea.startSec) * 1e3;
+    const rangeDurationMs =
+        tpTimeToMillis(selectedArea.end - selectedArea.start);
     return m('.time-range', `Selected range: ${rangeDurationMs.toFixed(6)} ms`);
   }
 
diff --git a/ui/src/frontend/base_slice_track.ts b/ui/src/frontend/base_slice_track.ts
index ed261a1..77c6e6d 100644
--- a/ui/src/frontend/base_slice_track.ts
+++ b/ui/src/frontend/base_slice_track.ts
@@ -22,7 +22,12 @@
 } from '../common/colorizer';
 import {NUM} from '../common/query_result';
 import {Selection, SelectionKind} from '../common/state';
-import {fromNs, toNs} from '../common/time';
+import {
+  fromNs,
+  tpDurationFromNanos,
+  TPTime,
+  tpTimeFromNanos,
+} from '../common/time';
 
 import {checkerboardExcept} from './checkerboard';
 import {globals} from './globals';
@@ -45,7 +50,7 @@
 // Exposed and standalone to allow for testing without making this
 // visible to subclasses.
 function filterVisibleSlices<S extends Slice>(
-    slices: S[], startS: number, endS: number): S[] {
+    slices: S[], start: TPTime, end: TPTime): S[] {
   // Here we aim to reduce the number of slices we have to draw
   // by ignoring those that are not visible. A slice is visible iff:
   //   slice.start + slice.duration >= start && slice.start <= end
@@ -89,7 +94,7 @@
   // For all slice in slices: slice.startS > endS (e.g. all slices are to the
   // right). Since the slices are sorted by startS we can check this easily:
   const maybeFirstSlice: S|undefined = slices[0];
-  if (maybeFirstSlice && maybeFirstSlice.startS > endS) {
+  if (maybeFirstSlice && maybeFirstSlice.start > end) {
     return [];
   }
   // It's not possible to easily check the analogous edge case where all slices
@@ -108,15 +113,15 @@
   let endIdx = slices.length;
   for (; startIdx < endIdx; ++startIdx) {
     const slice = slices[startIdx];
-    const sliceEndS = slice.startS + slice.durationS;
-    if (sliceEndS >= startS && slice.startS <= endS) {
+    const sliceEndS = slice.start + slice.duration;
+    if (sliceEndS >= start && slice.start <= end) {
       break;
     }
   }
   for (; startIdx < endIdx; --endIdx) {
     const slice = slices[endIdx - 1];
-    const sliceEndS = slice.startS + slice.durationS;
-    if (sliceEndS >= startS && slice.startS <= endS) {
+    const sliceEndS = slice.start + slice.duration;
+    if (sliceEndS >= start && slice.start <= end) {
       break;
     }
   }
@@ -272,13 +277,16 @@
   renderCanvas(ctx: CanvasRenderingContext2D): void {
     // TODO(hjd): fonts and colors should come from the CSS and not hardcoded
     // here.
-    const timeScale = globals.frontendLocalState.timeScale;
-    const vizTime = globals.frontendLocalState.visibleWindowTime;
+    const {
+      visibleTimeScale: timeScale,
+      visibleWindowTime: vizTime,
+    } = globals.frontendLocalState;
 
     {
-      const windowSizePx = Math.max(1, timeScale.endPx - timeScale.startPx);
-      const rawStartNs = toNs(vizTime.start);
-      const rawEndNs = toNs(vizTime.end);
+      const windowSizePx = Math.max(1, timeScale.pxSpan.delta);
+      // TODO(stevegolton): Keep these guys as bigints
+      const rawStartNs = vizTime.start.nanos;
+      const rawEndNs = vizTime.end.nanos;
       const rawSlicesKey = CacheKey.create(rawStartNs, rawEndNs, windowSizePx);
 
       // If the visible time range is outside the cached area, requests
@@ -298,7 +306,8 @@
     // Filter only the visible slices. |this.slices| will have more slices than
     // needed because maybeRequestData() over-fetches to handle small pan/zooms.
     // We don't want to waste time drawing slices that are off screen.
-    const vizSlices = this.getVisibleSlicesInternal(vizTime.start, vizTime.end);
+    const vizSlices = this.getVisibleSlicesInternal(
+        vizTime.start.toTPTime('floor'), vizTime.end.toTPTime('ceil'));
 
     let selection = globals.state.currentSelection;
 
@@ -321,15 +330,15 @@
     // pxEnd is the last visible pixel in the visible viewport. Drawing
     // anything < 0 or > pxEnd doesn't produce any visible effect as it goes
     // beyond the visible portion of the canvas.
-    const pxEnd = Math.floor(timeScale.timeToPx(vizTime.end));
+    const pxEnd = Math.floor(timeScale.hpTimeToPx(vizTime.end));
 
     for (const slice of vizSlices) {
       // Compute the basic geometry for any visible slice, even if only
       // partially visible. This might end up with a negative x if the
       // slice starts before the visible time or with a width that overflows
       // pxEnd.
-      slice.x = timeScale.timeToPx(slice.startS);
-      slice.w = timeScale.deltaTimeToPx(slice.durationS);
+      slice.x = timeScale.tpTimeToPx(slice.start);
+      slice.w = timeScale.durationToPx(slice.duration);
       if (slice.flags & SLICE_FLAGS_INSTANT) {
         // In the case of an instant slice, set the slice geometry on the
         // bounding box that will contain the chevron.
@@ -429,10 +438,10 @@
     checkerboardExcept(
         ctx,
         this.getHeight(),
-        timeScale.timeToPx(vizTime.start),
-        timeScale.timeToPx(vizTime.end),
-        timeScale.timeToPx(fromNs(this.slicesKey.startNs)),
-        timeScale.timeToPx(fromNs(this.slicesKey.endNs)));
+        timeScale.hpTimeToPx(vizTime.start),
+        timeScale.hpTimeToPx(vizTime.end),
+        timeScale.secondsToPx(fromNs(this.slicesKey.startNs)),
+        timeScale.secondsToPx(fromNs(this.slicesKey.endNs)));
 
     // TODO(hjd): Remove this.
     // The only thing this does is drawing the sched latency arrow. We should
@@ -623,8 +632,8 @@
 
     return {
       id: row.id,
-      startS: fromNs(startNsQ),
-      durationS: fromNs(endNsQ - startNsQ),
+      start: tpTimeFromNanos(startNsQ),
+      duration: tpDurationFromNanos(endNsQ - startNsQ),
       flags,
       depth: row.depth,
       title: '',
@@ -701,10 +710,10 @@
     return true;
   }
 
-  private getVisibleSlicesInternal(startS: number, endS: number):
+  private getVisibleSlicesInternal(start: TPTime, end: TPTime):
       Array<CastInternal<T['slice']>> {
     return filterVisibleSlices<CastInternal<T['slice']>>(
-        this.slices, startS, endS);
+        this.slices, start, end);
   }
 
   private updateSliceAndTrackHeight() {
diff --git a/ui/src/frontend/base_slice_track_unittest.ts b/ui/src/frontend/base_slice_track_unittest.ts
index 7dd109d..e9202a2 100644
--- a/ui/src/frontend/base_slice_track_unittest.ts
+++ b/ui/src/frontend/base_slice_track_unittest.ts
@@ -19,11 +19,11 @@
 } from './base_slice_track';
 import {Slice} from './slice';
 
-function slice(startS: number, durationS: number): Slice {
+function slice(start: number, duration: number): Slice {
   return {
     id: 42,
-    startS,
-    durationS,
+    start: BigInt(start),
+    duration: BigInt(duration),
     depth: 0,
     flags: 0,
     title: '',
@@ -36,24 +36,24 @@
 const s = slice;
 
 test('filterVisibleSlices', () => {
-  expect(filterVisibleSlices([], 0, 100)).toEqual([]);
-  expect(filterVisibleSlices([s(10, 80)], 0, 100)).toEqual([s(10, 80)]);
-  expect(filterVisibleSlices([s(0, 20)], 10, 100)).toEqual([s(0, 20)]);
-  expect(filterVisibleSlices([s(0, 10)], 10, 100)).toEqual([s(0, 10)]);
-  expect(filterVisibleSlices([s(100, 10)], 10, 100)).toEqual([s(100, 10)]);
-  expect(filterVisibleSlices([s(10, 0)], 10, 100)).toEqual([s(10, 0)]);
-  expect(filterVisibleSlices([s(100, 0)], 10, 100)).toEqual([s(100, 0)]);
-  expect(filterVisibleSlices([s(0, 5)], 10, 90)).toEqual([]);
-  expect(filterVisibleSlices([s(95, 5)], 10, 90)).toEqual([]);
-  expect(filterVisibleSlices([s(0, 5), s(95, 5)], 10, 90)).toEqual([]);
+  expect(filterVisibleSlices([], 0n, 100n)).toEqual([]);
+  expect(filterVisibleSlices([s(10, 80)], 0n, 100n)).toEqual([s(10, 80)]);
+  expect(filterVisibleSlices([s(0, 20)], 10n, 100n)).toEqual([s(0, 20)]);
+  expect(filterVisibleSlices([s(0, 10)], 10n, 100n)).toEqual([s(0, 10)]);
+  expect(filterVisibleSlices([s(100, 10)], 10n, 100n)).toEqual([s(100, 10)]);
+  expect(filterVisibleSlices([s(10, 0)], 10n, 100n)).toEqual([s(10, 0)]);
+  expect(filterVisibleSlices([s(100, 0)], 10n, 100n)).toEqual([s(100, 0)]);
+  expect(filterVisibleSlices([s(0, 5)], 10n, 90n)).toEqual([]);
+  expect(filterVisibleSlices([s(95, 5)], 10n, 90n)).toEqual([]);
+  expect(filterVisibleSlices([s(0, 5), s(95, 5)], 10n, 90n)).toEqual([]);
   expect(filterVisibleSlices(
              [
                s(0, 5),
                s(50, 0),
                s(95, 5),
              ],
-             10,
-             90))
+             10n,
+             90n))
       .toEqual([
         s(50, 0),
       ]);
@@ -63,8 +63,8 @@
                s(1, 9),
                s(6, 3),
              ],
-             10,
-             90))
+             10n,
+             90n))
       .toContainEqual(s(1, 9));
   expect(filterVisibleSlices(
              [
@@ -73,16 +73,16 @@
                s(6, 3),
                s(50, 0),
              ],
-             10,
-             90))
+             10n,
+             90n))
       .toContainEqual(s(1, 9));
   expect(filterVisibleSlices(
              [
                s(85, 10),
                s(100, 10),
              ],
-             10,
-             90))
+             10n,
+             90n))
       .toEqual([
         s(85, 10),
       ]);
@@ -91,8 +91,8 @@
                s(0, 100),
 
              ],
-             10,
-             90))
+             10n,
+             90n))
       .toEqual([
         s(0, 100),
       ]);
@@ -109,7 +109,7 @@
                s(8, 1),
                s(9, 1),
              ],
-             10,
-             90))
+             10n,
+             90n))
       .toContainEqual(s(5, 10));
 });
diff --git a/ui/src/frontend/chrome_slice_panel.ts b/ui/src/frontend/chrome_slice_panel.ts
index 7c4dd0d..50e3d1c 100644
--- a/ui/src/frontend/chrome_slice_panel.ts
+++ b/ui/src/frontend/chrome_slice_panel.ts
@@ -19,7 +19,7 @@
 import {Arg, ArgsTree, isArgTreeArray, isArgTreeMap} from '../common/arg_types';
 import {EngineProxy} from '../common/engine';
 import {runQuery} from '../common/queries';
-import {timeToCode} from '../common/time';
+import {timeToCode, tpDurationToSeconds, tpTimeToCode} from '../common/time';
 
 import {FlowPoint, globals, SliceDetails} from './globals';
 import {PanelSize} from './panel';
@@ -295,7 +295,9 @@
           !sliceInfo.category || sliceInfo.category === '[NULL]' ?
               'N/A' :
               sliceInfo.category);
-      defaultBuilder.add('Start time', timeToCode(sliceInfo.ts));
+      defaultBuilder.add(
+          'Start time',
+          tpTimeToCode(sliceInfo.ts - globals.state.traceTime.start));
       if (sliceInfo.absTime !== undefined) {
         defaultBuilder.add('Absolute Time', sliceInfo.absTime);
       }
@@ -305,9 +307,11 @@
           sliceInfo.threadDur !== undefined) {
         // If we have valid thread duration, also display a percentage of
         // |threadDur| compared to |dur|.
-        const threadDurFractionSuffix = sliceInfo.threadDur === -1 ?
+        const ratio = tpDurationToSeconds(sliceInfo.threadDur) /
+            tpDurationToSeconds(sliceInfo.dur);
+        const threadDurFractionSuffix = sliceInfo.threadDur === -1n ?
             '' :
-            ` (${(sliceInfo.threadDur / sliceInfo.dur * 100).toFixed(2)}%)`;
+            ` (${(ratio * 100).toFixed(2)}%)`;
         defaultBuilder.add(
             'Thread duration',
             this.computeDuration(sliceInfo.threadTs, sliceInfo.threadDur) +
diff --git a/ui/src/frontend/counter_panel.ts b/ui/src/frontend/counter_panel.ts
index 99d3841..4237773 100644
--- a/ui/src/frontend/counter_panel.ts
+++ b/ui/src/frontend/counter_panel.ts
@@ -14,8 +14,7 @@
 
 import m from 'mithril';
 
-import {fromNs, timeToCode} from '../common/time';
-
+import {tpTimeToCode} from '../common/time';
 import {globals} from './globals';
 import {Panel} from './panel';
 
@@ -37,7 +36,11 @@
                    m('tr', m('th', `Name`), m('td', `${counterInfo.name}`)),
                    m('tr',
                      m('th', `Start time`),
-                     m('td', `${timeToCode(counterInfo.startTime)}`)),
+                     m('td',
+                       `${
+                           tpTimeToCode(
+                               counterInfo.startTime -
+                               globals.state.traceTime.start)}`)),
                    m('tr',
                      m('th', `Value`),
                      m('td', `${counterInfo.value.toLocaleString()}`)),
@@ -46,7 +49,7 @@
                      m('td', `${counterInfo.delta.toLocaleString()}`)),
                    m('tr',
                      m('th', `Duration`),
-                     m('td', `${timeToCode(fromNs(counterInfo.duration))}`)),
+                     m('td', `${tpTimeToCode(counterInfo.duration)}`)),
                  ])],
               ));
     } else {
diff --git a/ui/src/frontend/debug.ts b/ui/src/frontend/debug.ts
index fae7a83..7e9c9e5 100644
--- a/ui/src/frontend/debug.ts
+++ b/ui/src/frontend/debug.ts
@@ -12,6 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {produce} from 'immer';
 import m from 'mithril';
 
 import {Actions} from '../common/actions';
@@ -19,12 +20,14 @@
 
 import {globals} from './globals';
 
+
 declare global {
   interface Window {
     m: typeof m;
     getSchema: typeof getSchema;
     globals: typeof globals;
     Actions: typeof Actions;
+    produce: typeof produce;
   }
 }
 
@@ -33,4 +36,5 @@
   window.m = m;
   window.globals = globals;
   window.Actions = Actions;
+  window.produce = produce;
 }
diff --git a/ui/src/frontend/drag/border_drag_strategy.ts b/ui/src/frontend/drag/border_drag_strategy.ts
index df450fc..564ffc3 100644
--- a/ui/src/frontend/drag/border_drag_strategy.ts
+++ b/ui/src/frontend/drag/border_drag_strategy.ts
@@ -12,28 +12,27 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 import {TimeScale} from '../time_scale';
-
 import {DragStrategy} from './drag_strategy';
 
 export class BorderDragStrategy extends DragStrategy {
   private moveStart = false;
 
-  constructor(timeScale: TimeScale, private pixelBounds: [number, number]) {
-    super(timeScale);
+  constructor(map: TimeScale, private pixelBounds: [number, number]) {
+    super(map);
   }
 
   onDrag(x: number) {
-    let tStart =
-        this.timeScale.pxToTime(this.moveStart ? x : this.pixelBounds[0]);
-    let tEnd =
-        this.timeScale.pxToTime(!this.moveStart ? x : this.pixelBounds[1]);
-    if (tStart > tEnd) {
+    let tStart = this.map.pxToHpTime(this.moveStart ? x : this.pixelBounds[0]);
+    let tEnd = this.map.pxToHpTime(!this.moveStart ? x : this.pixelBounds[1]);
+    if (tStart.isGreaterThan(tEnd)) {
       this.moveStart = !this.moveStart;
       [tEnd, tStart] = [tStart, tEnd];
     }
     super.updateGlobals(tStart, tEnd);
-    this.pixelBounds =
-        [this.timeScale.timeToPx(tStart), this.timeScale.timeToPx(tEnd)];
+    this.pixelBounds = [
+      this.map.hpTimeToPx(tStart),
+      this.map.hpTimeToPx(tEnd),
+    ];
   }
 
   onDragStart(x: number) {
diff --git a/ui/src/frontend/drag/drag_strategy.ts b/ui/src/frontend/drag/drag_strategy.ts
index 2896849..afb83e1 100644
--- a/ui/src/frontend/drag/drag_strategy.ts
+++ b/ui/src/frontend/drag/drag_strategy.ts
@@ -11,19 +11,22 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
-import {TimeSpan} from '../../common/time';
+import {
+  HighPrecisionTime,
+  HighPrecisionTimeSpan,
+} from '../../common/high_precision_time';
 import {globals} from '../globals';
 import {TimeScale} from '../time_scale';
 
 export abstract class DragStrategy {
-  constructor(protected timeScale: TimeScale) {}
+  constructor(protected map: TimeScale) {}
 
   abstract onDrag(x: number): void;
 
   abstract onDragStart(x: number): void;
 
-  protected updateGlobals(tStart: number, tEnd: number) {
-    const vizTime = new TimeSpan(tStart, tEnd);
+  protected updateGlobals(tStart: HighPrecisionTime, tEnd: HighPrecisionTime) {
+    const vizTime = new HighPrecisionTimeSpan(tStart, tEnd);
     globals.frontendLocalState.updateVisibleTime(vizTime);
     globals.rafScheduler.scheduleRedraw();
   }
diff --git a/ui/src/frontend/drag/inner_drag_strategy.ts b/ui/src/frontend/drag/inner_drag_strategy.ts
index 2af1b39..7be7f7b 100644
--- a/ui/src/frontend/drag/inner_drag_strategy.ts
+++ b/ui/src/frontend/drag/inner_drag_strategy.ts
@@ -23,8 +23,8 @@
 
   onDrag(x: number) {
     const move = x - this.dragStartPx;
-    const tStart = this.timeScale.pxToTime(this.pixelBounds[0] + move);
-    const tEnd = this.timeScale.pxToTime(this.pixelBounds[1] + move);
+    const tStart = this.map.pxToHpTime(this.pixelBounds[0] + move);
+    const tEnd = this.map.pxToHpTime(this.pixelBounds[1] + move);
     super.updateGlobals(tStart, tEnd);
   }
 
diff --git a/ui/src/frontend/drag/outer_drag_strategy.ts b/ui/src/frontend/drag/outer_drag_strategy.ts
index 648b50d..f8269fc 100644
--- a/ui/src/frontend/drag/outer_drag_strategy.ts
+++ b/ui/src/frontend/drag/outer_drag_strategy.ts
@@ -11,16 +11,19 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
+import {
+  HighPrecisionTime,
+} from '../../common/high_precision_time';
 import {DragStrategy} from './drag_strategy';
 
 export class OuterDragStrategy extends DragStrategy {
   private dragStartPx = 0;
 
   onDrag(x: number) {
-    const dragBeginTime = this.timeScale.pxToTime(this.dragStartPx);
-    const dragEndTime = this.timeScale.pxToTime(x);
-    const tStart = Math.min(dragBeginTime, dragEndTime);
-    const tEnd = Math.max(dragBeginTime, dragEndTime);
+    const dragBeginTime = this.map.pxToHpTime(this.dragStartPx);
+    const dragEndTime = this.map.pxToHpTime(x);
+    const tStart = HighPrecisionTime.min(dragBeginTime, dragEndTime);
+    const tEnd = HighPrecisionTime.max(dragBeginTime, dragEndTime);
     super.updateGlobals(tStart, tEnd);
   }
 
diff --git a/ui/src/frontend/flamegraph_panel.ts b/ui/src/frontend/flamegraph_panel.ts
index dbf30f0..0ac5f79 100644
--- a/ui/src/frontend/flamegraph_panel.ts
+++ b/ui/src/frontend/flamegraph_panel.ts
@@ -28,7 +28,7 @@
   FlamegraphStateViewingOption,
   ProfileType,
 } from '../common/state';
-import {timeToCode} from '../common/time';
+import {tpTimeToCode} from '../common/time';
 import {profileType} from '../controller/flamegraph_controller';
 
 import {Flamegraph, NodeRendering} from './flamegraph';
@@ -64,7 +64,7 @@
 
 export class FlamegraphDetailsPanel extends Panel<FlamegraphDetailsPanelAttrs> {
   private profileType?: ProfileType = undefined;
-  private ts = 0;
+  private ts = 0n;
   private pids: number[] = [];
   private flamegraph: Flamegraph = new Flamegraph([]);
   private focusRegex = '';
@@ -76,12 +76,12 @@
   view() {
     const flamegraphDetails = globals.flamegraphDetails;
     if (flamegraphDetails && flamegraphDetails.type !== undefined &&
-        flamegraphDetails.startNs !== undefined &&
-        flamegraphDetails.durNs !== undefined &&
+        flamegraphDetails.start !== undefined &&
+        flamegraphDetails.dur !== undefined &&
         flamegraphDetails.pids !== undefined &&
         flamegraphDetails.upids !== undefined) {
       this.profileType = profileType(flamegraphDetails.type);
-      this.ts = flamegraphDetails.startNs + flamegraphDetails.durNs;
+      this.ts = flamegraphDetails.start + flamegraphDetails.dur;
       this.pids = flamegraphDetails.pids;
       if (flamegraphDetails.flamegraph) {
         this.flamegraph.updateDataIfChanged(
@@ -108,7 +108,7 @@
                         toSelectedCallsite(
                             flamegraphDetails.expandedCallsite)}`),
                   m('div.time',
-                    `Snapshot time: ${timeToCode(flamegraphDetails.durNs)}`),
+                    `Snapshot time: ${tpTimeToCode(flamegraphDetails.dur)}`),
                   m('input[type=text][placeholder=Focus]', {
                     oninput: (e: Event) => {
                       const target = (e.target as HTMLInputElement);
diff --git a/ui/src/frontend/flow_events_renderer.ts b/ui/src/frontend/flow_events_renderer.ts
index 909dcb7..fdb91e0 100644
--- a/ui/src/frontend/flow_events_renderer.ts
+++ b/ui/src/frontend/flow_events_renderer.ts
@@ -140,7 +140,7 @@
   }
 
   private getXCoordinate(ts: number): number {
-    return globals.frontendLocalState.timeScale.timeToPx(ts);
+    return globals.frontendLocalState.visibleTimeScale.secondsToPx(ts);
   }
 
   private getSliceRect(args: FlowEventsRendererArgs, point: FlowPoint):
diff --git a/ui/src/frontend/frontend_local_state.ts b/ui/src/frontend/frontend_local_state.ts
index 34663d8..f4d95ca 100644
--- a/ui/src/frontend/frontend_local_state.ts
+++ b/ui/src/frontend/frontend_local_state.ts
@@ -14,6 +14,10 @@
 
 import {assertTrue} from '../base/logging';
 import {Actions} from '../common/actions';
+import {
+  HighPrecisionTime,
+  HighPrecisionTimeSpan,
+} from '../common/high_precision_time';
 import {HttpRpcState} from '../common/http_rpc_engine';
 import {
   Area,
@@ -21,11 +25,15 @@
   Timestamped,
   VisibleState,
 } from '../common/state';
-import {TimeSpan} from '../common/time';
+import {Span} from '../common/time';
+import {
+  TPTime,
+  TPTimeSpan,
+} from '../common/time';
 
 import {globals} from './globals';
 import {ratelimit} from './rate_limiters';
-import {TimeScale} from './time_scale';
+import {PxSpan, TimeScale} from './time_scale';
 
 interface Range {
   start?: number;
@@ -42,10 +50,6 @@
   return current;
 }
 
-function capBetween(t: number, start: number, end: number) {
-  return Math.min(Math.max(t, start), end);
-}
-
 // Calculate the space a scrollbar takes up so that we can subtract it from
 // the canvas width.
 function calculateScrollbarWidth() {
@@ -60,13 +64,99 @@
   return width;
 }
 
+export class TimeWindow {
+  private readonly MIN_DURATION_NS = 10;
+  private _start: HighPrecisionTime = new HighPrecisionTime();
+  private _durationNanos: number = 10e9;
+
+  private get _end(): HighPrecisionTime {
+    return this._start.addNanos(this._durationNanos);
+  }
+
+  update(span: Span<HighPrecisionTime>) {
+    this._start = span.start;
+    this._durationNanos = Math.max(this.MIN_DURATION_NS, span.duration.nanos);
+    this.preventClip();
+  }
+
+  // Pan the window by certain number of seconds
+  pan(offset: HighPrecisionTime) {
+    this._start = this._start.add(offset);
+    this.preventClip();
+  }
+
+  // Zoom in or out a bit centered on a specific offset from the root
+  // Offset represents the center of the zoom as a normalized value between 0
+  // and 1 where 0 is the start of the time window and 1 is the end
+  zoom(ratio: number, offset: number) {
+    // TODO(stevegolton): Handle case where trace time < MIN_DURATION_NS
+
+    const traceDuration = globals.stateTraceTime().duration;
+    const minDuration = Math.min(this.MIN_DURATION_NS, traceDuration.nanos);
+    const newDurationNanos = Math.max(this._durationNanos * ratio, minDuration);
+    // Delta between new and old duration
+    // +ve if new duration is shorter than old duration
+    const durationDeltaNanos = this._durationNanos - newDurationNanos;
+    // If offset is 0, don't move the start at all
+    // If offset if 1, move the start by the amount the duration has changed
+    // If new duration is shorter - move start to right
+    // If new duration is longer - move start to left
+    this._start = this._start.addNanos(durationDeltaNanos * offset);
+    this._durationNanos = newDurationNanos;
+    this.preventClip();
+  }
+
+  createTimeScale(startPx: number, endPx: number): TimeScale {
+    return new TimeScale(
+        this._start, this._durationNanos, new PxSpan(startPx, endPx));
+  }
+
+  // Get timespan covering entire range of the window
+  get timeSpan(): HighPrecisionTimeSpan {
+    return new HighPrecisionTimeSpan(this._start, this._end);
+  }
+
+  get timestampSpan(): Span<TPTime> {
+    return new TPTimeSpan(this.earliest, this.latest);
+  }
+
+  get earliest(): TPTime {
+    return this._start.toTPTime('floor');
+  }
+
+  get latest(): TPTime {
+    return this._start.addNanos(this._durationNanos).toTPTime('ceil');
+  }
+
+  // Limit the zoom and pan
+  private preventClip() {
+    const traceTimeSpan = globals.stateTraceTime();
+    const traceDurationNanos = traceTimeSpan.duration.nanos;
+
+    if (this._durationNanos > traceDurationNanos) {
+      this._start = traceTimeSpan.start;
+      this._durationNanos = traceDurationNanos;
+    }
+
+    if (this._start.isLessThan(traceTimeSpan.start)) {
+      this._start = traceTimeSpan.start;
+    }
+
+    const end = this._start.addNanos(this._durationNanos);
+    if (end.isGreaterThan(traceTimeSpan.end)) {
+      this._start = traceTimeSpan.end.subtractNanos(this._durationNanos);
+    }
+  }
+}
+
 /**
  * State that is shared between several frontend components, but not the
  * controller. This state is updated at 60fps.
  */
 export class FrontendLocalState {
-  visibleWindowTime = new TimeSpan(0, 10);
-  timeScale = new TimeScale(this.visibleWindowTime, [0, 0]);
+  visibleWindow = new TimeWindow();
+  startPx: number = 0;
+  endPx: number = 0;
   showPanningHint = false;
   showCookieConsent = false;
   visibleTracks = new Set<string>();
@@ -82,9 +172,9 @@
 
   private _visibleState: VisibleState = {
     lastUpdate: 0,
-    startSec: 0,
-    endSec: 10,
-    resolution: 1,
+    start: 0n,
+    end: BigInt(10e9),
+    resolution: 1n,
   };
 
   private _selectedArea?: Area;
@@ -125,6 +215,16 @@
     }
   }
 
+  zoomVisibleWindow(ratio: number, centerPoint: number) {
+    this.visibleWindow.zoom(ratio, centerPoint);
+    this.kickUpdateLocalState();
+  }
+
+  panVisibleWindow(delta: HighPrecisionTime) {
+    this.visibleWindow.pan(delta);
+    this.kickUpdateLocalState();
+  }
+
   mergeState(state: FrontendState): void {
     // This is unfortunately subtle. This class mutates this._visibleState.
     // Since we may not mutate |state| (in order to make immer's immutable
@@ -137,19 +237,22 @@
     this._visibleState = chooseLatest(this._visibleState, state.visibleState);
     const visibleStateWasUpdated = previousVisibleState !== this._visibleState;
     if (visibleStateWasUpdated) {
-      this.updateLocalTime(
-          new TimeSpan(this._visibleState.startSec, this._visibleState.endSec));
+      this.updateLocalTime(new HighPrecisionTimeSpan(
+          HighPrecisionTime.fromTPTime(this._visibleState.start),
+          HighPrecisionTime.fromTPTime(this._visibleState.end),
+          ));
     }
   }
 
+  // Set the highlight box to draw
   selectArea(
-      startSec: number, endSec: number,
+      start: TPTime, end: TPTime,
       tracks = this._selectedArea ? this._selectedArea.tracks : []) {
     assertTrue(
-        endSec >= startSec,
-        `Impossible select area: startSec [${startSec}] >= endSec [${endSec}]`);
+        end >= start,
+        `Impossible select area: start [${start}] >= end [${end}]`);
     this.showPanningHint = true;
-    this._selectedArea = {startSec, endSec, tracks};
+    this._selectedArea = {start, end, tracks},
     globals.rafScheduler.scheduleFullRedraw();
   }
 
@@ -166,12 +269,11 @@
     globals.dispatch(Actions.setVisibleTraceTime(this._visibleState));
   }, 50);
 
-  private updateLocalTime(ts: TimeSpan) {
-    const traceTime = globals.state.traceTime;
-    const startSec = capBetween(ts.start, traceTime.startSec, traceTime.endSec);
-    const endSec = capBetween(ts.end, traceTime.startSec, traceTime.endSec);
-    this.visibleWindowTime = new TimeSpan(startSec, endSec);
-    this.timeScale.setTimeBounds(this.visibleWindowTime);
+  private updateLocalTime(ts: Span<HighPrecisionTime>) {
+    const traceBounds = globals.stateTraceTime();
+    const start = ts.start.clamp(traceBounds.start, traceBounds.end);
+    const end = ts.end.clamp(traceBounds.start, traceBounds.end);
+    this.visibleWindow.update(new HighPrecisionTimeSpan(start, end));
     this.updateResolution();
   }
 
@@ -181,17 +283,17 @@
     this.ratelimitedUpdateVisible();
   }
 
-  updateVisibleTime(ts: TimeSpan) {
-    this.updateLocalTime(ts);
+  private kickUpdateLocalState() {
     this._visibleState.lastUpdate = Date.now() / 1000;
-    this._visibleState.startSec = this.visibleWindowTime.start;
-    this._visibleState.endSec = this.visibleWindowTime.end;
+    this._visibleState.start = this.visibleWindowTime.start.toTPTime();
+    this._visibleState.end = this.visibleWindowTime.end.toTPTime();
     this._visibleState.resolution = globals.getCurResolution();
     this.ratelimitedUpdateVisible();
   }
 
-  getVisibleStateBounds(): [number, number] {
-    return [this.visibleWindowTime.start, this.visibleWindowTime.end];
+  updateVisibleTime(ts: Span<HighPrecisionTime>) {
+    this.updateLocalTime(ts);
+    this.kickUpdateLocalState();
   }
 
   // Whenever start/end px of the timeScale is changed, update
@@ -202,7 +304,28 @@
     pxStart = Math.max(0, pxStart);
     pxEnd = Math.max(0, pxEnd);
     if (pxStart === pxEnd) pxEnd = pxStart + 1;
-    this.timeScale.setLimitsPx(pxStart, pxEnd);
+    this.startPx = pxStart;
+    this.endPx = pxEnd;
     this.updateResolution();
   }
+
+  // Get the time scale for the visible window
+  get visibleTimeScale(): TimeScale {
+    return this.visibleWindow.createTimeScale(this.startPx, this.endPx);
+  }
+
+  // Produces a TimeScale object for this time window provided start and end px
+  getTimeScale(startPx: number, endPx: number): TimeScale {
+    return this.visibleWindow.createTimeScale(startPx, endPx);
+  }
+
+  // Get the bounds of the window in pixels
+  get windowSpan(): PxSpan {
+    return new PxSpan(this.startPx, this.endPx);
+  }
+
+  // Get the bounds of the visible time window as a time span
+  get visibleWindowTime(): Span<HighPrecisionTime> {
+    return this.visibleWindow.timeSpan;
+  }
 }
diff --git a/ui/src/frontend/ftrace_panel.ts b/ui/src/frontend/ftrace_panel.ts
index 8de83cb..e45b5fe 100644
--- a/ui/src/frontend/ftrace_panel.ts
+++ b/ui/src/frontend/ftrace_panel.ts
@@ -18,7 +18,7 @@
 import {assertExists} from '../base/logging';
 import {Actions} from '../common/actions';
 import {colorForString} from '../common/colorizer';
-import {formatTimestamp} from '../common/time';
+import {formatTPTime, TPTime} from '../common/time';
 
 import {globals} from './globals';
 import {Panel} from './panel';
@@ -117,12 +117,12 @@
     this.recomputeVisibleRowsAndUpdate(scrollContainer);
   };
 
-  onRowOver(ts: number) {
+  onRowOver(ts: TPTime) {
     globals.dispatch(Actions.setHoverCursorTimestamp({ts}));
   }
 
   onRowOut() {
-    globals.dispatch(Actions.setHoverCursorTimestamp({ts: -1}));
+    globals.dispatch(Actions.setHoverCursorTimestamp({ts: -1n}));
   }
 
   private renderRowsLabel() {
@@ -188,8 +188,7 @@
       for (let i = 0; i < events.length; i++) {
         const {ts, name, cpu, process, args} = events[i];
 
-        const timestamp =
-            formatTimestamp(ts / 1e9 - globals.state.traceTime.startSec);
+        const timestamp = formatTPTime(ts - globals.state.traceTime.start);
 
         const rank = i + offset;
 
@@ -204,7 +203,7 @@
             `.row`,
             {
               style: {top: `${(rank + 1.0) * ROW_H}px`},
-              onmouseover: this.onRowOver.bind(this, ts / 1e9),
+              onmouseover: this.onRowOver.bind(this, ts),
               onmouseout: this.onRowOut.bind(this),
             },
             m('.cell', timestamp),
diff --git a/ui/src/frontend/globals.ts b/ui/src/frontend/globals.ts
index 0ca10a0..79ca58a 100644
--- a/ui/src/frontend/globals.ts
+++ b/ui/src/frontend/globals.ts
@@ -12,6 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {BigintMath} from '../base/bigint_math';
 import {assertExists} from '../base/logging';
 import {Actions, DeferredAction} from '../common/actions';
 import {AggregateData} from '../common/aggregation_data';
@@ -22,10 +23,19 @@
 } from '../common/conversion_jobs';
 import {createEmptyState} from '../common/empty_state';
 import {Engine} from '../common/engine';
+import {
+  HighPrecisionTime,
+  HighPrecisionTimeSpan,
+} from '../common/high_precision_time';
 import {MetricResult} from '../common/metric_data';
 import {CurrentSearchResults, SearchSummary} from '../common/search_data';
 import {CallsiteInfo, EngineConfig, ProfileType, State} from '../common/state';
-import {fromNs, toNs} from '../common/time';
+import {Span, tpTimeFromSeconds} from '../common/time';
+import {
+  TPDuration,
+  TPTime,
+  TPTimeSpan,
+} from '../common/time';
 
 import {Analytics, initAnalytics} from './analytics';
 import {BottomTabList} from './bottom_tab';
@@ -33,6 +43,7 @@
 import {RafScheduler} from './raf_scheduler';
 import {Router} from './router';
 import {ServiceWorkerController} from './service_worker_controller';
+import {PxSpan, TimeScale} from './time_scale';
 
 type Dispatch = (action: DeferredAction) => void;
 type TrackDataStore = Map<string, {}>;
@@ -41,18 +52,18 @@
 type Description = Map<string, string>;
 
 export interface SliceDetails {
-  ts?: number;
+  ts?: TPTime;
   absTime?: string;
-  dur?: number;
-  threadTs?: number;
-  threadDur?: number;
+  dur?: TPDuration;
+  threadTs?: TPTime;
+  threadDur?: TPDuration;
   priority?: number;
   endState?: string|null;
   cpu?: number;
   id?: number;
   threadStateId?: number;
   utid?: number;
-  wakeupTs?: number;
+  wakeupTs?: TPTime;
   wakerUtid?: number;
   wakerCpu?: number;
   category?: string;
@@ -104,23 +115,23 @@
 }
 
 export interface CounterDetails {
-  startTime?: number;
+  startTime?: TPTime;
   value?: number;
   delta?: number;
-  duration?: number;
+  duration?: TPDuration;
   name?: string;
 }
 
 export interface ThreadStateDetails {
-  ts?: number;
-  dur?: number;
+  ts?: TPTime;
+  dur?: TPDuration;
 }
 
 export interface FlamegraphDetails {
   type?: ProfileType;
   id?: number;
-  startNs?: number;
-  durNs?: number;
+  start?: TPTime;
+  dur?: TPDuration;
   pids?: number[];
   upids?: number[];
   flamegraph?: CallsiteInfo[];
@@ -143,8 +154,8 @@
 }
 
 export interface QuantizedLoad {
-  startSec: number;
-  endSec: number;
+  start: TPTime;
+  end: TPTime;
   load: number;
 }
 type OverviewStore = Map<string, QuantizedLoad[]>;
@@ -161,7 +172,7 @@
 
 export interface FtraceEvent {
   id: number;
-  ts: number;
+  ts: TPTime;
   name: string;
   cpu: number;
   thread: string|null;
@@ -530,7 +541,7 @@
     this.aggregateDataStore.set(kind, data);
   }
 
-  getCurResolution() {
+  getCurResolution(): TPDuration {
     // Truncate the resolution to the closest power of 2 (in nanosecond space).
     // We choose to work in ns space because resolution is consumed be track
     // controllers for quantization and they rely on resolution to be a power
@@ -541,24 +552,18 @@
     // levels. Logic: each zoom level represents a delta of 0.1 * (visible
     // window span). Therefore, zooming out by six levels is 1.1^6 ~= 2.
     // Similarily, zooming in six levels is 0.9^6 ~= 0.5.
-    const pxToSec = this.frontendLocalState.timeScale.deltaPxToDuration(1);
+    const timeScale = this.frontendLocalState.visibleTimeScale;
     // TODO(b/186265930): Remove once fixed:
-    if (!isFinite(pxToSec)) {
-      // Resolution is in pixels per second so 1000 means 1px = 1ms.
-      console.error(`b/186265930: Bad pxToSec suppressed ${pxToSec}`);
-      return fromNs(Math.pow(2, Math.floor(Math.log2(toNs(1000)))));
+    if (timeScale.pxSpan.delta === 0) {
+      console.error(`b/186265930: Bad pxToSec suppressed`);
+      return BigintMath.bitFloor(tpTimeFromSeconds(1000));
     }
-    const pxToNs = Math.max(toNs(pxToSec), 1);
-    const resolution = fromNs(Math.pow(2, Math.floor(Math.log2(pxToNs))));
-    const log2 = Math.log2(toNs(resolution));
-    if (log2 % 1 !== 0) {
-      throw new Error(`Resolution should be a power of two.
-        pxToSec: ${pxToSec},
-        pxToNs: ${pxToNs},
-        resolution: ${resolution},
-        log2: ${Math.log2(toNs(resolution))}`);
-    }
-    return resolution;
+
+    const timePerPx = HighPrecisionTime.max(
+        timeScale.pxDeltaToDuration(1), new HighPrecisionTime(1n));
+
+    const resolutionBig = BigintMath.bitFloor(timePerPx.toTPTime());
+    return resolutionBig;
   }
 
   getCurrentEngine(): EngineConfig|undefined {
@@ -637,6 +642,30 @@
   shutdown() {
     this._rafScheduler!.shutdown();
   }
+
+  // Get a timescale that covers the entire trace
+  getTraceTimeScale(pxSpan: PxSpan): TimeScale {
+    const {start, end} = this.state.traceTime;
+    const traceTime = HighPrecisionTimeSpan.fromTpTime(start, end);
+    return new TimeScale(traceTime.start, traceTime.duration.nanos, pxSpan);
+  }
+
+  // Get the trace time bounds
+  stateTraceTime(): Span<HighPrecisionTime> {
+    const {start, end} = this.state.traceTime;
+    return HighPrecisionTimeSpan.fromTpTime(start, end);
+  }
+
+  stateTraceTimeTP(): Span<TPTime> {
+    const {start, end} = this.state.traceTime;
+    return new TPTimeSpan(start, end);
+  }
+
+  // Get the state version of the visible time bounds
+  stateVisibleTime(): Span<TPTime> {
+    const {start, end} = this.state.frontendLocalState.visibleState;
+    return new TPTimeSpan(start, end);
+  }
 }
 
 export const globals = new Globals();
diff --git a/ui/src/frontend/gridline_helper.ts b/ui/src/frontend/gridline_helper.ts
index 1c9dbfe..6581c81 100644
--- a/ui/src/frontend/gridline_helper.ts
+++ b/ui/src/frontend/gridline_helper.ts
@@ -13,49 +13,91 @@
 // limitations under the License.
 
 import {assertTrue} from '../base/logging';
-import {roundDownNearest} from '../base/math_utils';
+import {Span, tpDurationToSeconds} from '../common/time';
+import {TPDuration, TPTime, TPTimeSpan} from '../common/time';
+
 import {TRACK_BORDER_COLOR, TRACK_SHELL_WIDTH} from './css_constants';
 import {globals} from './globals';
 import {TimeScale} from './time_scale';
 
-// Returns the optimal step size (in seconds) and tick pattern of ticks within
-// the step. The returned step size has two properties: (1) It is 1, 2, or 5,
-// multiplied by some integer power of 10. (2) It is maximised given the
-// constraint: |range| / stepSize <= |maxNumberOfSteps|.
-export function getStepSize(
-    range: number, maxNumberOfSteps: number): [number, string] {
-  // First, get the largest possible power of 10 that is smaller than the
-  // desired step size, and use it as our initial step size.
-  // For example, if the range is 2345ms and the desired steps is 10, then the
-  // minimum step size is 234.5ms so the step size will initialise to 100.
-  const minStepSize = range / maxNumberOfSteps;
-  const zeros = Math.floor(Math.log10(minStepSize));
-  const initialStepSize = Math.pow(10, zeros);
+const micros = 1000n;
+const millis = 1000n * micros;
+const seconds = 1000n * millis;
+const minutes = 60n * seconds;
+const hours = 60n * minutes;
+const days = 24n * hours;
 
-  // We know that |initialStepSize| is a power of 10, and
-  // initialStepSize <= desiredStepSize <= 10 * initialStepSize. There are four
-  // possible candidates for final step size: 1, 2, 5 or 10 * initialStepSize.
-  // For our example above, this would result in a step size of 500ms, as both
-  // 100ms and 200ms are smaller than the minimum step size of 234.5ms.
-  // We pick the candidate that minimizes the step size without letting the
-  // number of steps exceed |maxNumberOfSteps|. The factor we pick to also
-  // determines the pattern of ticks. This pattern is represented using a string
-  // where:
-  //  | = Major tick
-  //  : = Medium tick
-  //  . = Minor tick
-  const stepSizeMultipliers: [number, string][] =
-      [[1, '|....:....'], [2, '|.:.'], [5, '|....'], [10, '|....:....']];
+// These patterns cover the entire range of 0 - 2^63-1 nanoseconds
+const patterns: [bigint, string][] = [
+  [1n, '|'],
+  [2n, '|:'],
+  [5n, '|....'],
+  [10n, '|....:....'],
+  [20n, '|.:.'],
+  [50n, '|....'],
+  [100n, '|....:....'],
+  [200n, '|.:.'],
+  [500n, '|....'],
+  [1n * micros, '|....:....'],
+  [2n * micros, '|.:.'],
+  [5n * micros, '|....'],
+  [10n * micros, '|....:....'],
+  [20n * micros, '|.:.'],
+  [50n * micros, '|....'],
+  [100n * micros, '|....:....'],
+  [200n * micros, '|.:.'],
+  [500n * micros, '|....'],
+  [1n * millis, '|....:....'],
+  [2n * millis, '|.:.'],
+  [5n * millis, '|....'],
+  [10n * millis, '|....:....'],
+  [20n * millis, '|.:.'],
+  [50n * millis, '|....'],
+  [100n * millis, '|....:....'],
+  [200n * millis, '|.:.'],
+  [500n * millis, '|....'],
+  [1n * seconds, '|....:....'],
+  [2n * seconds, '|.:.'],
+  [5n * seconds, '|....'],
+  [10n * seconds, '|....:....'],
+  [30n * seconds, '|.:.:.'],
+  [1n * minutes, '|.....'],
+  [2n * minutes, '|.:.'],
+  [5n * minutes, '|.....'],
+  [10n * minutes, '|....:....'],
+  [30n * minutes, '|.:.:.'],
+  [1n * hours, '|.....'],
+  [2n * hours, '|.:.'],
+  [6n * hours, '|.....'],
+  [12n * hours, '|.....:.....'],
+  [1n * days, '|.:.'],
+  [2n * days, '|.:.'],
+  [5n * days, '|....'],
+  [10n * days, '|....:....'],
+  [20n * days, '|.:.'],
+  [50n * days, '|....'],
+  [100n * days, '|....:....'],
+  [200n * days, '|.:.'],
+  [500n * days, '|....'],
+  [1000n * days, '|....:....'],
+  [2000n * days, '|.:.'],
+  [5000n * days, '|....'],
+  [10000n * days, '|....:....'],
+  [20000n * days, '|.:.'],
+  [50000n * days, '|....'],
+  [100000n * days, '|....:....'],
+  [200000n * days, '|.:.'],
+];
 
-  for (const [multiplier, pattern] of stepSizeMultipliers) {
-    const newStepSize = multiplier * initialStepSize;
-    const numberOfNewSteps = range / newStepSize;
-    if (numberOfNewSteps <= maxNumberOfSteps) {
-      return [newStepSize, pattern];
+// Returns the optimal step size and pattern of ticks within the step.
+export function getPattern(minPatternSize: bigint): [TPDuration, string] {
+  for (const [size, pattern] of patterns) {
+    if (size >= minPatternSize) {
+      return [size, pattern];
     }
   }
 
-  throw new Error('Something has gone horribly wrong with maths');
+  throw new Error('Pattern not defined for this minsize');
 }
 
 function tickPatternToArray(pattern: string): TickType[] {
@@ -75,21 +117,23 @@
   });
 }
 
-// Assuming a number only has one non-zero decimal digit, find the number of
-// decimal places required to accurately print that number. I.e. the parameter
-// we should pass to number.toFixed(x). To account for floating point
-// innaccuracies when representing numbers in base-10, we only take the first
-// nonzero fractional digit into account. E.g.
+// Get the number of decimal places we would have to print a time to for a given
+// min step size. For example, if we know the min step size is 0.1 and all
+// values are going to be aligned to integral multiples of 0.1, there's no
+// point printing these values with more than 1 decimal place.
+// Note: It's assumed that stepSize only has one significant figure.
+// E.g. 0.3 and 0.00002 are fine, but 0.123 will be treated as if it were 0.1.
+// Some examples: (seconds -> decimal places)
 //  1.0 -> 0
 //  0.5 -> 1
 //  0.009 -> 3
 //  0.00007 -> 5
 //  30000 -> 0
 //  0.30000000000000004 -> 1
-export function guessDecimalPlaces(val: number): number {
-  const neglog10 = -Math.floor(Math.log10(val));
-  const clamped = Math.max(0, neglog10);
-  return clamped;
+export function guessDecimalPlaces(stepSize: TPDuration): number {
+  const stepSizeSeconds = tpDurationToSeconds(stepSize);
+  const decimalPlaces = -Math.floor(Math.log10(stepSizeSeconds));
+  return Math.max(0, decimalPlaces);
 }
 
 export enum TickType {
@@ -100,55 +144,58 @@
 
 export interface Tick {
   type: TickType;
-  time: number;
-  position: number;
+  time: TPTime;
 }
 
 const MIN_PX_PER_STEP = 80;
+export function getMaxMajorTicks(width: number) {
+  return Math.max(1, Math.floor(width / MIN_PX_PER_STEP));
+}
+
+function roundDownNearest(time: TPTime, stepSize: TPDuration): TPTime {
+  return stepSize * (time / stepSize);
+}
 
 // An iterable which generates a series of ticks for a given timescale.
 export class TickGenerator implements Iterable<Tick> {
   private _tickPattern: TickType[];
-  private _patternSize: number;
+  private _patternSize: TPDuration;
+  private _timeSpan: Span<TPTime>;
+  private _offset: TPTime;
 
-  constructor(private scale: TimeScale, {minLabelPx = MIN_PX_PER_STEP} = {}) {
-    assertTrue(minLabelPx > 0, 'minLabelPx cannot be lte 0');
-    assertTrue(scale.widthPx > 0, 'widthPx cannot be lte 0');
-    assertTrue(
-        scale.timeSpan.duration > 0, 'timeSpan.duration cannot be lte 0');
+  constructor(
+      timeSpan: Span<TPTime>, maxMajorTicks: number, offset: TPTime = 0n) {
+    assertTrue(timeSpan.duration > 0n, 'timeSpan.duration cannot be lte 0');
+    assertTrue(maxMajorTicks > 0, 'maxMajorTicks cannot be lte 0');
 
-    const desiredSteps = scale.widthPx / minLabelPx;
-    const [size, pattern] = getStepSize(scale.timeSpan.duration, desiredSteps);
+    this._timeSpan = timeSpan.add(-offset);
+    this._offset = offset;
+    const minStepSize =
+        BigInt(Math.floor(Number(timeSpan.duration) / maxMajorTicks));
+    const [size, pattern] = getPattern(minStepSize);
     this._patternSize = size;
     this._tickPattern = tickPatternToArray(pattern);
   }
 
   // Returns an iterable, so this object can be iterated over directly using the
   // `for x of y` notation. The use of a generator here is just to make things
-  // more elegant than creating an array of ticks and building an iterator for
-  // it.
+  // more elegant compared to creating an array of ticks and building an
+  // iterator for it.
   * [Symbol.iterator](): Generator<Tick> {
-    const span = this.scale.timeSpan;
-    const stepSize = this._patternSize / this._tickPattern.length;
-    const start = roundDownNearest(span.start, this._patternSize);
-    const timeAtStep = (i: number) => start + (i * stepSize);
+    const stepSize = this._patternSize / BigInt(this._tickPattern.length);
+    const start = roundDownNearest(this._timeSpan.start, this._patternSize);
+    const end = this._timeSpan.end;
+    let patternIndex = 0;
 
-    // Iterating using steps instead of
-    // for (let s = start; s < span.end; s += stepSize) because if start is much
-    // larger than stepSize we can enter an infinite loop due to floating
-    // point precision errors.
-    for (let i = 0; timeAtStep(i) < span.end; i++) {
-      const time = timeAtStep(i);
-      if (time >= span.start) {
-        const position = Math.floor(this.scale.timeToPx(time));
-        const type = this._tickPattern[i % this._tickPattern.length];
-        yield {type, time, position};
+    for (let time = start; time < end; time += stepSize, patternIndex++) {
+      if (time >= this._timeSpan.start) {
+        patternIndex = patternIndex % this._tickPattern.length;
+        const type = this._tickPattern[patternIndex];
+        yield {type, time: time + this._offset};
       }
     }
   }
 
-  // The number of decimal places labels should be printed with, assuming labels
-  // are only printed on major ticks.
   get digits(): number {
     return guessDecimalPlaces(this._patternSize);
   }
@@ -157,9 +204,7 @@
 // Gets the timescale associated with the current visible window.
 export function timeScaleForVisibleWindow(
     startPx: number, endPx: number): TimeScale {
-  const span = globals.frontendLocalState.visibleWindowTime;
-  const spanRelative = span.add(-globals.state.traceTime.startSec);
-  return new TimeScale(spanRelative, [startPx, endPx]);
+  return globals.frontendLocalState.getTimeScale(startPx, endPx);
 }
 
 export function drawGridLines(
@@ -169,13 +214,18 @@
   ctx.strokeStyle = TRACK_BORDER_COLOR;
   ctx.lineWidth = 1;
 
-  const timeScale = timeScaleForVisibleWindow(TRACK_SHELL_WIDTH, width);
-  if (timeScale.timeSpan.duration > 0 && timeScale.widthPx > 0) {
-    for (const {type, position} of new TickGenerator(timeScale)) {
+  const {earliest, latest} = globals.frontendLocalState.visibleWindow;
+  const span = new TPTimeSpan(earliest, latest);
+  if (width > TRACK_SHELL_WIDTH && span.duration > 0n) {
+    const maxMajorTicks = getMaxMajorTicks(width - TRACK_SHELL_WIDTH);
+    const map = timeScaleForVisibleWindow(TRACK_SHELL_WIDTH, width);
+    for (const {type, time} of new TickGenerator(
+             span, maxMajorTicks, globals.state.traceTime.start)) {
+      const px = Math.floor(map.tpTimeToPx(time));
       if (type === TickType.MAJOR) {
         ctx.beginPath();
-        ctx.moveTo(position + 0.5, 0);
-        ctx.lineTo(position + 0.5, height);
+        ctx.moveTo(px + 0.5, 0);
+        ctx.lineTo(px + 0.5, height);
         ctx.stroke();
       }
     }
diff --git a/ui/src/frontend/gridline_helper_unittest.ts b/ui/src/frontend/gridline_helper_unittest.ts
index 3b6dcac..2454680 100644
--- a/ui/src/frontend/gridline_helper_unittest.ts
+++ b/ui/src/frontend/gridline_helper_unittest.ts
@@ -12,303 +12,93 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {TimeSpan} from '../common/time';
+import {TPTimeSpan} from '../common/time';
 
-import {getStepSize, Tick, TickGenerator, TickType} from './gridline_helper';
-import {TimeScale} from './time_scale';
-
-const pattern1 = '|....:....';
-const pattern2 = '|.:.';
-const pattern5 = '|....';
-const timeScale = new TimeScale(new TimeSpan(0, 1), [1, 2]);
+import {getPattern, TickGenerator, TickType} from './gridline_helper';
 
 test('gridline helper to have sensible step sizes', () => {
-  expect(getStepSize(10, 14)).toEqual([1, pattern1]);
-  expect(getStepSize(30, 14)).toEqual([5, pattern5]);
-  expect(getStepSize(60, 14)).toEqual([5, pattern5]);
-  expect(getStepSize(100, 14)).toEqual([10, pattern1]);
+  expect(getPattern(1n)).toEqual([1n, '|']);
+  expect(getPattern(2n)).toEqual([2n, '|:']);
+  expect(getPattern(3n)).toEqual([5n, '|....']);
+  expect(getPattern(4n)).toEqual([5n, '|....']);
+  expect(getPattern(5n)).toEqual([5n, '|....']);
+  expect(getPattern(7n)).toEqual([10n, '|....:....']);
 
-  expect(getStepSize(10, 21)).toEqual([0.5, pattern5]);
-  expect(getStepSize(30, 21)).toEqual([2, pattern2]);
-  expect(getStepSize(60, 21)).toEqual([5, pattern5]);
-  expect(getStepSize(100, 21)).toEqual([5, pattern5]);
+  expect(getPattern(10n)).toEqual([10n, '|....:....']);
+  expect(getPattern(20n)).toEqual([20n, '|.:.']);
+  expect(getPattern(50n)).toEqual([50n, '|....']);
 
-  expect(getStepSize(10, 3)).toEqual([5, pattern5]);
-  expect(getStepSize(30, 3)).toEqual([10, pattern1]);
-  expect(getStepSize(60, 3)).toEqual([20, pattern2]);
-  expect(getStepSize(100, 3)).toEqual([50, pattern5]);
-
-  expect(getStepSize(800, 4)).toEqual([200, pattern2]);
+  expect(getPattern(100n)).toEqual([100n, '|....:....']);
 });
 
-test('gridline helper to scale to very small and very large values', () => {
-  expect(getStepSize(.01, 14)).toEqual([.001, pattern1]);
-  expect(getStepSize(10000, 14)).toEqual([1000, pattern1]);
-});
+describe('TickGenerator', () => {
+  it('can generate ticks with span starting at origin', () => {
+    const tickGen = new TickGenerator(new TPTimeSpan(0n, 10n), 1);
+    const expected = [
+      {type: TickType.MAJOR, time: 0n},
+      {type: TickType.MINOR, time: 1n},
+      {type: TickType.MINOR, time: 2n},
+      {type: TickType.MINOR, time: 3n},
+      {type: TickType.MINOR, time: 4n},
+      {type: TickType.MEDIUM, time: 5n},
+      {type: TickType.MINOR, time: 6n},
+      {type: TickType.MINOR, time: 7n},
+      {type: TickType.MINOR, time: 8n},
+      {type: TickType.MINOR, time: 9n},
+    ];
+    const actual = Array.from(tickGen!);
+    expect(actual).toStrictEqual(expected);
+    expect(tickGen!.digits).toEqual(8);
+  });
 
-test('gridline helper to always return a reasonable number of steps', () => {
-  for (let i = 1; i <= 1000; i++) {
-    const [stepSize, _] = getStepSize(i, 14);
-    expect(Math.round(i / stepSize)).toBeGreaterThanOrEqual(6);
-    expect(Math.round(i / stepSize)).toBeLessThanOrEqual(14);
-  }
-});
+  it('can generate ticks when span has an offset', () => {
+    const tickGen = new TickGenerator(new TPTimeSpan(10n, 20n), 1);
+    const expected = [
+      {type: TickType.MAJOR, time: 10n},
+      {type: TickType.MINOR, time: 11n},
+      {type: TickType.MINOR, time: 12n},
+      {type: TickType.MINOR, time: 13n},
+      {type: TickType.MINOR, time: 14n},
+      {type: TickType.MEDIUM, time: 15n},
+      {type: TickType.MINOR, time: 16n},
+      {type: TickType.MINOR, time: 17n},
+      {type: TickType.MINOR, time: 18n},
+      {type: TickType.MINOR, time: 19n},
+    ];
+    const actual = Array.from(tickGen!);
+    expect(actual).toStrictEqual(expected);
+    expect(tickGen!.digits).toEqual(8);
+  });
 
-describe('TickGenerator with range 0.0-1.0 and room for 2 labels', () => {
-  let tickGen: TickGenerator|undefined = undefined;
-  beforeAll(() => {
-    const timeSpan = new TimeSpan(0.0, 1.0);
-    const timeScale = new TimeScale(timeSpan, [0, 200]);
-    tickGen = new TickGenerator(timeScale, {minLabelPx: 100});
-  });
-  it('should produce major ticks at 0.5s and minor ticks at 0.1s starting at 0',
-     () => {
-       const expected = [
-         {type: TickType.MAJOR, time: 0.0},
-         {type: TickType.MINOR, time: 0.1},
-         {type: TickType.MINOR, time: 0.2},
-         {type: TickType.MINOR, time: 0.3},
-         {type: TickType.MINOR, time: 0.4},
-         {type: TickType.MAJOR, time: 0.5},
-         {type: TickType.MINOR, time: 0.6},
-         {type: TickType.MINOR, time: 0.7},
-         {type: TickType.MINOR, time: 0.8},
-         {type: TickType.MINOR, time: 0.9},
-       ];
-       const actual = Array.from(tickGen!);
-       expectTicksEqual(actual, expected);
-     });
-  it('should tell us to use 1 decimal place for labels', () => {
-    expect(tickGen!.digits).toEqual(1);
-  });
-});
-
-describe('TickGenerator with range 0.3-1.3 and room for 2 labels', () => {
-  let tickGen: TickGenerator|undefined = undefined;
-  beforeAll(() => {
-    const timeSpan = new TimeSpan(0.3, 1.3);
-    const timeScale = new TimeScale(timeSpan, [0, 200]);
-    tickGen = new TickGenerator(timeScale, {minLabelPx: 100});
-  });
-  it('should produce major ticks at 0.5s and minor ticks at 0.1s starting at 0',
-     () => {
-       const expected = [
-         {type: TickType.MINOR, time: 0.3},
-         {type: TickType.MINOR, time: 0.4},
-         {type: TickType.MAJOR, time: 0.5},
-         {type: TickType.MINOR, time: 0.6},
-         {type: TickType.MINOR, time: 0.7},
-         {type: TickType.MINOR, time: 0.8},
-         {type: TickType.MINOR, time: 0.9},
-         {type: TickType.MAJOR, time: 1.0},
-         {type: TickType.MINOR, time: 1.1},
-         {type: TickType.MINOR, time: 1.2},
-       ];
-       const actual = Array.from(tickGen!);
-       expectTicksEqual(actual, expected);
-     });
-  it('should tell us to use 1 decimal place for labels', () => {
-    expect(tickGen!.digits).toEqual(1);
-  });
-});
-
-describe('TickGenerator with range 0.0-0.2 and room for 1 label', () => {
-  let tickGen: TickGenerator|undefined = undefined;
-  beforeAll(() => {
-    const timeSpan = new TimeSpan(0.0, 0.2);
-    const timeScale = new TimeScale(timeSpan, [0, 100]);
-    tickGen = new TickGenerator(timeScale, {minLabelPx: 100});
-  });
-  it('should produce major ticks at 0.2s and minor ticks at 0.1s starting at 0',
-     () => {
-       const expected = [
-         {type: TickType.MAJOR, time: 0.0},
-         {type: TickType.MINOR, time: 0.05},
-         {type: TickType.MEDIUM, time: 0.1},
-         {type: TickType.MINOR, time: 0.15},
-       ];
-       const actual = Array.from(tickGen!);
-       expectTicksEqual(actual, expected);
-     });
-  it('should tell us to use 1 decimal place for labels', () => {
-    expect(tickGen!.digits).toEqual(1);
-  });
-});
-
-describe('TickGenerator with range 0.0-0.1 and room for 1 label', () => {
-  let tickGen: TickGenerator|undefined = undefined;
-  beforeAll(() => {
-    const timeSpan = new TimeSpan(0.0, 0.1);
-    const timeScale = new TimeScale(timeSpan, [0, 100]);
-    tickGen = new TickGenerator(timeScale, {minLabelPx: 100});
-  });
-  it('should produce major ticks at 0.1s & minor ticks at 0.02s starting at 0',
-     () => {
-       const expected = [
-         {type: TickType.MAJOR, time: 0.0},
-         {type: TickType.MINOR, time: 0.01},
-         {type: TickType.MINOR, time: 0.02},
-         {type: TickType.MINOR, time: 0.03},
-         {type: TickType.MINOR, time: 0.04},
-         {type: TickType.MEDIUM, time: 0.05},
-         {type: TickType.MINOR, time: 0.06},
-         {type: TickType.MINOR, time: 0.07},
-         {type: TickType.MINOR, time: 0.08},
-         {type: TickType.MINOR, time: 0.09},
-       ];
-       const actual = Array.from(tickGen!);
-       expect(tickGen!.digits).toEqual(1);
-       expectTicksEqual(actual, expected);
-     });
-  it('should tell us to use 1 decimal place for labels', () => {
-    expect(tickGen!.digits).toEqual(1);
-  });
-});
-
-describe('TickGenerator with a very small timespan', () => {
-  let tickGen: TickGenerator|undefined = undefined;
-  beforeAll(() => {
-    const timeSpan = new TimeSpan(0.0, 1e-9);
-    const timeScale = new TimeScale(timeSpan, [0, 100]);
-    tickGen = new TickGenerator(timeScale, {minLabelPx: 100});
-  });
-  it('should generate minor ticks at 2e-10s and one major tick at the start',
-     () => {
-       const expected = [
-         {type: TickType.MAJOR, time: 0.0},
-         {type: TickType.MINOR, time: 1e-10},
-         {type: TickType.MINOR, time: 2e-10},
-         {type: TickType.MINOR, time: 3e-10},
-         {type: TickType.MINOR, time: 4e-10},
-         {type: TickType.MEDIUM, time: 5e-10},
-         {type: TickType.MINOR, time: 6e-10},
-         {type: TickType.MINOR, time: 7e-10},
-         {type: TickType.MINOR, time: 8e-10},
-         {type: TickType.MINOR, time: 9e-10},
-       ];
-       const actual = Array.from(tickGen!);
-       expectTicksEqual(actual, expected);
-     });
-  it('should tell us to use 9 decimal places for labels', () => {
-    expect(tickGen!.digits).toEqual(9);
-  });
-});
-
-describe('TickGenerator with a very large timespan', () => {
-  let tickGen: TickGenerator|undefined = undefined;
-  beforeAll(() => {
-    const timeSpan = new TimeSpan(0.0, 1e9);
-    const timeScale = new TimeScale(timeSpan, [0, 100]);
-    tickGen = new TickGenerator(timeScale, {minLabelPx: 100});
-  });
-  it('should generate minor ticks at 2e8 and one major tick at the start',
-     () => {
-       const expected = [
-         {type: TickType.MAJOR, time: 0.0},
-         {type: TickType.MINOR, time: 1e8},
-         {type: TickType.MINOR, time: 2e8},
-         {type: TickType.MINOR, time: 3e8},
-         {type: TickType.MINOR, time: 4e8},
-         {type: TickType.MEDIUM, time: 5e8},
-         {type: TickType.MINOR, time: 6e8},
-         {type: TickType.MINOR, time: 7e8},
-         {type: TickType.MINOR, time: 8e8},
-         {type: TickType.MINOR, time: 9e8},
-       ];
-       const actual = Array.from(tickGen!);
-       expectTicksEqual(actual, expected);
-     });
-  it('should tell us to use 0 decimal places for labels', () => {
+  it('can generate ticks when span is large', () => {
+    const tickGen =
+        new TickGenerator(new TPTimeSpan(1000000000n, 2000000000n), 1);
+    const expected = [
+      {type: TickType.MAJOR, time: 1000000000n},
+      {type: TickType.MINOR, time: 1100000000n},
+      {type: TickType.MINOR, time: 1200000000n},
+      {type: TickType.MINOR, time: 1300000000n},
+      {type: TickType.MINOR, time: 1400000000n},
+      {type: TickType.MEDIUM, time: 1500000000n},
+      {type: TickType.MINOR, time: 1600000000n},
+      {type: TickType.MINOR, time: 1700000000n},
+      {type: TickType.MINOR, time: 1800000000n},
+      {type: TickType.MINOR, time: 1900000000n},
+    ];
+    const actual = Array.from(tickGen!);
+    expect(actual).toStrictEqual(expected);
     expect(tickGen!.digits).toEqual(0);
   });
-});
 
-describe('TickGenerator where the timespan has a dynamic range of 1e12', () => {
-  // This is the equivalent of zooming in to the nanosecond level, 1000 seconds
-  // into a trace Note: this is about the limit of what this generator can
-  // handle.
-  let tickGen: TickGenerator|undefined = undefined;
-  beforeAll(() => {
-    const timeSpan = new TimeSpan(1000, 1000.000000001);
-    const timeScale = new TimeScale(timeSpan, [0, 100]);
-    tickGen = new TickGenerator(timeScale, {minLabelPx: 100});
+  it('throws an error when timespan duration is 0', () => {
+    expect(() => {
+      new TickGenerator(new TPTimeSpan(0n, 0n), 1);
+    }).toThrow(Error);
   });
-  it('should generate minor ticks at 1e-10s and one major tick at the start',
-     () => {
-       const expected = [
-         {type: TickType.MAJOR, time: 1000.0000000000},
-         {type: TickType.MINOR, time: 1000.0000000001},
-         {type: TickType.MINOR, time: 1000.0000000002},
-         {type: TickType.MINOR, time: 1000.0000000003},
-         {type: TickType.MINOR, time: 1000.0000000004},
-         {type: TickType.MEDIUM, time: 1000.0000000005},
-         {type: TickType.MINOR, time: 1000.0000000006},
-         {type: TickType.MINOR, time: 1000.0000000007},
-         {type: TickType.MINOR, time: 1000.0000000008},
-         {type: TickType.MINOR, time: 1000.0000000009},
-       ];
-       const actual = Array.from(tickGen!);
-       expectTicksEqual(actual, expected);
-     });
-  it('should tell us to use 9 decimal places for labels', () => {
-    expect(tickGen!.digits).toEqual(9);
+
+  it('throws an error when max ticks is 0', () => {
+    expect(() => {
+      new TickGenerator(new TPTimeSpan(0n, 1n), 0);
+    }).toThrow(Error);
   });
 });
-
-describe(
-    'TickGenerator where the timespan has a ridiculously huge dynamic range',
-    () => {
-      // We don't expect this to work, just wanna make sure it doesn't crash or
-      // get stuck
-      it('should not crash or get stuck in an infinite loop', () => {
-        const timeSpan = new TimeSpan(1000, 1000.000000000001);
-        const timeScale = new TimeScale(timeSpan, [0, 100]);
-        new TickGenerator(timeScale);
-      });
-    });
-
-describe(
-    'TickGenerator where the timespan has a ridiculously huge dynamic range',
-    () => {
-      // We don't expect this to work, just wanna make sure it doesn't crash or
-      // get stuck
-      it('should not crash or get stuck in an infinite loop', () => {
-        const timeSpan = new TimeSpan(1000, 1000.000000000001);
-        const timeScale = new TimeScale(timeSpan, [0, 100]);
-        new TickGenerator(timeScale);
-      });
-    });
-
-test('TickGenerator constructed with a 0 width throws an error', () => {
-  expect(() => {
-    const timeScale = new TimeScale(new TimeSpan(0.0, 1.0), [0, 0]);
-    new TickGenerator(timeScale);
-  }).toThrow(Error);
-});
-
-test(
-    'TickGenerator constructed with desiredPxPerStep of 0 throws an error',
-    () => {
-      expect(() => {
-        new TickGenerator(timeScale, {minLabelPx: 0});
-      }).toThrow(Error);
-    });
-
-test('TickGenerator constructed with a 0 duration throws an error', () => {
-  expect(() => {
-    const timeScale = new TimeScale(new TimeSpan(0.0, 0.0), [0, 1]);
-    new TickGenerator(timeScale);
-  }).toThrow(Error);
-});
-
-function expectTicksEqual(actual: Tick[], expected: any[]) {
-  // TODO(stevegolton) We could write a custom matcher for this; this approach
-  // produces cryptic error messages.
-  expect(actual.length).toEqual(expected.length);
-  for (let i = 0; i < actual.length; ++i) {
-    const ex = expected[i];
-    const ac = actual[i];
-    expect(ac.type).toEqual(ex.type);
-    expect(ac.time).toBeCloseTo(ex.time, 9);
-  }
-}
diff --git a/ui/src/frontend/keyboard_event_handler.ts b/ui/src/frontend/keyboard_event_handler.ts
index e757c12..f25bf52 100644
--- a/ui/src/frontend/keyboard_event_handler.ts
+++ b/ui/src/frontend/keyboard_event_handler.ts
@@ -14,6 +14,7 @@
 
 import {Actions} from '../common/actions';
 import {Area} from '../common/state';
+import {TPTime} from '../common/time';
 
 import {Flow, globals} from './globals';
 import {toggleHelp} from './help_modal';
@@ -23,7 +24,8 @@
 } from './scroll_helper';
 import {executeSearch} from './search_handler';
 
-const INSTANT_FOCUS_DURATION_S = 1 / 1e9;  // 1 ns.
+const INSTANT_FOCUS_DURATION = 1n;
+const INCOMPLETE_SLICE_DURATION = 30_000n;
 type Direction = 'Forward'|'Backward';
 
 // Handles all key events than are not handled by the
@@ -55,8 +57,8 @@
     if (selection !== null && selection.kind === 'AREA') {
       const area = globals.state.areas[selection.areaId];
       const coversEntireTimeRange =
-          globals.state.traceTime.startSec === area.startSec &&
-          globals.state.traceTime.endSec === area.endSec;
+          globals.state.traceTime.start === area.start &&
+          globals.state.traceTime.end === area.end;
       if (!coversEntireTimeRange) {
         // If the current selection is an area which does not cover the entire
         // time range, preserve the list of selected tracks and expand the time
@@ -71,10 +73,11 @@
       // If the current selection is not an area, select all.
       tracksToSelect = Object.keys(globals.state.tracks);
     }
+    const {start, end} = globals.state.traceTime;
     globals.dispatch(Actions.selectArea({
       area: {
-        startSec: globals.state.traceTime.startSec,
-        endSec: globals.state.traceTime.endSec,
+        start,
+        end,
         tracks: tracksToSelect,
       },
     }));
@@ -201,29 +204,29 @@
   }
 }
 
-function findTimeRangeOfSelection(): {startTs: number, endTs: number} {
+function findTimeRangeOfSelection(): {startTs: TPTime, endTs: TPTime} {
   const selection = globals.state.currentSelection;
-  let startTs = -1;
-  let endTs = -1;
+  let startTs = -1n;
+  let endTs = -1n;
   if (selection === null) {
     return {startTs, endTs};
   } else if (selection.kind === 'SLICE' || selection.kind === 'CHROME_SLICE') {
     const slice = globals.sliceDetails;
     if (slice.ts && slice.dur !== undefined && slice.dur > 0) {
-      startTs = slice.ts + globals.state.traceTime.startSec;
+      startTs = slice.ts;
       endTs = startTs + slice.dur;
     } else if (slice.ts) {
-      startTs = slice.ts + globals.state.traceTime.startSec;
+      startTs = slice.ts;
       // This will handle either:
       // a)slice.dur === -1 -> unfinished slice
       // b)slice.dur === 0  -> instant event
-      endTs = slice.dur === -1 ? globals.state.traceTime.endSec :
-                                 startTs + INSTANT_FOCUS_DURATION_S;
+      endTs = slice.dur === -1n ? startTs + INCOMPLETE_SLICE_DURATION :
+                                  startTs + INSTANT_FOCUS_DURATION;
     }
   } else if (selection.kind === 'THREAD_STATE') {
     const threadState = globals.threadStateDetails;
     if (threadState.ts && threadState.dur) {
-      startTs = threadState.ts + globals.state.traceTime.startSec;
+      startTs = threadState.ts;
       endTs = startTs + threadState.dur;
     }
   } else if (selection.kind === 'COUNTER') {
@@ -232,8 +235,8 @@
   } else if (selection.kind === 'AREA') {
     const selectedArea = globals.state.areas[selection.areaId];
     if (selectedArea) {
-      startTs = selectedArea.startSec;
-      endTs = selectedArea.endSec;
+      startTs = selectedArea.start;
+      endTs = selectedArea.end;
     }
   } else if (selection.kind === 'NOTE') {
     const selectedNote = globals.state.notes[selection.id];
@@ -241,18 +244,18 @@
     // above in the AREA case.
     if (selectedNote && selectedNote.noteType === 'DEFAULT') {
       startTs = selectedNote.timestamp;
-      endTs = selectedNote.timestamp + INSTANT_FOCUS_DURATION_S;
+      endTs = selectedNote.timestamp + INSTANT_FOCUS_DURATION;
     }
   } else if (selection.kind === 'LOG') {
     // TODO(hjd): Make focus selection work for logs.
   } else if (
       selection.kind === 'DEBUG_SLICE' ||
       selection.kind === 'TOP_LEVEL_SCROLL') {
-    startTs = selection.startS;
-    if (selection.durationS > 0) {
-      endTs = startTs + selection.durationS;
+    startTs = selection.start;
+    if (selection.duration > 0) {
+      endTs = startTs + selection.duration;
     } else {
-      endTs = startTs + INSTANT_FOCUS_DURATION_S;
+      endTs = startTs + INSTANT_FOCUS_DURATION;
     }
   }
 
@@ -262,12 +265,12 @@
 
 function lockSliceSpan(persistent = false) {
   const range = findTimeRangeOfSelection();
-  if (range.startTs !== -1 && range.endTs !== -1 &&
+  if (range.startTs !== -1n && range.endTs !== -1n &&
       globals.state.currentSelection !== null) {
     const tracks = globals.state.currentSelection.trackId ?
         [globals.state.currentSelection.trackId] :
         [];
-    const area: Area = {startSec: range.startTs, endSec: range.endTs, tracks};
+    const area: Area = {start: range.startTs, end: range.endTs, tracks};
     globals.dispatch(Actions.markArea({area, persistent}));
   }
 }
@@ -277,7 +280,7 @@
   if (selection === null) return;
 
   const range = findTimeRangeOfSelection();
-  if (range.startTs !== -1 && range.endTs !== -1) {
+  if (range.startTs !== -1n && range.endTs !== -1n) {
     focusHorizontalRange(range.startTs, range.endTs);
   }
 
diff --git a/ui/src/frontend/logs_panel.ts b/ui/src/frontend/logs_panel.ts
index 18ed325..88baa1c 100644
--- a/ui/src/frontend/logs_panel.ts
+++ b/ui/src/frontend/logs_panel.ts
@@ -16,14 +16,14 @@
 
 import {assertExists} from '../base/logging';
 import {Actions} from '../common/actions';
+import {HighPrecisionTimeSpan} from '../common/high_precision_time';
 import {
   LogBounds,
   LogBoundsKey,
   LogEntries,
   LogEntriesKey,
 } from '../common/logs';
-import {formatTimestamp} from '../common/time';
-import {TimeSpan} from '../common/time';
+import {formatTPTime, TPTime} from '../common/time';
 
 import {SELECTED_LOG_ROWS_COLOR} from './css_constants';
 import {globals} from './globals';
@@ -62,12 +62,16 @@
         dom.parentElement!.parentElement!.parentElement as HTMLElement);
     this.scrollContainer.addEventListener(
         'scroll', this.onScroll.bind(this), {passive: true});
+    // TODO(stevegolton): Type assersions are a source of bugs.
+    // Let's try to find another way of doing this.
     this.bounds = globals.trackDataStore.get(LogBoundsKey) as LogBounds;
     this.entries = globals.trackDataStore.get(LogEntriesKey) as LogEntries;
     this.recomputeVisibleRowsAndUpdate();
   }
 
   onbeforeupdate(_: m.CVnodeDOM) {
+    // TODO(stevegolton): Type assersions are a source of bugs.
+    // Let's try to find another way of doing this.
     this.bounds = globals.trackDataStore.get(LogBoundsKey) as LogBounds;
     this.entries = globals.trackDataStore.get(LogEntriesKey) as LogEntries;
     this.recomputeVisibleRowsAndUpdate();
@@ -79,12 +83,12 @@
     globals.rafScheduler.scheduleFullRedraw();
   }
 
-  onRowOver(ts: number) {
+  onRowOver(ts: TPTime) {
     globals.dispatch(Actions.setHoverCursorTimestamp({ts}));
   }
 
   onRowOut() {
-    globals.dispatch(Actions.setHoverCursorTimestamp({ts: -1}));
+    globals.dispatch(Actions.setHoverCursorTimestamp({ts: -1n}));
   }
 
   private totalRows():
@@ -92,17 +96,19 @@
     if (!this.bounds) {
       return {isStale: false, total: 0, offset: 0, count: 0};
     }
-    const {total, startTs, endTs, firstRowTs, lastRowTs} = this.bounds;
+    const {
+      totalVisibleLogs,
+      firstVisibleLogTs,
+      lastVisibleLogTs,
+    } = this.bounds;
     const vis = globals.frontendLocalState.visibleWindowTime;
-    const leftSpan = new TimeSpan(startTs, firstRowTs);
-    const rightSpan = new TimeSpan(lastRowTs, endTs);
 
-    const isStaleLeft = !leftSpan.isInBounds(vis.start);
-    const isStaleRight = !rightSpan.isInBounds(vis.end);
-    const isStale = isStaleLeft || isStaleRight;
-    const offset = Math.min(this.visibleRowOffset, total);
-    const visCount = Math.min(total - offset, this.visibleRowCount);
-    return {isStale, total, count: visCount, offset};
+    const visibleLogSpan =
+        new HighPrecisionTimeSpan(firstVisibleLogTs, lastVisibleLogTs);
+    const isStale = !vis.contains(visibleLogSpan);
+    const offset = Math.min(this.visibleRowOffset, totalVisibleLogs);
+    const visCount = Math.min(totalVisibleLogs - offset, this.visibleRowCount);
+    return {isStale, total: totalVisibleLogs, count: visCount, offset};
   }
 
   view(_: m.CVnode<{}>) {
@@ -146,11 +152,10 @@
               {
                 'class': isStale ? 'stale' : '',
                 style,
-                'onmouseover': this.onRowOver.bind(this, ts / 1e9),
+                'onmouseover': this.onRowOver.bind(this, ts),
                 'onmouseout': this.onRowOut.bind(this),
               },
-              m('.cell',
-                formatTimestamp(ts / 1e9 - globals.state.traceTime.startSec)),
+              m('.cell', formatTPTime(ts - globals.state.traceTime.start)),
               m('.cell', priorityLetter || '?'),
               m('.cell', tags[i]),
               hasProcessNames ? m('.cell.with-process', processNames[i]) :
diff --git a/ui/src/frontend/notes_panel.ts b/ui/src/frontend/notes_panel.ts
index d3eec07..27d9ec8 100644
--- a/ui/src/frontend/notes_panel.ts
+++ b/ui/src/frontend/notes_panel.ts
@@ -17,7 +17,9 @@
 import {Actions} from '../common/actions';
 import {randomColor} from '../common/colorizer';
 import {AreaNote, Note} from '../common/state';
-import {timeToString} from '../common/time';
+import {
+  tpTimeToString,
+} from '../common/time';
 
 import {
   BottomTab,
@@ -28,6 +30,7 @@
 import {PerfettoMouseEvent} from './events';
 import {globals} from './globals';
 import {
+  getMaxMajorTicks,
   TickGenerator,
   TickType,
   timeScaleForVisibleWindow,
@@ -46,7 +49,7 @@
 
 function getStartTimestamp(note: Note|AreaNote) {
   if (note.noteType === 'AREA') {
-    return globals.state.areas[note.areaId].startSec;
+    return globals.state.areas[note.areaId].start;
   } else {
     return note.timestamp;
   }
@@ -66,7 +69,7 @@
     });
     dom.addEventListener('mouseout', () => {
       this.hoveredX = null;
-      globals.dispatch(Actions.setHoveredNoteTimestamp({ts: -1}));
+      globals.dispatch(Actions.setHoveredNoteTimestamp({ts: -1n}));
     }, {passive: true});
   }
 
@@ -110,15 +113,27 @@
   }
 
   renderCanvas(ctx: CanvasRenderingContext2D, size: PanelSize) {
-    const timeScale = globals.frontendLocalState.timeScale;
     let aNoteIsHovered = false;
 
     ctx.fillStyle = '#999';
     ctx.fillRect(TRACK_SHELL_WIDTH - 2, 0, 2, size.height);
-    const relScale = timeScaleForVisibleWindow(TRACK_SHELL_WIDTH, size.width);
-    if (relScale.timeSpan.duration > 0 && relScale.widthPx > 0) {
-      for (const {type, position} of new TickGenerator(relScale)) {
-        if (type === TickType.MAJOR) ctx.fillRect(position, 0, 1, size.height);
+
+    ctx.save();
+    ctx.beginPath();
+    ctx.rect(TRACK_SHELL_WIDTH, 0, size.width - TRACK_SHELL_WIDTH, size.height);
+    ctx.clip();
+
+    const span = globals.frontendLocalState.visibleWindow.timestampSpan;
+    const {visibleTimeScale} = globals.frontendLocalState;
+    if (size.width > TRACK_SHELL_WIDTH && span.duration > 0n) {
+      const maxMajorTicks = getMaxMajorTicks(size.width - TRACK_SHELL_WIDTH);
+      const map = timeScaleForVisibleWindow(TRACK_SHELL_WIDTH, size.width);
+      for (const {type, time} of new TickGenerator(
+               span, maxMajorTicks, globals.state.traceTime.start)) {
+        const px = Math.floor(map.tpTimeToPx(time));
+        if (type === TickType.MAJOR) {
+          ctx.fillRect(px, 0, 1, size.height);
+        }
       }
     }
 
@@ -129,11 +144,10 @@
       const timestamp = getStartTimestamp(note);
       // TODO(hjd): We should still render area selection marks in viewport is
       // *within* the area (e.g. both lhs and rhs are out of bounds).
-      if ((note.noteType !== 'AREA' && !timeScale.timeInBounds(timestamp)) ||
+      if ((note.noteType !== 'AREA' && !span.contains(timestamp)) ||
           (note.noteType === 'AREA' &&
-           !timeScale.timeInBounds(globals.state.areas[note.areaId].endSec) &&
-           !timeScale.timeInBounds(
-               globals.state.areas[note.areaId].startSec))) {
+           !span.contains(globals.state.areas[note.areaId].end) &&
+           !span.contains(globals.state.areas[note.areaId].start))) {
         continue;
       }
       const currentIsHovered =
@@ -144,7 +158,7 @@
       const isSelected = selection !== null &&
           ((selection.kind === 'NOTE' && selection.id === note.id) ||
            (selection.kind === 'AREA' && selection.noteId === note.id));
-      const x = timeScale.timeToPx(timestamp);
+      const x = visibleTimeScale.tpTimeToPx(timestamp);
       const left = Math.floor(x + TRACK_SHELL_WIDTH);
 
       // Draw flag or marker.
@@ -153,7 +167,8 @@
         this.drawAreaMarker(
             ctx,
             left,
-            Math.floor(timeScale.timeToPx(area.endSec) + TRACK_SHELL_WIDTH),
+            Math.floor(
+                visibleTimeScale.tpTimeToPx(area.end) + TRACK_SHELL_WIDTH),
             note.color,
             isSelected);
       } else {
@@ -175,19 +190,21 @@
     // A real note is hovered so we don't need to see the preview line.
     // TODO(hjd): Change cursor to pointer here.
     if (aNoteIsHovered) {
-      globals.dispatch(Actions.setHoveredNoteTimestamp({ts: -1}));
+      globals.dispatch(Actions.setHoveredNoteTimestamp({ts: -1n}));
     }
 
     // View preview note flag when hovering on notes panel.
     if (!aNoteIsHovered && this.hoveredX !== null) {
-      const timestamp = timeScale.pxToTime(this.hoveredX);
-      if (timeScale.timeInBounds(timestamp)) {
+      const timestamp = visibleTimeScale.pxToHpTime(this.hoveredX).toTPTime();
+      if (span.contains(timestamp)) {
         globals.dispatch(Actions.setHoveredNoteTimestamp({ts: timestamp}));
-        const x = timeScale.timeToPx(timestamp);
+        const x = visibleTimeScale.tpTimeToPx(timestamp);
         const left = Math.floor(x + TRACK_SHELL_WIDTH);
         this.drawFlag(ctx, left, size.height, '#aaa', /* fill */ true);
       }
     }
+
+    ctx.restore();
   }
 
   private drawAreaMarker(
@@ -197,7 +214,7 @@
     ctx.strokeStyle = color;
     const topOffset = 10;
     // Don't draw in the track shell section.
-    if (x >= globals.frontendLocalState.timeScale.startPx + TRACK_SHELL_WIDTH) {
+    if (x >= globals.frontendLocalState.windowSpan.start + TRACK_SHELL_WIDTH) {
       // Draw left triangle.
       ctx.beginPath();
       ctx.moveTo(x, topOffset);
@@ -218,7 +235,7 @@
 
     // Start line after track shell section, join triangles.
     const startDraw = Math.max(
-        x, globals.frontendLocalState.timeScale.startPx + TRACK_SHELL_WIDTH);
+        x, globals.frontendLocalState.windowSpan.start + TRACK_SHELL_WIDTH);
     ctx.beginPath();
     ctx.moveTo(startDraw, topOffset);
     ctx.lineTo(xEnd, topOffset);
@@ -250,8 +267,8 @@
 
   private onClick(x: number, _: number) {
     if (x < 0) return;
-    const timeScale = globals.frontendLocalState.timeScale;
-    const timestamp = timeScale.pxToTime(x);
+    const {visibleTimeScale} = globals.frontendLocalState;
+    const timestamp = visibleTimeScale.pxToHpTime(x).toTPTime();
     for (const note of Object.values(globals.state.notes)) {
       if (this.hoveredX && this.mouseOverNote(this.hoveredX, note)) {
         if (note.noteType === 'AREA') {
@@ -268,13 +285,13 @@
   }
 
   private mouseOverNote(x: number, note: AreaNote|Note): boolean {
-    const timeScale = globals.frontendLocalState.timeScale;
-    const noteX = timeScale.timeToPx(getStartTimestamp(note));
+    const timeScale = globals.frontendLocalState.visibleTimeScale;
+    const noteX = timeScale.tpTimeToPx(getStartTimestamp(note));
     if (note.noteType === 'AREA') {
       const noteArea = globals.state.areas[note.areaId];
       return (noteX <= x && x < noteX + AREA_TRIANGLE_WIDTH) ||
-          (timeScale.timeToPx(noteArea.endSec) > x &&
-           x > timeScale.timeToPx(noteArea.endSec) - AREA_TRIANGLE_WIDTH);
+          (timeScale.tpTimeToPx(noteArea.end) > x &&
+           x > timeScale.tpTimeToPx(noteArea.end) - AREA_TRIANGLE_WIDTH);
     } else {
       const width = FLAG_WIDTH;
       return noteX <= x && x < noteX + width;
@@ -308,13 +325,12 @@
     if (note === undefined) {
       return m('.', `No Note with id ${this.config.id}`);
     }
-    const startTime =
-        getStartTimestamp(note) - globals.state.traceTime.startSec;
+    const startTime = getStartTimestamp(note) - globals.state.traceTime.start;
     return m(
         '.notes-editor-panel',
         m('.notes-editor-panel-heading-bar',
           m('.notes-editor-panel-heading',
-            `Annotation at ${timeToString(startTime)}`),
+            `Annotation at ${tpTimeToString(startTime)}`),
           m('input[type=text]', {
             onkeydown: (e: Event) => {
               e.stopImmediatePropagation();
diff --git a/ui/src/frontend/overview_timeline_panel.ts b/ui/src/frontend/overview_timeline_panel.ts
index 76b4515..54f932e 100644
--- a/ui/src/frontend/overview_timeline_panel.ts
+++ b/ui/src/frontend/overview_timeline_panel.ts
@@ -14,9 +14,12 @@
 
 import m from 'mithril';
 
-import {assertExists} from '../base/logging';
 import {hueForCpu} from '../common/colorizer';
-import {TimeSpan} from '../common/time';
+import {
+  Span,
+  TPTime,
+  tpTimeToSeconds,
+} from '../common/time';
 
 import {
   OVERVIEW_TIMELINE_NON_VISIBLE_COLOR,
@@ -29,9 +32,9 @@
 import {OuterDragStrategy} from './drag/outer_drag_strategy';
 import {DragGestureHandler} from './drag_gesture_handler';
 import {globals} from './globals';
-import {TickGenerator, TickType} from './gridline_helper';
+import {getMaxMajorTicks, TickGenerator, TickType} from './gridline_helper';
 import {Panel, PanelSize} from './panel';
-import {TimeScale} from './time_scale';
+import {PxSpan, TimeScale} from './time_scale';
 
 export class OverviewTimelinePanel extends Panel {
   private static HANDLE_SIZE_PX = 5;
@@ -39,7 +42,7 @@
   private width = 0;
   private gesture?: DragGestureHandler;
   private timeScale?: TimeScale;
-  private totTime = new TimeSpan(0, 0);
+  private traceTime?: Span<TPTime>;
   private dragStrategy?: DragStrategy;
   private readonly boundOnMouseMove = this.onMouseMove.bind(this);
 
@@ -47,11 +50,11 @@
   // https://github.com/Microsoft/TypeScript/issues/1373
   onupdate({dom}: m.CVnodeDOM) {
     this.width = dom.getBoundingClientRect().width;
-    this.totTime = new TimeSpan(
-        globals.state.traceTime.startSec, globals.state.traceTime.endSec);
-    this.timeScale = new TimeScale(
-        this.totTime, [TRACK_SHELL_WIDTH, assertExists(this.width)]);
-
+    this.traceTime = globals.stateTraceTimeTP();
+    const traceTime = globals.stateTraceTime();
+    const pxSpan = new PxSpan(TRACK_SHELL_WIDTH, this.width);
+    this.timeScale =
+        new TimeScale(traceTime.start, traceTime.duration.nanos, pxSpan);
     if (this.gesture === undefined) {
       this.gesture = new DragGestureHandler(
           dom as HTMLElement,
@@ -78,26 +81,27 @@
 
   renderCanvas(ctx: CanvasRenderingContext2D, size: PanelSize) {
     if (this.width === undefined) return;
+    if (this.traceTime === undefined) return;
     if (this.timeScale === undefined) return;
     const headerHeight = 20;
     const tracksHeight = size.height - headerHeight;
-    const timeSpan = new TimeSpan(0, this.totTime.duration);
 
-    const timeScale = new TimeScale(timeSpan, [TRACK_SHELL_WIDTH, this.width]);
-
-    if (timeScale.timeSpan.duration > 0 && timeScale.widthPx > 0) {
-      const tickGen = new TickGenerator(timeScale);
+    if (size.width > TRACK_SHELL_WIDTH && this.traceTime.duration > 0n) {
+      const maxMajorTicks = getMaxMajorTicks(this.width - TRACK_SHELL_WIDTH);
+      const tickGen = new TickGenerator(
+          this.traceTime, maxMajorTicks, globals.state.traceTime.start);
 
       // Draw time labels on the top header.
       ctx.font = '10px Roboto Condensed';
       ctx.fillStyle = '#999';
-      for (const {type, time, position} of tickGen) {
-        const xPos = Math.round(position);
+      for (const {type, time} of tickGen) {
+        const xPos = Math.floor(this.timeScale.tpTimeToPx(time));
         if (xPos <= 0) continue;
         if (xPos > this.width) break;
         if (type === TickType.MAJOR) {
           ctx.fillRect(xPos - 1, 0, 1, headerHeight - 5);
-          ctx.fillText(time.toFixed(tickGen.digits) + ' s', xPos + 5, 18);
+          const sec = tpTimeToSeconds(time - globals.state.traceTime.start);
+          ctx.fillText(sec.toFixed(tickGen.digits) + ' s', xPos + 5, 18);
         } else if (type == TickType.MEDIUM) {
           ctx.fillRect(xPos - 1, 0, 1, 8);
         } else if (type == TickType.MINOR) {
@@ -114,8 +118,8 @@
       for (const key of globals.overviewStore.keys()) {
         const loads = globals.overviewStore.get(key)!;
         for (let i = 0; i < loads.length; i++) {
-          const xStart = Math.floor(this.timeScale.timeToPx(loads[i].startSec));
-          const xEnd = Math.ceil(this.timeScale.timeToPx(loads[i].endSec));
+          const xStart = Math.floor(this.timeScale.tpTimeToPx(loads[i].start));
+          const xEnd = Math.ceil(this.timeScale.tpTimeToPx(loads[i].end));
           const yOff = Math.floor(headerHeight + y * trackHeight);
           const lightness = Math.ceil((1 - loads[i].load * 0.7) * 100);
           ctx.fillStyle = `hsl(${hueForCpu(y)}, 50%, ${lightness}%)`;
@@ -210,10 +214,10 @@
   }
 
   private static extractBounds(timeScale: TimeScale): [number, number] {
-    const vizTime = globals.frontendLocalState.getVisibleStateBounds();
+    const vizTime = globals.frontendLocalState.visibleWindowTime;
     return [
-      Math.floor(timeScale.timeToPx(vizTime[0])),
-      Math.ceil(timeScale.timeToPx(vizTime[1])),
+      Math.floor(timeScale.hpTimeToPx(vizTime.start)),
+      Math.ceil(timeScale.hpTimeToPx(vizTime.end)),
     ];
   }
 
diff --git a/ui/src/frontend/panel_container.ts b/ui/src/frontend/panel_container.ts
index 4c6576f..b7841d7 100644
--- a/ui/src/frontend/panel_container.ts
+++ b/ui/src/frontend/panel_container.ts
@@ -135,11 +135,13 @@
       return;
     }
 
+    const {visibleTimeScale} = globals.frontendLocalState;
+
     // The Y value is given from the top of the pan and zoom region, we want it
     // from the top of the panel container. The parent offset corrects that.
     const panels = this.getPanelsInRegion(
-        globals.frontendLocalState.timeScale.timeToPx(area.startSec),
-        globals.frontendLocalState.timeScale.timeToPx(area.endSec),
+        visibleTimeScale.tpTimeToPx(area.start),
+        visibleTimeScale.tpTimeToPx(area.end),
         globals.frontendLocalState.areaY.start + TOPBAR_HEIGHT,
         globals.frontendLocalState.areaY.end + TOPBAR_HEIGHT);
     // Get the track ids from the panels.
@@ -160,7 +162,7 @@
         }
       }
     }
-    globals.frontendLocalState.selectArea(area.startSec, area.endSec, tracks);
+    globals.frontendLocalState.selectArea(area.start, area.end, tracks);
   }
 
   constructor(vnode: m.CVnode<Attrs>) {
@@ -449,8 +451,9 @@
       return;
     }
 
-    const startX = globals.frontendLocalState.timeScale.timeToPx(area.startSec);
-    const endX = globals.frontendLocalState.timeScale.timeToPx(area.endSec);
+    const {visibleTimeScale} = globals.frontendLocalState;
+    const startX = visibleTimeScale.tpTimeToPx(area.start);
+    const endX = visibleTimeScale.tpTimeToPx(area.end);
     // To align with where to draw on the canvas subtract the first panel Y.
     selectedTracksMinY -= this.panelContainerTop;
     selectedTracksMaxY -= this.panelContainerTop;
diff --git a/ui/src/frontend/pivot_table_query_generator.ts b/ui/src/frontend/pivot_table_query_generator.ts
index 0c61f56..dffa6e4 100644
--- a/ui/src/frontend/pivot_table_query_generator.ts
+++ b/ui/src/frontend/pivot_table_query_generator.ts
@@ -20,7 +20,6 @@
   PivotTableQuery,
   PivotTableState,
 } from '../common/state';
-import {toNs} from '../common/time';
 import {
   getSelectedTrackIds,
 } from '../controller/aggregation/slice_aggregation_controller';
@@ -100,8 +99,8 @@
 
 export function areaFilter(area: Area): string {
   return `
-    ts + dur > ${toNs(area.startSec)}
-    and ts < ${toNs(area.endSec)}
+    ts + dur > ${area.start}
+    and ts < ${area.end}
     and track_id in (${getSelectedTrackIds(area).join(', ')})
   `;
 }
diff --git a/ui/src/frontend/query_table.ts b/ui/src/frontend/query_table.ts
index c27ecc2..a58d1d6 100644
--- a/ui/src/frontend/query_table.ts
+++ b/ui/src/frontend/query_table.ts
@@ -14,13 +14,15 @@
 
 
 import m from 'mithril';
+import {BigintMath} from '../base/bigint_math';
 
 import {Actions} from '../common/actions';
 import {QueryResponse} from '../common/queries';
 import {ColumnType, Row} from '../common/query_result';
-import {fromNs} from '../common/time';
-import {Anchor} from './anchor';
+import {TPTime, tpTimeFromNanos} from '../common/time';
+import {TPDuration} from '../common/time';
 
+import {Anchor} from './anchor';
 import {copyToClipboard, queryResponseToClipboard} from './clipboard';
 import {downloadData} from './download_utils';
 import {globals} from './globals';
@@ -38,6 +40,16 @@
 }
 
 // Convert column value to number if it's a bigint or a number, otherwise throw
+function colToTimestamp(colValue: ColumnType): TPTime {
+  if (typeof colValue === 'bigint') {
+    return colValue;
+  } else if (typeof colValue === 'number') {
+    return tpTimeFromNanos(colValue);
+  } else {
+    throw Error('Value is not a number or a bigint');
+  }
+}
+
 function colToNumber(colValue: ColumnType): number {
   if (typeof colValue === 'bigint') {
     return Number(colValue);
@@ -48,6 +60,15 @@
   }
 }
 
+function colToDuration(colValue: ColumnType): TPDuration {
+  return colToTimestamp(colValue);
+}
+
+function clampDurationLower(
+    dur: TPDuration, lowerClamp: TPDuration): TPDuration {
+  return BigintMath.max(dur, lowerClamp);
+}
+
 class QueryTableRow implements m.ClassComponent<QueryTableRowAttrs> {
   static columnsContainsSliceLocation(columns: string[]) {
     const requiredColumns = ['ts', 'dur', 'track_id'];
@@ -65,15 +86,14 @@
     // the slice.
     event.stopPropagation();
 
-    const sliceStart = fromNs(colToNumber(row.ts));
+    const sliceStart = colToTimestamp(row.ts);
     // row.dur can be negative. Clamp to 1ns.
-    const sliceDur = fromNs(Math.max(colToNumber(row.dur), 1));
+    const sliceDur = clampDurationLower(colToDuration(row.dur), 1n);
     const sliceEnd = sliceStart + sliceDur;
-    const trackId: number = colToNumber(row.track_id);
+    const trackId = colToNumber(row.track_id);
     const uiTrackId = globals.state.uiTrackIdByTraceTrackId[trackId];
     if (uiTrackId === undefined) return;
     verticalScrollToTrack(uiTrackId, true);
-    // TODO(stevegolton) Soon this function will only accept Bigints
     focusHorizontalRange(sliceStart, sliceEnd);
 
     let sliceId: number|undefined;
diff --git a/ui/src/frontend/scroll_helper.ts b/ui/src/frontend/scroll_helper.ts
index 18a9a78..f177a65 100644
--- a/ui/src/frontend/scroll_helper.ts
+++ b/ui/src/frontend/scroll_helper.ts
@@ -13,23 +13,28 @@
 // limitations under the License.
 
 import {Actions} from '../common/actions';
+import {
+  HighPrecisionTime,
+  HighPrecisionTimeSpan,
+} from '../common/high_precision_time';
 import {getContainingTrackId} from '../common/state';
-import {fromNs, TimeSpan, toNs} from '../common/time';
+import {TPTime} from '../common/time';
 
 import {globals} from './globals';
 
-const INCOMPLETE_SLICE_TIME_S = 0.00003;
 
 // Given a timestamp, if |ts| is not currently in view move the view to
 // center |ts|, keeping the same zoom level.
-export function horizontalScrollToTs(ts: number) {
-  const startNs = toNs(globals.frontendLocalState.visibleWindowTime.start);
-  const endNs = toNs(globals.frontendLocalState.visibleWindowTime.end);
-  const currentViewNs = endNs - startNs;
-  if (ts < startNs || ts > endNs) {
+// TODO(stevegolton): Remove me!
+export function horizontalScrollToTs(ts: TPTime) {
+  console.log('horizontalScrollToTs', ts);
+  const time = HighPrecisionTime.fromTPTime(ts);
+  const {start, end, duration} = globals.frontendLocalState.visibleWindowTime;
+  const halfDuration = duration.nanos / 2;
+  if (time.isLessThan(start) || time.isGreaterThan(end)) {
     // TODO(hjd): This is an ugly jump, we should do a smooth pan instead.
-    globals.frontendLocalState.updateVisibleTime(new TimeSpan(
-        fromNs(ts - currentViewNs / 2), fromNs(ts + currentViewNs / 2)));
+    globals.frontendLocalState.updateVisibleTime(new HighPrecisionTimeSpan(
+        time.subtractNanos(halfDuration), time.addNanos(halfDuration)));
   }
 }
 
@@ -46,16 +51,11 @@
 //   to cover 1/5 of the viewport.
 // - Otherwise, preserve the zoom range.
 export function focusHorizontalRange(
-    startTs: number, endTs: number, viewPercentage?: number) {
-  const visibleDur = globals.frontendLocalState.visibleWindowTime.end -
-      globals.frontendLocalState.visibleWindowTime.start;
-  let selectDur = endTs - startTs;
-  // TODO(altimin): We go from `ts` and `dur` to `startTs` and `endTs` and back
-  // to `dur`. We should fix that.
-  if (toNs(selectDur) === -1) {  // Unfinished slice
-    selectDur = INCOMPLETE_SLICE_TIME_S;
-    endTs = startTs;
-  }
+    start: TPTime, end: TPTime, viewPercentage?: number) {
+  console.log('focusHorizontalRange', start, end);
+  const visible = globals.frontendLocalState.visibleWindowTime;
+  const trace = globals.stateTraceTime();
+  const select = HighPrecisionTimeSpan.fromTpTime(start, end);
 
   if (viewPercentage !== undefined) {
     if (viewPercentage <= 0.0 || viewPercentage > 1.0) {
@@ -67,51 +67,43 @@
       viewPercentage = 0.5;
     }
     const paddingPercentage = 1.0 - viewPercentage;
-    const paddingTime = selectDur * paddingPercentage;
-    const halfPaddingTime = paddingTime / 2;
-    globals.frontendLocalState.updateVisibleTime(
-        new TimeSpan(startTs - halfPaddingTime, endTs + halfPaddingTime));
+    const paddingTime = select.duration.multiply(paddingPercentage);
+    const halfPaddingTime = paddingTime.divide(2);
+    globals.frontendLocalState.updateVisibleTime(select.pad(halfPaddingTime));
     return;
   }
-
   // If the range is too large to fit on the current zoom level, resize.
-  if (selectDur > 0.5 * visibleDur) {
-    globals.frontendLocalState.updateVisibleTime(
-        new TimeSpan(startTs - (selectDur * 2), endTs + (selectDur * 2)));
+  if (select.duration.isGreaterThan(visible.duration.multiply(0.5))) {
+    const paddedRange = select.pad(select.duration.multiply(2));
+    globals.frontendLocalState.updateVisibleTime(paddedRange);
     return;
   }
-  const midpointTs = (endTs + startTs) / 2;
   // Calculate the new visible window preserving the zoom level.
-  let newStartTs = midpointTs - visibleDur / 2;
-  let newEndTs = midpointTs + visibleDur / 2;
+  let newStart = select.midpoint.subtract(visible.duration.divide(2));
+  let newEnd = select.midpoint.add(visible.duration.divide(2));
 
   // Adjust the new visible window if it intersects with the trace boundaries.
   // It's needed to make the "update the zoom level if visible window doesn't
   // change" logic reliable.
-  if (newEndTs > globals.state.traceTime.endSec) {
-    newStartTs = globals.state.traceTime.endSec - visibleDur;
-    newEndTs = globals.state.traceTime.endSec;
+  if (newEnd.isGreaterThan(trace.end)) {
+    newStart = trace.end.subtract(visible.duration);
+    newEnd = trace.end;
   }
-  if (newStartTs < globals.state.traceTime.startSec) {
-    newStartTs = globals.state.traceTime.startSec;
-    newEndTs = globals.state.traceTime.startSec + visibleDur;
+  if (newStart.isLessThan(trace.start)) {
+    newStart = trace.start;
+    newEnd = trace.start.add(visible.duration);
   }
 
-  const newStartNs = toNs(newStartTs);
-  const newEndNs = toNs(newEndTs);
-
-  const viewStartNs = toNs(globals.frontendLocalState.visibleWindowTime.start);
-  const viewEndNs = toNs(globals.frontendLocalState.visibleWindowTime.end);
+  const view = new HighPrecisionTimeSpan(newStart, newEnd);
 
   // If preserving the zoom doesn't change the visible window, update the zoom
   // level.
-  if (newStartNs === viewStartNs && newEndNs === viewEndNs) {
-    globals.frontendLocalState.updateVisibleTime(
-        new TimeSpan(startTs - (selectDur * 2), endTs + (selectDur * 2)));
-    return;
+  if (view.start.equals(visible.start) && view.end.equals(visible.end)) {
+    const padded = select.pad(select.duration.multiply(2));
+    globals.frontendLocalState.updateVisibleTime(padded);
+  } else {
+    globals.frontendLocalState.updateVisibleTime(view);
   }
-  globals.frontendLocalState.updateVisibleTime(
-      new TimeSpan(newStartTs, newEndTs));
 }
 
 // Given a track id, find a track with that id and scroll it into view. If the
@@ -155,7 +147,7 @@
 
 // Scroll vertically and horizontally to reach track (|trackId|) at |ts|.
 export function scrollToTrackAndTs(
-    trackId: string|number|undefined, ts: number, openGroup = false) {
+    trackId: string|number|undefined, ts: TPTime, openGroup = false) {
   if (trackId !== undefined) {
     verticalScrollToTrack(trackId, openGroup);
   }
diff --git a/ui/src/frontend/search_handler.ts b/ui/src/frontend/search_handler.ts
index 1622a7e..f995617 100644
--- a/ui/src/frontend/search_handler.ts
+++ b/ui/src/frontend/search_handler.ts
@@ -14,8 +14,6 @@
 
 import {searchSegment} from '../base/binary_search';
 import {Actions} from '../common/actions';
-import {toNs} from '../common/time';
-
 import {globals} from './globals';
 
 function setToPrevious(current: number) {
@@ -34,8 +32,9 @@
 
 export function executeSearch(reverse = false) {
   const index = globals.state.searchIndex;
-  const startNs = toNs(globals.frontendLocalState.visibleWindowTime.start);
-  const endNs = toNs(globals.frontendLocalState.visibleWindowTime.end);
+  const vizWindow = globals.frontendLocalState.visibleWindowTime;
+  const startNs = vizWindow.start.nanos;
+  const endNs = vizWindow.end.nanos;
   const currentTs = globals.currentSearchResults.tsStarts[index];
 
   // If the value of |globals.currentSearchResults.totalResults| is 0,
diff --git a/ui/src/frontend/slice.ts b/ui/src/frontend/slice.ts
index 5b660ef..7587a27 100644
--- a/ui/src/frontend/slice.ts
+++ b/ui/src/frontend/slice.ts
@@ -13,13 +13,14 @@
 // limitations under the License.
 
 import {Color} from '../common/colorizer';
+import {TPDuration, TPTime} from '../common/time';
 
 export interface Slice {
   // These properties are updated only once per query result when the Slice
   // object is created and don't change afterwards.
   readonly id: number;
-  readonly startS: number;
-  readonly durationS: number;
+  readonly start: TPTime;
+  readonly duration: TPDuration;
   readonly depth: number;
   readonly flags: number;
 
diff --git a/ui/src/frontend/slice_details_panel.ts b/ui/src/frontend/slice_details_panel.ts
index 9b018dd..13e3dd5 100644
--- a/ui/src/frontend/slice_details_panel.ts
+++ b/ui/src/frontend/slice_details_panel.ts
@@ -16,7 +16,7 @@
 
 import {Actions} from '../common/actions';
 import {translateState} from '../common/thread_state';
-import {timeToCode, toNs} from '../common/time';
+import {tpTimeToCode} from '../common/time';
 import {globals, SliceDetails, ThreadDesc} from './globals';
 import {scrollToTrackAndTs} from './scroll_helper';
 import {SlicePanel} from './slice_panel';
@@ -60,9 +60,8 @@
     if (!threadInfo) {
       return null;
     }
-    const timestamp = timeToCode(
-        sliceInfo.wakeupTs! - globals.state.traceTime.startSec,
-    );
+    const timestamp =
+        tpTimeToCode(sliceInfo.wakeupTs! - globals.state.traceTime.start);
     return m(
         '.slice-details-wakeup-text',
         m('', `Wakeup @ ${timestamp} on CPU ${sliceInfo.wakerCpu} by`),
@@ -76,9 +75,7 @@
       return null;
     }
 
-    const latency = timeToCode(
-        sliceInfo.ts - (sliceInfo.wakeupTs - globals.state.traceTime.startSec),
-    );
+    const latency = tpTimeToCode(sliceInfo.ts - sliceInfo.wakeupTs);
     return m(
         '.slice-details-latency-text',
         m('', `Scheduling latency: ${latency}`),
@@ -111,7 +108,10 @@
               {onclick: () => this.goToThread(), title: 'Go to thread'},
               'call_made'))),
         m('tr', m('th', `Cmdline`), m('td', threadInfo.cmdline)),
-        m('tr', m('th', `Start time`), m('td', `${timeToCode(sliceInfo.ts)}`)),
+        m('tr',
+          m('th', `Start time`),
+          m('td',
+            `${tpTimeToCode(sliceInfo.ts - globals.state.traceTime.start)}`)),
         m('tr',
           m('th', `Duration`),
           m('td', this.computeDuration(sliceInfo.ts, sliceInfo.dur))),
@@ -172,8 +172,7 @@
         trackId: trackId.toString(),
       }));
 
-      scrollToTrackAndTs(
-          trackId, toNs(sliceInfo.ts + globals.state.traceTime.startSec), true);
+      scrollToTrackAndTs(trackId, sliceInfo.ts, true);
     }
   }
 
diff --git a/ui/src/frontend/slice_panel.ts b/ui/src/frontend/slice_panel.ts
index 17b4aeb..9d6f542 100644
--- a/ui/src/frontend/slice_panel.ts
+++ b/ui/src/frontend/slice_panel.ts
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {timeToCode, toNs} from '../common/time';
+import {TPDuration, TPTime, tpTimeToCode} from '../common/time';
 
 import {globals, SliceDetails} from './globals';
 import {Panel} from './panel';
@@ -34,10 +34,9 @@
 }
 
 export abstract class SlicePanel extends Panel {
-  protected computeDuration(ts: number, dur: number): string {
-    return toNs(dur) === -1 ?
-        `${globals.state.traceTime.endSec - ts} (Did not end)` :
-        timeToCode(dur);
+  protected computeDuration(ts: TPTime, dur: TPDuration): string {
+    return dur === -1n ? `${globals.state.traceTime.end - ts} (Did not end)` :
+                         tpTimeToCode(dur);
   }
 
   protected getProcessThreadDetails(sliceInfo: SliceDetails) {
diff --git a/ui/src/frontend/sql_types.ts b/ui/src/frontend/sql_types.ts
index f7135fa..b7e2df2 100644
--- a/ui/src/frontend/sql_types.ts
+++ b/ui/src/frontend/sql_types.ts
@@ -12,8 +12,8 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {ColumnType} from 'src/common/query_result';
-import {fromNs, toNs} from '../common/time';
+import {TPTime} from '../common/time';
+
 import {globals} from './globals';
 
 // Type-safe aliases for various flavours of ints Trace Processor exposes
@@ -26,37 +26,19 @@
 
 // Timestamp (in nanoseconds) in the same time domain as Trace Processor is
 // exposing.
-export type TPTimestamp = bigint&{
+export type TPTimestamp = TPTime&{
   __type: 'TPTimestamp'
 }
 
-// Create a timestamp from a bigint in nanos.
-// Use this when we know the type is a bigint.
-export function timestampFromNanos(nanos: bigint) {
-  return nanos as TPTimestamp;
-}
-
-// Create a timestamp from an arbitrary SQL value.
-// Throws if the value cannot be reasonably converted to a timestamp.
-// Assumes the input will be in units of nanoseconds.
-export function timestampFromSqlNanos(nanos: ColumnType): TPTimestamp {
-  if (typeof nanos === 'bigint') {
-    return nanos as TPTimestamp;
-  } else if (typeof nanos === 'number') {
-    // Note - this will throw if the number is something which cannot be
-    // represented by an integer - i.e. decimals, infinity, or NaN.
-    return BigInt(nanos) as TPTimestamp;
-  } else {
-    throw Error('Refusing to create TPTimestamp from unrelated type');
-  }
+export function asTPTimestamp(v: bigint): TPTimestamp;
+export function asTPTimestamp(v?: bigint): TPTimestamp|undefined;
+export function asTPTimestamp(v?: bigint): TPTimestamp|undefined {
+  return v as (TPTimestamp | undefined);
 }
 
 // TODO: unify this with common/time.ts.
-// TODO(stevegolton): Return a bigint, or a new TPDuration object rather than
-// convert to number which could lose precision.
-export function toTraceTime(ts: TPTimestamp): number {
-  const traceStartNs = toNs(globals.state.traceTime.startSec);
-  return fromNs(Number(ts - BigInt(traceStartNs)));
+export function toTraceTime(ts: TPTimestamp): TPTime {
+  return ts - globals.state.traceTime.start;
 }
 
 // Unique id for a process, id into |process| table.
diff --git a/ui/src/frontend/thread_state.ts b/ui/src/frontend/thread_state.ts
index c7031f2..0bc4082 100644
--- a/ui/src/frontend/thread_state.ts
+++ b/ui/src/frontend/thread_state.ts
@@ -16,7 +16,11 @@
 import {EngineProxy} from '../common/engine';
 import {LONG, NUM, NUM_NULL, STR_NULL} from '../common/query_result';
 import {translateState} from '../common/thread_state';
-import {fromNs, timeToCode} from '../common/time';
+import {
+  TPDuration,
+  TPTime,
+  tpTimeToCode,
+} from '../common/time';
 
 import {copyToClipboard} from './clipboard';
 import {globals} from './globals';
@@ -26,9 +30,6 @@
   asUtid,
   SchedSqlId,
   ThreadStateSqlId,
-  timestampFromNanos,
-  toTraceTime,
-  TPTimestamp,
 } from './sql_types';
 import {
   constraintsToQueryFragment,
@@ -50,10 +51,10 @@
   threadStateSqlId: ThreadStateSqlId;
   // Id of the corresponding entry in the |sched| table.
   schedSqlId?: SchedSqlId;
-  // Timestamp of the beginning of this thread state in nanoseconds.
-  ts: TPTimestamp;
+  // Timestamp of the the beginning of this thread state in nanoseconds.
+  ts: TPTime;
   // Duration of this thread state in nanoseconds.
-  dur: number;
+  dur: TPDuration;
   // CPU id if this thread state corresponds to a thread running on the CPU.
   cpu?: number;
   // Human-readable name of this thread state.
@@ -90,7 +91,7 @@
     threadStateSqlId: NUM,
     schedSqlId: NUM_NULL,
     ts: LONG,
-    dur: NUM,
+    dur: LONG,
     cpu: NUM_NULL,
     state: STR_NULL,
     blockedFunction: STR_NULL,
@@ -110,7 +111,7 @@
     result.push({
       threadStateSqlId: it.threadStateSqlId as ThreadStateSqlId,
       schedSqlId: fromNumNull(it.schedSqlId) as (SchedSqlId | undefined),
-      ts: timestampFromNanos(it.ts),
+      ts: it.ts,
       dur: it.dur,
       cpu: fromNumNull(it.cpu),
       state: translateState(it.state || undefined, ioWait),
@@ -137,7 +138,7 @@
   return result[0];
 }
 
-export function goToSchedSlice(cpu: number, id: SchedSqlId, ts: TPTimestamp) {
+export function goToSchedSlice(cpu: number, id: SchedSqlId, ts: TPTime) {
   let trackId: string|undefined;
   for (const track of Object.values(globals.state.tracks)) {
     if (track.kind === 'CpuSliceTrack' &&
@@ -149,15 +150,12 @@
     return;
   }
   globals.makeSelection(Actions.selectSlice({id, trackId}));
-  // TODO(stevegolton): scrollToTrackAndTs() should take a TPTimestamp
-  scrollToTrackAndTs(trackId, Number(ts));
+  scrollToTrackAndTs(trackId, ts);
 }
 
 function stateToValue(
-    state: string,
-    cpu: number|undefined,
-    id: SchedSqlId|undefined,
-    ts: TPTimestamp): Value|null {
+    state: string, cpu: number|undefined, id: SchedSqlId|undefined, ts: TPTime):
+    Value|null {
   if (!state) {
     return null;
   }
@@ -177,8 +175,9 @@
 export function threadStateToDict(state: ThreadState): Dict {
   const result: {[name: string]: Value|null} = {};
 
-  result['Start time'] = value(timeToCode(toTraceTime(state.ts)));
-  result['Duration'] = value(timeToCode(fromNs(state.dur)));
+  result['Start time'] =
+      value(tpTimeToCode(state.ts - globals.state.traceTime.start));
+  result['Duration'] = value(tpTimeToCode(state.dur));
   result['State'] =
       stateToValue(state.state, state.cpu, state.schedSqlId, state.ts);
   result['Blocked function'] = maybeValue(state.blockedFunction);
diff --git a/ui/src/frontend/tickmark_panel.ts b/ui/src/frontend/tickmark_panel.ts
index 00612eb..f1579b9 100644
--- a/ui/src/frontend/tickmark_panel.ts
+++ b/ui/src/frontend/tickmark_panel.ts
@@ -19,6 +19,7 @@
 import {TRACK_SHELL_WIDTH} from './css_constants';
 import {globals} from './globals';
 import {
+  getMaxMajorTicks,
   TickGenerator,
   TickType,
   timeScaleForVisibleWindow,
@@ -32,14 +33,26 @@
   }
 
   renderCanvas(ctx: CanvasRenderingContext2D, size: PanelSize) {
-    const {timeScale, visibleWindowTime} = globals.frontendLocalState;
+    const {visibleWindowTime, visibleTimeScale} = globals.frontendLocalState;
 
     ctx.fillStyle = '#999';
     ctx.fillRect(TRACK_SHELL_WIDTH - 2, 0, 2, size.height);
-    const relScale = timeScaleForVisibleWindow(TRACK_SHELL_WIDTH, size.width);
-    if (relScale.timeSpan.duration > 0 && relScale.widthPx > 0) {
-      for (const {type, position} of new TickGenerator(relScale)) {
-        if (type === TickType.MAJOR) ctx.fillRect(position, 0, 1, size.height);
+
+    ctx.save();
+    ctx.beginPath();
+    ctx.rect(TRACK_SHELL_WIDTH, 0, size.width - TRACK_SHELL_WIDTH, size.height);
+    ctx.clip();
+
+    const span = globals.frontendLocalState.visibleWindow.timestampSpan;
+    if (size.width > TRACK_SHELL_WIDTH && span.duration > 0n) {
+      const maxMajorTicks = getMaxMajorTicks(size.width - TRACK_SHELL_WIDTH);
+      const map = timeScaleForVisibleWindow(TRACK_SHELL_WIDTH, size.width);
+      for (const {type, time} of new TickGenerator(
+               span, maxMajorTicks, globals.state.traceTime.start)) {
+        const px = Math.floor(map.tpTimeToPx(time));
+        if (type === TickType.MAJOR) {
+          ctx.fillRect(px, 0, 1, size.height);
+        }
       }
     }
 
@@ -47,12 +60,13 @@
     for (let i = 0; i < data.tsStarts.length; i++) {
       const tStart = data.tsStarts[i];
       const tEnd = data.tsEnds[i];
-      if (tEnd <= visibleWindowTime.start || tStart >= visibleWindowTime.end) {
+      if (tEnd <= visibleWindowTime.start.seconds ||
+          tStart >= visibleWindowTime.end.seconds) {
         continue;
       }
       const rectStart =
-          Math.max(timeScale.timeToPx(tStart), 0) + TRACK_SHELL_WIDTH;
-      const rectEnd = timeScale.timeToPx(tEnd) + TRACK_SHELL_WIDTH;
+          Math.max(visibleTimeScale.secondsToPx(tStart), 0) + TRACK_SHELL_WIDTH;
+      const rectEnd = visibleTimeScale.secondsToPx(tEnd) + TRACK_SHELL_WIDTH;
       ctx.fillStyle = '#ffe263';
       ctx.fillRect(
           Math.floor(rectStart),
@@ -61,16 +75,21 @@
           size.height);
     }
     const index = globals.state.searchIndex;
-    const startSec = fromNs(globals.currentSearchResults.tsStarts[index]);
-    const triangleStart =
-        Math.max(timeScale.timeToPx(startSec), 0) + TRACK_SHELL_WIDTH;
-    ctx.fillStyle = '#000';
-    ctx.beginPath();
-    ctx.moveTo(triangleStart, size.height);
-    ctx.lineTo(triangleStart - 3, 0);
-    ctx.lineTo(triangleStart + 3, 0);
-    ctx.lineTo(triangleStart, size.height);
-    ctx.fill();
-    ctx.closePath();
+    if (index !== -1) {
+      const startSec = fromNs(globals.currentSearchResults.tsStarts[index]);
+      const triangleStart =
+          Math.max(visibleTimeScale.secondsToPx(startSec), 0) +
+          TRACK_SHELL_WIDTH;
+      ctx.fillStyle = '#000';
+      ctx.beginPath();
+      ctx.moveTo(triangleStart, size.height);
+      ctx.lineTo(triangleStart - 3, 0);
+      ctx.lineTo(triangleStart + 3, 0);
+      ctx.lineTo(triangleStart, size.height);
+      ctx.fill();
+      ctx.closePath();
+    }
+
+    ctx.restore();
   }
 }
diff --git a/ui/src/frontend/time_axis_panel.ts b/ui/src/frontend/time_axis_panel.ts
index 3f6dc64..4819d54 100644
--- a/ui/src/frontend/time_axis_panel.ts
+++ b/ui/src/frontend/time_axis_panel.ts
@@ -14,11 +14,15 @@
 
 import m from 'mithril';
 
-import {timeToString} from '../common/time';
+import {
+  tpTimeToSeconds,
+  tpTimeToString,
+} from '../common/time';
 
 import {TRACK_SHELL_WIDTH} from './css_constants';
 import {globals} from './globals';
 import {
+  getMaxMajorTicks,
   TickGenerator,
   TickType,
   timeScaleForVisibleWindow,
@@ -35,21 +39,33 @@
     ctx.font = '10px Roboto Condensed';
     ctx.textAlign = 'left';
 
-    const startTime = timeToString(globals.state.traceTime.startSec);
+    const startTime = tpTimeToString(globals.state.traceTime.start);
     ctx.fillText(startTime + ' +', 6, 11);
 
+    ctx.save();
+    ctx.beginPath();
+    ctx.rect(TRACK_SHELL_WIDTH, 0, size.width - TRACK_SHELL_WIDTH, size.height);
+    ctx.clip();
+
     // Draw time axis.
-    const timeScale = timeScaleForVisibleWindow(TRACK_SHELL_WIDTH, size.width);
-    if (timeScale.timeSpan.duration > 0 && timeScale.widthPx > 0) {
-      const tickGen = new TickGenerator(timeScale);
-      for (const {type, time, position} of tickGen) {
+    const span = globals.frontendLocalState.visibleWindow.timestampSpan;
+    if (size.width > TRACK_SHELL_WIDTH && span.duration > 0n) {
+      const maxMajorTicks = getMaxMajorTicks(size.width - TRACK_SHELL_WIDTH);
+      const map = timeScaleForVisibleWindow(TRACK_SHELL_WIDTH, size.width);
+      const tickGen =
+          new TickGenerator(span, maxMajorTicks, globals.state.traceTime.start);
+      for (const {type, time} of tickGen) {
+        const position = Math.floor(map.tpTimeToPx(time));
+        const sec = tpTimeToSeconds(time - globals.state.traceTime.start);
         if (type === TickType.MAJOR) {
           ctx.fillRect(position, 0, 1, size.height);
-          ctx.fillText(time.toFixed(tickGen.digits) + ' s', position + 5, 10);
+          ctx.fillText(sec.toFixed(tickGen.digits) + ' s', position + 5, 10);
         }
       }
     }
 
+    ctx.restore();
+
     ctx.fillRect(TRACK_SHELL_WIDTH - 2, 0, 2, size.height);
   }
 }
diff --git a/ui/src/frontend/time_scale.ts b/ui/src/frontend/time_scale.ts
index 6f0307a..7804348 100644
--- a/ui/src/frontend/time_scale.ts
+++ b/ui/src/frontend/time_scale.ts
@@ -12,95 +12,92 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {assertFalse, assertTrue} from '../base/logging';
-import {TimeSpan} from '../common/time';
+import {assertTrue} from '../base/logging';
+import {
+  HighPrecisionTime,
+  HighPrecisionTimeSpan,
+} from '../common/high_precision_time';
+import {Span} from '../common/time';
+import {
+  TPDuration,
+  TPTime,
+} from '../common/time';
 
-const MAX_ZOOM_SPAN_SEC = 1e-8;  // 10 ns.
-
-/**
- * Defines a mapping between number and seconds for the entire application.
- * Linearly scales time values from boundsMs to pixel values in boundsPx and
- * back.
- */
 export class TimeScale {
-  private timeBounds: TimeSpan;
-  private _startPx: number;
-  private _endPx: number;
-  private secPerPx = 0;
+  private _start: HighPrecisionTime;
+  private _durationNanos: number;
+  readonly pxSpan: PxSpan;
+  private _nanosPerPx = 0;
+  private _startSec: number;
 
-  constructor(timeBounds: TimeSpan, boundsPx: [number, number]) {
-    this.timeBounds = timeBounds;
-    this._startPx = boundsPx[0];
-    this._endPx = boundsPx[1];
-    this.updateSlope();
+  constructor(start: HighPrecisionTime, durationNanos: number, pxSpan: PxSpan) {
+    // TODO(stevegolton): Ensure duration & pxSpan > 0.
+    // assertTrue(pxSpan.start < pxSpan.end, 'Px start >= end');
+    // assertTrue(durationNanos < 0, 'Duration <= 0');
+    this.pxSpan = pxSpan;
+    this._start = start;
+    this._durationNanos = durationNanos;
+    if (durationNanos <= 0 || pxSpan.delta <= 0) {
+      this._nanosPerPx = 1;
+    } else {
+      this._nanosPerPx = durationNanos / (pxSpan.delta);
+    }
+    this._startSec = this._start.seconds;
   }
 
-  private updateSlope() {
-    this.secPerPx = this.timeBounds.duration / (this._endPx - this._startPx);
+  get timeSpan(): Span<HighPrecisionTime> {
+    const end = this._start.addNanos(this._durationNanos);
+    return new HighPrecisionTimeSpan(this._start, end);
   }
 
-  deltaTimeToPx(time: number): number {
-    return Math.round(time / this.secPerPx);
+  tpTimeToPx(ts: TPTime): number {
+    // WARNING: Number(bigint) can be surprisingly slow. Avoid in hotpath.
+    const timeOffsetNanos = Number(ts - this._start.base) - this._start.offset;
+    return this.pxSpan.start + timeOffsetNanos / this._nanosPerPx;
   }
 
-  timeToPx(time: number): number {
-    return this._startPx + (time - this.timeBounds.start) / this.secPerPx;
+  secondsToPx(seconds: number): number {
+    const timeOffset = (seconds - this._startSec) * 1e9;
+    return this.pxSpan.start + timeOffset / this._nanosPerPx;
   }
 
-  pxToTime(px: number): number {
-    return this.timeBounds.start + (px - this._startPx) * this.secPerPx;
+  hpTimeToPx(time: HighPrecisionTime): number {
+    const timeOffsetNanos = time.subtract(this._start).nanos;
+    return this.pxSpan.start + timeOffsetNanos / this._nanosPerPx;
   }
 
-  deltaPxToDuration(px: number): number {
-    return px * this.secPerPx;
+  // Convert pixels to a high precision time object, which can be futher
+  // converted to other time formats.
+  pxToHpTime(px: number): HighPrecisionTime {
+    const offsetNanos = (px - this.pxSpan.start) * this._nanosPerPx;
+    return this._start.addNanos(offsetNanos);
   }
 
-  setTimeBounds(timeBounds: TimeSpan) {
-    this.timeBounds = timeBounds;
-    this.updateSlope();
+  durationToPx(dur: TPDuration): number {
+    // WARNING: Number(bigint) can be surprisingly slow. Avoid in hotpath.
+    return Number(dur) / this._nanosPerPx;
   }
 
-  setLimitsPx(pxStart: number, pxEnd: number) {
-    assertFalse(pxStart === pxEnd);
-    assertTrue(pxStart >= 0 && pxEnd >= 0);
-    this._startPx = pxStart;
-    this._endPx = pxEnd;
-    this.updateSlope();
-  }
-
-  timeInBounds(time: number): boolean {
-    return this.timeBounds.isInBounds(time);
-  }
-
-  get startPx(): number {
-    return this._startPx;
-  }
-
-  get endPx(): number {
-    return this._endPx;
-  }
-
-  get widthPx(): number {
-    return this._endPx - this._startPx;
-  }
-
-  get timeSpan(): TimeSpan {
-    return this.timeBounds;
+  pxDeltaToDuration(pxDelta: number): HighPrecisionTime {
+    const time = pxDelta * this._nanosPerPx;
+    return HighPrecisionTime.fromNanos(time);
   }
 }
 
-export function computeZoom(
-    scale: TimeScale, span: TimeSpan, zoomFactor: number, zoomPx: number):
-    TimeSpan {
-  const startPx = scale.startPx;
-  const endPx = scale.endPx;
-  const deltaPx = endPx - startPx;
-  const deltaTime = span.end - span.start;
-  const newDeltaTime = Math.max(deltaTime * zoomFactor, MAX_ZOOM_SPAN_SEC);
-  const clampedZoomPx = Math.max(startPx, Math.min(endPx, zoomPx));
-  const zoomTime = scale.pxToTime(clampedZoomPx);
-  const r = (clampedZoomPx - startPx) / deltaPx;
-  const newStartTime = zoomTime - newDeltaTime * r;
-  const newEndTime = newStartTime + newDeltaTime;
-  return new TimeSpan(newStartTime, newEndTime);
+export class PxSpan {
+  constructor(private _start: number, private _end: number) {
+    assertTrue(_start <= _end, 'PxSpan start > end');
+  }
+
+  get start(): number {
+    return this._start;
+  }
+
+  get end(): number {
+    return this._end;
+  }
+
+  get delta(): number {
+    return this._end - this._start;
+  }
 }
diff --git a/ui/src/frontend/time_scale_unittest.ts b/ui/src/frontend/time_scale_unittest.ts
index 7a1be03..f7046de 100644
--- a/ui/src/frontend/time_scale_unittest.ts
+++ b/ui/src/frontend/time_scale_unittest.ts
@@ -12,66 +12,62 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {TimeSpan} from '../common/time';
+import {HighPrecisionTime} from '../common/high_precision_time';
 
-import {computeZoom, TimeScale} from './time_scale';
+import {PxSpan, TimeScale} from './time_scale';
 
-test('time scale to work', () => {
-  const scale = new TimeScale(new TimeSpan(0, 100), [200, 1000]);
+describe('TimeScale', () => {
+  const ts =
+      new TimeScale(new HighPrecisionTime(40n), 100, new PxSpan(200, 1000));
 
-  expect(scale.timeToPx(0)).toEqual(200);
-  expect(scale.timeToPx(100)).toEqual(1000);
-  expect(scale.timeToPx(50)).toEqual(600);
+  it('converts timescales to pixels', () => {
+    expect(ts.tpTimeToPx(40n)).toEqual(200);
+    expect(ts.tpTimeToPx(140n)).toEqual(1000);
+    expect(ts.tpTimeToPx(90n)).toEqual(600);
 
-  expect(scale.pxToTime(200)).toEqual(0);
-  expect(scale.pxToTime(1000)).toEqual(100);
-  expect(scale.pxToTime(600)).toEqual(50);
+    expect(ts.tpTimeToPx(240n)).toEqual(1800);
+    expect(ts.tpTimeToPx(-60n)).toEqual(-600);
+  });
 
-  expect(scale.deltaPxToDuration(400)).toEqual(50);
+  it('converts pixels to HPTime objects', () => {
+    let result = ts.pxToHpTime(200);
+    expect(result.base).toEqual(40n);
+    expect(result.offset).toBeCloseTo(0);
 
-  expect(scale.timeInBounds(50)).toEqual(true);
-  expect(scale.timeInBounds(0)).toEqual(true);
-  expect(scale.timeInBounds(100)).toEqual(true);
-  expect(scale.timeInBounds(-1)).toEqual(false);
-  expect(scale.timeInBounds(101)).toEqual(false);
-});
+    result = ts.pxToHpTime(1000);
+    expect(result.base).toEqual(140n);
+    expect(result.offset).toBeCloseTo(0);
 
+    result = ts.pxToHpTime(600);
+    expect(result.base).toEqual(90n);
+    expect(result.offset).toBeCloseTo(0);
 
-test('time scale to be updatable', () => {
-  const scale = new TimeScale(new TimeSpan(0, 100), [100, 1000]);
+    result = ts.pxToHpTime(1800);
+    expect(result.base).toEqual(240n);
+    expect(result.offset).toBeCloseTo(0);
 
-  expect(scale.timeToPx(0)).toEqual(100);
+    result = ts.pxToHpTime(-600);
+    expect(result.base).toEqual(-60n);
+    expect(result.offset).toBeCloseTo(0);
+  });
 
-  scale.setLimitsPx(200, 1000);
-  expect(scale.timeToPx(0)).toEqual(200);
-  expect(scale.timeToPx(100)).toEqual(1000);
+  it('converts durations to pixels', () => {
+    expect(ts.durationToPx(0n)).toEqual(0);
+    expect(ts.durationToPx(1n)).toEqual(8);
+    expect(ts.durationToPx(1000n)).toEqual(8000);
+  });
 
-  scale.setTimeBounds(new TimeSpan(0, 200));
-  expect(scale.timeToPx(0)).toEqual(200);
-  expect(scale.timeToPx(100)).toEqual(600);
-  expect(scale.timeToPx(200)).toEqual(1000);
-});
+  it('converts pxDeltaToDurations to HPTime durations', () => {
+    let result = ts.pxDeltaToDuration(0);
+    expect(result.base).toEqual(0n);
+    expect(result.offset).toBeCloseTo(0);
 
-test('it zooms', () => {
-  const span = new TimeSpan(0, 20);
-  const scale = new TimeScale(span, [0, 100]);
-  const newSpan = computeZoom(scale, span, 0.5, 50);
-  expect(newSpan.start).toEqual(5);
-  expect(newSpan.end).toEqual(15);
-});
+    result = ts.pxDeltaToDuration(1);
+    expect(result.base).toEqual(0n);
+    expect(result.offset).toBeCloseTo(0.125);
 
-test('it zooms an offset scale and span', () => {
-  const span = new TimeSpan(1000, 1020);
-  const scale = new TimeScale(span, [200, 300]);
-  const newSpan = computeZoom(scale, span, 0.5, 250);
-  expect(newSpan.start).toEqual(1005);
-  expect(newSpan.end).toEqual(1015);
-});
-
-test('it clamps zoom in', () => {
-  const span = new TimeSpan(1000, 1040);
-  const scale = new TimeScale(span, [200, 300]);
-  const newSpan = computeZoom(scale, span, 0.0000000001, 225);
-  expect((newSpan.end - newSpan.start) / 2 + newSpan.start).toBeCloseTo(1010);
-  expect(newSpan.end - newSpan.start).toBeCloseTo(1e-8);
+    result = ts.pxDeltaToDuration(100);
+    expect(result.base).toEqual(12n);
+    expect(result.offset).toBeCloseTo(0.5);
+  });
 });
diff --git a/ui/src/frontend/time_selection_panel.ts b/ui/src/frontend/time_selection_panel.ts
index 20f1c53..5711e6a 100644
--- a/ui/src/frontend/time_selection_panel.ts
+++ b/ui/src/frontend/time_selection_panel.ts
@@ -13,9 +13,13 @@
 // limitations under the License.
 
 import m from 'mithril';
+import {BigintMath} from '../base/bigint_math';
 
-import {timeToString} from '../common/time';
-import {TimeSpan} from '../common/time';
+import {Span, tpTimeToString} from '../common/time';
+import {
+  TPTime,
+  TPTimeSpan,
+} from '../common/time';
 
 import {
   BACKGROUND_COLOR,
@@ -24,6 +28,7 @@
 } from './css_constants';
 import {globals} from './globals';
 import {
+  getMaxMajorTicks,
   TickGenerator,
   TickType,
   timeScaleForVisibleWindow,
@@ -48,7 +53,7 @@
   ctx.fillStyle = FOREGROUND_COLOR;
 
   const xLeft = Math.floor(target.x);
-  const xRight = Math.ceil(target.x + target.width);
+  const xRight = Math.floor(target.x + target.width);
   const yMid = Math.floor(target.height / 2 + target.y);
   const xWidth = xRight - xLeft;
 
@@ -130,11 +135,21 @@
   renderCanvas(ctx: CanvasRenderingContext2D, size: PanelSize) {
     ctx.fillStyle = '#999';
     ctx.fillRect(TRACK_SHELL_WIDTH - 2, 0, 2, size.height);
-    const scale = timeScaleForVisibleWindow(TRACK_SHELL_WIDTH, size.width);
-    if (scale.timeSpan.duration > 0 && scale.widthPx > 0) {
-      for (const {position, type} of new TickGenerator(scale)) {
+
+    ctx.save();
+    ctx.beginPath();
+    ctx.rect(TRACK_SHELL_WIDTH, 0, size.width - TRACK_SHELL_WIDTH, size.height);
+    ctx.clip();
+
+    const span = globals.frontendLocalState.visibleWindow.timestampSpan;
+    if (size.width > TRACK_SHELL_WIDTH && span.duration > 0n) {
+      const maxMajorTicks = getMaxMajorTicks(size.width - TRACK_SHELL_WIDTH);
+      const map = timeScaleForVisibleWindow(TRACK_SHELL_WIDTH, size.width);
+      for (const {type, time} of new TickGenerator(
+               span, maxMajorTicks, globals.state.traceTime.start)) {
+        const px = Math.floor(map.tpTimeToPx(time));
         if (type === TickType.MAJOR) {
-          ctx.fillRect(position, 0, 1, size.height);
+          ctx.fillRect(px, 0, 1, size.height);
         }
       }
     }
@@ -142,17 +157,17 @@
     const localArea = globals.frontendLocalState.selectedArea;
     const selection = globals.state.currentSelection;
     if (localArea !== undefined) {
-      const start = Math.min(localArea.startSec, localArea.endSec);
-      const end = Math.max(localArea.startSec, localArea.endSec);
-      this.renderSpan(ctx, size, new TimeSpan(start, end));
+      const start = BigintMath.min(localArea.start, localArea.end);
+      const end = BigintMath.max(localArea.start, localArea.end);
+      this.renderSpan(ctx, size, new TPTimeSpan(start, end));
     } else if (selection !== null && selection.kind === 'AREA') {
       const selectedArea = globals.state.areas[selection.areaId];
-      const start = Math.min(selectedArea.startSec, selectedArea.endSec);
-      const end = Math.max(selectedArea.startSec, selectedArea.endSec);
-      this.renderSpan(ctx, size, new TimeSpan(start, end));
+      const start = BigintMath.min(selectedArea.start, selectedArea.end);
+      const end = BigintMath.max(selectedArea.start, selectedArea.end);
+      this.renderSpan(ctx, size, new TPTimeSpan(start, end));
     }
 
-    if (globals.state.hoverCursorTimestamp !== -1) {
+    if (globals.state.hoverCursorTimestamp !== -1n) {
       this.renderHover(ctx, size, globals.state.hoverCursorTimestamp);
     }
 
@@ -162,27 +177,29 @@
       if (note.noteType === 'AREA' && !noteIsSelected) {
         const selectedArea = globals.state.areas[note.areaId];
         this.renderSpan(
-            ctx,
-            size,
-            new TimeSpan(selectedArea.startSec, selectedArea.endSec));
+            ctx, size, new TPTimeSpan(selectedArea.start, selectedArea.end));
       }
     }
+
+    ctx.restore();
   }
 
-  renderHover(ctx: CanvasRenderingContext2D, size: PanelSize, ts: number) {
-    const timeScale = globals.frontendLocalState.timeScale;
-    const xPos = TRACK_SHELL_WIDTH + Math.floor(timeScale.timeToPx(ts));
-    const offsetTime = timeToString(ts - globals.state.traceTime.startSec);
-    const timeFromStart = timeToString(ts);
+  renderHover(ctx: CanvasRenderingContext2D, size: PanelSize, ts: TPTime) {
+    const {visibleTimeScale} = globals.frontendLocalState;
+    const xPos =
+        TRACK_SHELL_WIDTH + Math.floor(visibleTimeScale.tpTimeToPx(ts));
+    const offsetTime = tpTimeToString(ts - globals.state.traceTime.start);
+    const timeFromStart = tpTimeToString(ts);
     const label = `${offsetTime} (${timeFromStart})`;
     drawIBar(ctx, xPos, this.bounds(size), label);
   }
 
-  renderSpan(ctx: CanvasRenderingContext2D, size: PanelSize, span: TimeSpan) {
-    const timeScale = globals.frontendLocalState.timeScale;
-    const xLeft = timeScale.timeToPx(span.start);
-    const xRight = timeScale.timeToPx(span.end);
-    const label = timeToString(span.duration);
+  renderSpan(
+      ctx: CanvasRenderingContext2D, size: PanelSize, span: Span<TPTime>) {
+    const {visibleTimeScale} = globals.frontendLocalState;
+    const xLeft = visibleTimeScale.tpTimeToPx(span.start);
+    const xRight = visibleTimeScale.tpTimeToPx(span.end);
+    const label = tpTimeToString(span.duration);
     drawHBar(
         ctx,
         {
diff --git a/ui/src/frontend/trace_converter.ts b/ui/src/frontend/trace_converter.ts
index ca4ced3..0f5ca69 100644
--- a/ui/src/frontend/trace_converter.ts
+++ b/ui/src/frontend/trace_converter.ts
@@ -17,6 +17,7 @@
   ConversionJobName,
   ConversionJobStatus,
 } from '../common/conversion_jobs';
+import {TPTime} from '../common/time';
 
 import {download} from './clipboard';
 import {maybeShowErrorDialog} from './error_dialog';
@@ -106,7 +107,7 @@
 }
 
 export function convertTraceToPprofAndDownload(
-    trace: Blob, pid: number, ts: number) {
+    trace: Blob, pid: number, ts: TPTime) {
   makeWorkerAndPost({
     kind: 'ConvertTraceToPprof',
     trace,
diff --git a/ui/src/frontend/track.ts b/ui/src/frontend/track.ts
index 2d5ab81..e826317 100644
--- a/ui/src/frontend/track.ts
+++ b/ui/src/frontend/track.ts
@@ -130,9 +130,11 @@
   render(ctx: CanvasRenderingContext2D) {
     globals.frontendLocalState.addVisibleTrack(this.trackState.id);
     if (this.data() === undefined && !this.frontendOnly) {
-      const {visibleWindowTime, timeScale} = globals.frontendLocalState;
-      const startPx = Math.floor(timeScale.timeToPx(visibleWindowTime.start));
-      const endPx = Math.ceil(timeScale.timeToPx(visibleWindowTime.end));
+      const {visibleWindowTime, visibleTimeScale} = globals.frontendLocalState;
+      const startPx =
+          Math.floor(visibleTimeScale.hpTimeToPx(visibleWindowTime.start));
+      const endPx =
+          Math.ceil(visibleTimeScale.hpTimeToPx(visibleWindowTime.end));
       checkerboard(ctx, this.getHeight(), startPx, endPx);
     } else {
       this.renderCanvas(ctx);
@@ -175,7 +177,7 @@
     y -= 10;
 
     // Ensure the box is on screen:
-    const endPx = globals.frontendLocalState.timeScale.endPx;
+    const endPx = globals.frontendLocalState.visibleTimeScale.pxSpan.end;
     if (x + width > endPx) {
       x -= x + width - endPx;
     }
diff --git a/ui/src/frontend/track_cache.ts b/ui/src/frontend/track_cache.ts
index 049cbf4..2adbd0c 100644
--- a/ui/src/frontend/track_cache.ts
+++ b/ui/src/frontend/track_cache.ts
@@ -52,6 +52,7 @@
 // In other words the normal window is a superset of the data of the
 // non-normal window at a higher resolution. Normalization is used to
 // avoid re-fetching data on tiny zooms/moves/resizes.
+// TODO(stevegolton): Convert to bigint timestamps.
 export class CacheKey {
   readonly startNs: number;
   readonly endNs: number;
diff --git a/ui/src/frontend/track_group_panel.ts b/ui/src/frontend/track_group_panel.ts
index c9d109f..dbab7f7 100644
--- a/ui/src/frontend/track_group_panel.ts
+++ b/ui/src/frontend/track_group_panel.ts
@@ -187,18 +187,17 @@
   }
 
   highlightIfTrackSelected(ctx: CanvasRenderingContext2D, size: PanelSize) {
-    const localState = globals.frontendLocalState;
+    const {visibleTimeScale} = globals.frontendLocalState;
     const selection = globals.state.currentSelection;
     if (!selection || selection.kind !== 'AREA') return;
     const selectedArea = globals.state.areas[selection.areaId];
+    const selectedAreaDuration = selectedArea.end - selectedArea.start;
     if (selectedArea.tracks.includes(this.trackGroupId)) {
       ctx.fillStyle = 'rgba(131, 152, 230, 0.3)';
       ctx.fillRect(
-          localState.timeScale.timeToPx(selectedArea.startSec) +
-              this.shellWidth,
+          visibleTimeScale.tpTimeToPx(selectedArea.start) + this.shellWidth,
           0,
-          localState.timeScale.deltaTimeToPx(
-              selectedArea.endSec - selectedArea.startSec),
+          visibleTimeScale.durationToPx(selectedAreaDuration),
           size.height);
     }
   }
@@ -227,20 +226,20 @@
 
     this.highlightIfTrackSelected(ctx, size);
 
-    const localState = globals.frontendLocalState;
+    const {visibleTimeScale} = globals.frontendLocalState;
     // Draw vertical line when hovering on the notes panel.
-    if (globals.state.hoveredNoteTimestamp !== -1) {
+    if (globals.state.hoveredNoteTimestamp !== -1n) {
       drawVerticalLineAtTime(
           ctx,
-          localState.timeScale,
+          visibleTimeScale,
           globals.state.hoveredNoteTimestamp,
           size.height,
           `#aaa`);
     }
-    if (globals.state.hoverCursorTimestamp !== -1) {
+    if (globals.state.hoverCursorTimestamp !== -1n) {
       drawVerticalLineAtTime(
           ctx,
-          localState.timeScale,
+          visibleTimeScale,
           globals.state.hoverCursorTimestamp,
           size.height,
           `#344596`);
@@ -251,7 +250,7 @@
           globals.sliceDetails.wakeupTs !== undefined) {
         drawVerticalLineAtTime(
             ctx,
-            localState.timeScale,
+            visibleTimeScale,
             globals.sliceDetails.wakeupTs,
             size.height,
             `black`);
@@ -265,21 +264,21 @@
             'rgba(' + hex.rgb(note.color.substr(1)).toString() + ', 0.65)';
         drawVerticalLineAtTime(
             ctx,
-            localState.timeScale,
-            globals.state.areas[note.areaId].startSec,
+            visibleTimeScale,
+            globals.state.areas[note.areaId].start,
             size.height,
             transparentNoteColor,
             1);
         drawVerticalLineAtTime(
             ctx,
-            localState.timeScale,
-            globals.state.areas[note.areaId].endSec,
+            visibleTimeScale,
+            globals.state.areas[note.areaId].end,
             size.height,
             transparentNoteColor,
             1);
       } else if (note.noteType === 'DEFAULT') {
         drawVerticalLineAtTime(
-            ctx, localState.timeScale, note.timestamp, size.height, note.color);
+            ctx, visibleTimeScale, note.timestamp, size.height, note.color);
       }
     }
   }
diff --git a/ui/src/frontend/track_panel.ts b/ui/src/frontend/track_panel.ts
index 9110c1d..278b73d 100644
--- a/ui/src/frontend/track_panel.ts
+++ b/ui/src/frontend/track_panel.ts
@@ -362,20 +362,20 @@
   }
 
   highlightIfTrackSelected(ctx: CanvasRenderingContext2D, size: PanelSize) {
-    const localState = globals.frontendLocalState;
+    const {visibleTimeScale} = globals.frontendLocalState;
     const selection = globals.state.currentSelection;
     const trackState = this.trackState;
     if (!selection || selection.kind !== 'AREA' || trackState === undefined) {
       return;
     }
     const selectedArea = globals.state.areas[selection.areaId];
+    const selectedAreaDuration = selectedArea.end - selectedArea.start;
     if (selectedArea.tracks.includes(trackState.id)) {
-      const timeScale = localState.timeScale;
       ctx.fillStyle = SELECTION_FILL_COLOR;
       ctx.fillRect(
-          timeScale.timeToPx(selectedArea.startSec) + TRACK_SHELL_WIDTH,
+          visibleTimeScale.tpTimeToPx(selectedArea.start) + TRACK_SHELL_WIDTH,
           0,
-          timeScale.deltaTimeToPx(selectedArea.endSec - selectedArea.startSec),
+          visibleTimeScale.durationToPx(selectedAreaDuration),
           size.height);
     }
   }
@@ -396,20 +396,20 @@
 
     this.highlightIfTrackSelected(ctx, size);
 
-    const localState = globals.frontendLocalState;
+    const {visibleTimeScale} = globals.frontendLocalState;
     // Draw vertical line when hovering on the notes panel.
-    if (globals.state.hoveredNoteTimestamp !== -1) {
+    if (globals.state.hoveredNoteTimestamp !== -1n) {
       drawVerticalLineAtTime(
           ctx,
-          localState.timeScale,
+          visibleTimeScale,
           globals.state.hoveredNoteTimestamp,
           size.height,
           `#aaa`);
     }
-    if (globals.state.hoverCursorTimestamp !== -1) {
+    if (globals.state.hoverCursorTimestamp !== -1n) {
       drawVerticalLineAtTime(
           ctx,
-          localState.timeScale,
+          visibleTimeScale,
           globals.state.hoverCursorTimestamp,
           size.height,
           `#344596`);
@@ -420,7 +420,7 @@
           globals.sliceDetails.wakeupTs !== undefined) {
         drawVerticalLineAtTime(
             ctx,
-            localState.timeScale,
+            visibleTimeScale,
             globals.sliceDetails.wakeupTs,
             size.height,
             `black`);
@@ -434,21 +434,21 @@
             'rgba(' + hex.rgb(note.color.substr(1)).toString() + ', 0.65)';
         drawVerticalLineAtTime(
             ctx,
-            localState.timeScale,
-            globals.state.areas[note.areaId].startSec,
+            visibleTimeScale,
+            globals.state.areas[note.areaId].start,
             size.height,
             transparentNoteColor,
             1);
         drawVerticalLineAtTime(
             ctx,
-            localState.timeScale,
-            globals.state.areas[note.areaId].endSec,
+            visibleTimeScale,
+            globals.state.areas[note.areaId].end,
             size.height,
             transparentNoteColor,
             1);
       } else if (note.noteType === 'DEFAULT') {
         drawVerticalLineAtTime(
-            ctx, localState.timeScale, note.timestamp, size.height, note.color);
+            ctx, visibleTimeScale, note.timestamp, size.height, note.color);
       }
     }
   }
diff --git a/ui/src/frontend/vertical_line_helper.ts b/ui/src/frontend/vertical_line_helper.ts
index b1e2cfc..353d166 100644
--- a/ui/src/frontend/vertical_line_helper.ts
+++ b/ui/src/frontend/vertical_line_helper.ts
@@ -12,18 +12,20 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {TPTime} from '../common/time';
 import {TRACK_SHELL_WIDTH} from './css_constants';
 import {TimeScale} from './time_scale';
 
-export function drawVerticalLineAtTime(ctx: CanvasRenderingContext2D,
-                                       timeScale: TimeScale,
-                                       time: number,
-                                       height: number,
-                                       color: string,
-                                       lineWidth = 2) {
-    const xPos = TRACK_SHELL_WIDTH + Math.floor(timeScale.timeToPx(time));
-    drawVerticalLine(ctx, xPos, height, color, lineWidth);
-  }
+export function drawVerticalLineAtTime(
+    ctx: CanvasRenderingContext2D,
+    timeScale: TimeScale,
+    time: TPTime,
+    height: number,
+    color: string,
+    lineWidth = 2) {
+  const xPos = TRACK_SHELL_WIDTH + Math.floor(timeScale.tpTimeToPx(time));
+  drawVerticalLine(ctx, xPos, height, color, lineWidth);
+}
 
 function drawVerticalLine(ctx: CanvasRenderingContext2D,
                           xPos: number,
diff --git a/ui/src/frontend/viewer_page.ts b/ui/src/frontend/viewer_page.ts
index d3651e7..1a9e096 100644
--- a/ui/src/frontend/viewer_page.ts
+++ b/ui/src/frontend/viewer_page.ts
@@ -13,10 +13,10 @@
 // limitations under the License.
 
 import m from 'mithril';
+import {BigintMath} from '../base/bigint_math';
 
 import {Actions} from '../common/actions';
 import {featureFlags} from '../common/feature_flags';
-import {TimeSpan} from '../common/time';
 
 import {TRACK_SHELL_WIDTH} from './css_constants';
 import {DetailsPanel} from './details_panel';
@@ -28,7 +28,6 @@
 import {AnyAttrsVnode, PanelContainer} from './panel_container';
 import {TickmarkPanel} from './tickmark_panel';
 import {TimeAxisPanel} from './time_axis_panel';
-import {computeZoom} from './time_scale';
 import {TimeSelectionPanel} from './time_selection_panel';
 import {DISMISSED_PANNING_HINT_KEY} from './topbar';
 import {TrackGroupPanel} from './track_group_panel';
@@ -53,8 +52,9 @@
     const area = globals.frontendLocalState.selectedArea ?
         globals.frontendLocalState.selectedArea :
         globals.state.areas[selection.areaId];
-    const start = globals.frontendLocalState.timeScale.timeToPx(area.startSec);
-    const end = globals.frontendLocalState.timeScale.timeToPx(area.endSec);
+    const {visibleTimeScale} = globals.frontendLocalState;
+    const start = visibleTimeScale.tpTimeToPx(area.start);
+    const end = visibleTimeScale.tpTimeToPx(area.end);
     const startDrag = mousePos - TRACK_SHELL_WIDTH;
     const startDistance = Math.abs(start - startDrag);
     const endDistance = Math.abs(end - startDrag);
@@ -119,21 +119,14 @@
       element: panZoomEl,
       contentOffsetX: SIDEBAR_WIDTH,
       onPanned: (pannedPx: number) => {
+        const {
+          visibleTimeScale,
+        } = globals.frontendLocalState;
+
         this.keepCurrentSelection = true;
-        const traceTime = globals.state.traceTime;
-        const vizTime = globals.frontendLocalState.visibleWindowTime;
-        const origDelta = vizTime.duration;
-        const tDelta = frontendLocalState.timeScale.deltaPxToDuration(pannedPx);
-        let tStart = vizTime.start + tDelta;
-        let tEnd = vizTime.end + tDelta;
-        if (tStart < traceTime.startSec) {
-          tStart = traceTime.startSec;
-          tEnd = tStart + origDelta;
-        } else if (tEnd > traceTime.endSec) {
-          tEnd = traceTime.endSec;
-          tStart = tEnd - origDelta;
-        }
-        frontendLocalState.updateVisibleTime(new TimeSpan(tStart, tEnd));
+        const tDelta = visibleTimeScale.pxDeltaToDuration(pannedPx);
+        frontendLocalState.panVisibleWindow(tDelta);
+
         // If the user has panned they no longer need the hint.
         localStorage.setItem(DISMISSED_PANNING_HINT_KEY, 'true');
         globals.rafScheduler.scheduleRedraw();
@@ -141,11 +134,10 @@
       onZoomed: (zoomedPositionPx: number, zoomRatio: number) => {
         // TODO(hjd): Avoid hardcoding TRACK_SHELL_WIDTH.
         // TODO(hjd): Improve support for zooming in overview timeline.
-        const span = frontendLocalState.visibleWindowTime;
-        const scale = frontendLocalState.timeScale;
         const zoomPx = zoomedPositionPx - TRACK_SHELL_WIDTH;
-        const newSpan = computeZoom(scale, span, 1 - zoomRatio, zoomPx);
-        frontendLocalState.updateVisibleTime(newSpan);
+        const rect = vnode.dom.getBoundingClientRect();
+        const centerPoint = zoomPx / (rect.width - TRACK_SHELL_WIDTH);
+        frontendLocalState.zoomVisibleWindow(1 - zoomRatio, centerPoint);
         globals.rafScheduler.scheduleRedraw();
       },
       editSelection: (currentPx: number) => {
@@ -159,7 +151,7 @@
           currentY: number,
           editing: boolean) => {
         const traceTime = globals.state.traceTime;
-        const scale = frontendLocalState.timeScale;
+        const {visibleTimeScale} = frontendLocalState;
         this.keepCurrentSelection = true;
         if (editing) {
           const selection = globals.state.currentSelection;
@@ -167,33 +159,40 @@
             const area = globals.frontendLocalState.selectedArea ?
                 globals.frontendLocalState.selectedArea :
                 globals.state.areas[selection.areaId];
-            let newTime = scale.pxToTime(currentX - TRACK_SHELL_WIDTH);
+            let newTime =
+                visibleTimeScale.pxToHpTime(currentX - TRACK_SHELL_WIDTH)
+                    .toTPTime();
             // Have to check again for when one boundary crosses over the other.
             const curBoundary = onTimeRangeBoundary(prevX);
             if (curBoundary == null) return;
-            const keepTime =
-                curBoundary === 'START' ? area.endSec : area.startSec;
+            const keepTime = curBoundary === 'START' ? area.end : area.start;
             // Don't drag selection outside of current screen.
             if (newTime < keepTime) {
-              newTime = Math.max(newTime, scale.pxToTime(scale.startPx));
+              newTime = BigintMath.max(
+                  newTime, visibleTimeScale.timeSpan.start.toTPTime());
             } else {
-              newTime = Math.min(newTime, scale.pxToTime(scale.endPx));
+              newTime = BigintMath.max(
+                  newTime, visibleTimeScale.timeSpan.end.toTPTime());
             }
             // When editing the time range we always use the saved tracks,
             // since these will not change.
             frontendLocalState.selectArea(
-                Math.max(Math.min(keepTime, newTime), traceTime.startSec),
-                Math.min(Math.max(keepTime, newTime), traceTime.endSec),
+                BigintMath.max(
+                    BigintMath.min(keepTime, newTime), traceTime.start),
+                BigintMath.min(
+                    BigintMath.max(keepTime, newTime), traceTime.end),
                 globals.state.areas[selection.areaId].tracks);
           }
         } else {
           let startPx = Math.min(dragStartX, currentX) - TRACK_SHELL_WIDTH;
           let endPx = Math.max(dragStartX, currentX) - TRACK_SHELL_WIDTH;
           if (startPx < 0 && endPx < 0) return;
-          startPx = Math.max(startPx, scale.startPx);
-          endPx = Math.min(endPx, scale.endPx);
+          startPx = Math.max(startPx, visibleTimeScale.pxSpan.start);
+          endPx = Math.min(endPx, visibleTimeScale.pxSpan.end);
           frontendLocalState.selectArea(
-              scale.pxToTime(startPx), scale.pxToTime(endPx));
+              visibleTimeScale.pxToHpTime(startPx).toTPTime('floor'),
+              visibleTimeScale.pxToHpTime(endPx).toTPTime('ceil'),
+          );
           frontendLocalState.areaY.start = dragStartY;
           frontendLocalState.areaY.end = currentY;
         }
@@ -271,6 +270,9 @@
           m('.pan-and-zoom-content',
             {
               onclick: () => {
+                // TODO(stevegolton): Make it possible to click buttons and
+                // things on this element without deselecting the selected
+                // element!
                 // We don't want to deselect when panning/drag selecting.
                 if (this.keepCurrentSelection) {
                   this.keepCurrentSelection = false;
diff --git a/ui/src/frontend/widgets/duration.ts b/ui/src/frontend/widgets/duration.ts
index 6f36c95..cd18e02 100644
--- a/ui/src/frontend/widgets/duration.ts
+++ b/ui/src/frontend/widgets/duration.ts
@@ -14,14 +14,14 @@
 
 import m from 'mithril';
 
-import {fromNs, timeToCode} from '../../common/time';
+import {TPDuration, tpTimeToCode} from '../../common/time';
 
 interface DurationAttrs {
-  dur: number;
+  dur: TPDuration;
 }
 
 export class Duration implements m.ClassComponent<DurationAttrs> {
   view(vnode: m.Vnode<DurationAttrs>) {
-    return timeToCode(fromNs(vnode.attrs.dur));
+    return tpTimeToCode(vnode.attrs.dur);
   }
 }
diff --git a/ui/src/frontend/widgets/timestamp.ts b/ui/src/frontend/widgets/timestamp.ts
index 4552275..d8cc841 100644
--- a/ui/src/frontend/widgets/timestamp.ts
+++ b/ui/src/frontend/widgets/timestamp.ts
@@ -14,7 +14,7 @@
 
 import m from 'mithril';
 
-import {timeToCode} from '../../common/time';
+import {tpTimeToCode} from '../../common/time';
 import {toTraceTime, TPTimestamp} from '../sql_types';
 
 interface TimestampAttrs {
@@ -23,6 +23,6 @@
 
 export class Timestamp implements m.ClassComponent<TimestampAttrs> {
   view(vnode: m.Vnode<TimestampAttrs>) {
-    return timeToCode(toTraceTime(vnode.attrs.ts));
+    return tpTimeToCode(toTraceTime(vnode.attrs.ts));
   }
 }
diff --git a/ui/src/traceconv/index.ts b/ui/src/traceconv/index.ts
index 714666a..df3e8a0 100644
--- a/ui/src/traceconv/index.ts
+++ b/ui/src/traceconv/index.ts
@@ -18,6 +18,7 @@
   ConversionJobName,
   ConversionJobStatus,
 } from '../common/conversion_jobs';
+import {TPTime} from '../common/time';
 import traceconv from '../gen/traceconv';
 
 const selfWorker = self as {} as Worker;
@@ -176,7 +177,7 @@
   kind: 'ConvertTraceToPprof';
   trace: Blob;
   pid: number;
-  ts: number;
+  ts: TPTime;
 }
 
 function isConvertTraceToPprof(msg: Args): msg is ConvertTraceToPprofArgs {
@@ -186,8 +187,7 @@
   return true;
 }
 
-async function ConvertTraceToPprof(
-trace: Blob, pid: number, ts: number) {
+async function ConvertTraceToPprof(trace: Blob, pid: number, ts: TPTime) {
   const jobName = 'convert_pprof';
   updateJobStatus(jobName, ConversionJobStatus.InProgress);
   const args = [
diff --git a/ui/src/tracks/actual_frames/index.ts b/ui/src/tracks/actual_frames/index.ts
index 927dea4..7f5afb0 100644
--- a/ui/src/tracks/actual_frames/index.ts
+++ b/ui/src/tracks/actual_frames/index.ts
@@ -14,7 +14,7 @@
 
 import {PluginContext} from '../../common/plugin_api';
 import {NUM, NUM_NULL, STR} from '../../common/query_result';
-import {fromNs, toNs} from '../../common/time';
+import {fromNs, TPDuration, TPTime, tpTimeToNanos} from '../../common/time';
 import {TrackData} from '../../common/track_data';
 import {TrackController} from '../../controller/track_controller';
 import {NewTrackArgs, Track} from '../../frontend/track';
@@ -51,16 +51,17 @@
   static readonly kind = ACTUAL_FRAMES_SLICE_TRACK_KIND;
   private maxDurNs = 0;
 
-  async onBoundsChange(start: number, end: number, resolution: number):
+  async onBoundsChange(start: TPTime, end: TPTime, resolution: TPDuration):
       Promise<Data> {
-    const startNs = toNs(start);
-    const endNs = toNs(end);
+    const startNs = tpTimeToNanos(start);
+    const endNs = tpTimeToNanos(end);
 
     const pxSize = this.pxSize();
 
     // ns per quantization bucket (i.e. ns per pixel). /2 * 2 is to force it to
     // be an even number, so we can snap in the middle.
-    const bucketNs = Math.max(Math.round(resolution * 1e9 * pxSize / 2) * 2, 1);
+    const bucketNs =
+        Math.max(Math.round(Number(resolution) * pxSize / 2) * 2, 1);
 
     if (this.maxDurNs === 0) {
       const maxDurResult = await this.query(`
diff --git a/ui/src/tracks/android_log/index.ts b/ui/src/tracks/android_log/index.ts
index b2a824b..6c8a1bc 100644
--- a/ui/src/tracks/android_log/index.ts
+++ b/ui/src/tracks/android_log/index.ts
@@ -14,7 +14,7 @@
 
 import {PluginContext} from '../../common/plugin_api';
 import {NUM} from '../../common/query_result';
-import {fromNs, toNsCeil, toNsFloor} from '../../common/time';
+import {fromNs, TPDuration, TPTime} from '../../common/time';
 import {TrackData} from '../../common/track_data';
 import {LIMIT} from '../../common/track_data';
 import {
@@ -61,21 +61,15 @@
 class AndroidLogTrackController extends TrackController<Config, Data> {
   static readonly kind = ANDROID_LOGS_TRACK_KIND;
 
-  async onBoundsChange(start: number, end: number, resolution: number):
+  async onBoundsChange(start: TPTime, end: TPTime, resolution: TPDuration):
       Promise<Data> {
-    const startNs = toNsFloor(start);
-    const endNs = toNsCeil(end);
-
-    // |resolution| is in s/px the frontend wants.
-    const quantNs = toNsCeil(resolution);
-
     const queryRes = await this.query(`
       select
-        cast(ts / ${quantNs} as integer) * ${quantNs} as tsQuant,
+        cast(ts / ${resolution} as integer) * ${resolution} as tsQuant,
         prio,
         count(prio) as numEvents
       from android_logs
-      where ts >= ${startNs} and ts <= ${endNs}
+      where ts >= ${start} and ts <= ${end}
       group by tsQuant, prio
       order by tsQuant, prio limit ${LIMIT};`);
 
@@ -113,16 +107,16 @@
   }
 
   renderCanvas(ctx: CanvasRenderingContext2D): void {
-    const {timeScale, visibleWindowTime} = globals.frontendLocalState;
+    const {visibleTimeScale, windowSpan} = globals.frontendLocalState;
 
     const data = this.data();
 
     if (data === undefined) return;  // Can't possibly draw anything.
 
-    const dataStartPx = timeScale.timeToPx(data.start);
-    const dataEndPx = timeScale.timeToPx(data.end);
-    const visibleStartPx = timeScale.timeToPx(visibleWindowTime.start);
-    const visibleEndPx = timeScale.timeToPx(visibleWindowTime.end);
+    const dataStartPx = visibleTimeScale.tpTimeToPx(data.start);
+    const dataEndPx = visibleTimeScale.tpTimeToPx(data.end);
+    const visibleStartPx = windowSpan.start;
+    const visibleEndPx = windowSpan.end;
 
     checkerboardExcept(
         ctx,
@@ -133,7 +127,7 @@
         dataEndPx);
 
     const quantWidth =
-        Math.max(EVT_PX, timeScale.deltaTimeToPx(data.resolution));
+        Math.max(EVT_PX, visibleTimeScale.durationToPx(data.resolution));
     const blockH = RECT_HEIGHT / LEVELS.length;
     for (let i = 0; i < data.timestamps.length; i++) {
       for (let lev = 0; lev < LEVELS.length; lev++) {
@@ -143,7 +137,7 @@
         }
         if (!hasEventsForCurColor) continue;
         ctx.fillStyle = LEVELS[lev].color;
-        const px = Math.floor(timeScale.timeToPx(data.timestamps[i]));
+        const px = Math.floor(visibleTimeScale.secondsToPx(data.timestamps[i]));
         ctx.fillRect(px, MARGIN_TOP + blockH * lev, quantWidth, blockH);
       }  // for(lev)
     }    // for (timestamps)
diff --git a/ui/src/tracks/async_slices/index.ts b/ui/src/tracks/async_slices/index.ts
index 338e02a..997577f 100644
--- a/ui/src/tracks/async_slices/index.ts
+++ b/ui/src/tracks/async_slices/index.ts
@@ -13,8 +13,8 @@
 // limitations under the License.
 
 import {PluginContext} from '../../common/plugin_api';
-import {NUM, NUM_NULL, STR} from '../../common/query_result';
-import {fromNs, toNs} from '../../common/time';
+import {LONG_NULL, NUM, STR} from '../../common/query_result';
+import {fromNs, TPDuration, TPTime} from '../../common/time';
 import {TrackData} from '../../common/track_data';
 import {
   TrackController,
@@ -43,26 +43,24 @@
 
 class AsyncSliceTrackController extends TrackController<Config, Data> {
   static readonly kind = ASYNC_SLICE_TRACK_KIND;
-  private maxDurNs = 0;
+  private maxDurNs: TPDuration = 0n;
 
-  async onBoundsChange(start: number, end: number, resolution: number):
+  async onBoundsChange(start: TPTime, end: TPTime, resolution: TPDuration):
       Promise<Data> {
-    const startNs = toNs(start);
-    const endNs = toNs(end);
-
     const pxSize = this.pxSize();
 
     // ns per quantization bucket (i.e. ns per pixel). /2 * 2 is to force it to
     // be an even number, so we can snap in the middle.
-    const bucketNs = Math.max(Math.round(resolution * 1e9 * pxSize / 2) * 2, 1);
+    const bucketNs =
+        Math.max(Math.round(Number(resolution) * pxSize / 2) * 2, 1);
 
-    if (this.maxDurNs === 0) {
+    if (this.maxDurNs === 0n) {
       const maxDurResult = await this.query(`
         select max(iif(dur = -1, (SELECT end_ts FROM trace_bounds) - ts, dur))
         as maxDur from experimental_slice_layout
         where filter_track_ids = '${this.config.trackIds.join(',')}'
       `);
-      this.maxDurNs = maxDurResult.firstRow({maxDur: NUM_NULL}).maxDur || 0;
+      this.maxDurNs = maxDurResult.firstRow({maxDur: LONG_NULL}).maxDur || 0n;
     }
 
     const queryRes = await this.query(`
@@ -78,8 +76,8 @@
       from experimental_slice_layout
       where
         filter_track_ids = '${this.config.trackIds.join(',')}' and
-        ts >= ${startNs - this.maxDurNs} and
-        ts <= ${endNs}
+        ts >= ${start - this.maxDurNs} and
+        ts <= ${end}
       group by tsq, layout_depth
       order by tsq, layout_depth
     `);
diff --git a/ui/src/tracks/chrome_slices/index.ts b/ui/src/tracks/chrome_slices/index.ts
index 23f9a5e..4e70507 100644
--- a/ui/src/tracks/chrome_slices/index.ts
+++ b/ui/src/tracks/chrome_slices/index.ts
@@ -15,9 +15,16 @@
 import {Actions} from '../../common/actions';
 import {cropText, drawIncompleteSlice} from '../../common/canvas_utils';
 import {colorForThreadIdleSlice, hslForSlice} from '../../common/colorizer';
+import {
+  HighPrecisionTime,
+} from '../../common/high_precision_time';
 import {PluginContext} from '../../common/plugin_api';
-import {NUM, NUM_NULL, STR} from '../../common/query_result';
-import {fromNs, toNs} from '../../common/time';
+import {LONG_NULL, NUM, NUM_NULL, STR} from '../../common/query_result';
+import {
+  fromNs,
+  TPDuration,
+  TPTime,
+} from '../../common/time';
 import {TrackData} from '../../common/track_data';
 import {
   TrackController,
@@ -60,27 +67,25 @@
 
 export class ChromeSliceTrackController extends TrackController<Config, Data> {
   static kind = SLICE_TRACK_KIND;
-  private maxDurNs = 0;
+  private maxDurNs: TPDuration = 0n;
 
-  async onBoundsChange(start: number, end: number, resolution: number):
+  async onBoundsChange(start: TPTime, end: TPTime, resolution: TPDuration):
       Promise<Data> {
-    const startNs = toNs(start);
-    const endNs = toNs(end);
-
     const pxSize = this.pxSize();
 
     // ns per quantization bucket (i.e. ns per pixel). /2 * 2 is to force it to
     // be an even number, so we can snap in the middle.
-    const bucketNs = Math.max(Math.round(resolution * 1e9 * pxSize / 2) * 2, 1);
+    const bucketNs =
+        Math.max(Math.round(Number(resolution) * pxSize / 2) * 2, 1);
 
     const tableName = this.namespaceTable('slice');
 
-    if (this.maxDurNs === 0) {
+    if (this.maxDurNs === 0n) {
       const query = `
           SELECT max(iif(dur = -1, (SELECT end_ts FROM trace_bounds) - ts, dur))
           AS maxDur FROM ${tableName} WHERE track_id = ${this.config.trackId}`;
       const queryRes = await this.query(query);
-      this.maxDurNs = queryRes.firstRow({maxDur: NUM_NULL}).maxDur || 0;
+      this.maxDurNs = queryRes.firstRow({maxDur: LONG_NULL}).maxDur || 0n;
     }
 
     // Buckets are always even and positive, don't quantize once we zoom to
@@ -103,8 +108,8 @@
         thread_dur as threadDur
       FROM ${tableName}
       WHERE track_id = ${this.config.trackId} AND
-        ts >= (${startNs - this.maxDurNs}) AND
-        ts <= ${endNs}
+        ts >= (${start - this.maxDurNs}) AND
+        ts <= ${end}
       GROUP BY depth, tsq`;
     const queryRes = await this.query(query);
 
@@ -209,7 +214,7 @@
   renderCanvas(ctx: CanvasRenderingContext2D): void {
     // TODO: fonts and colors should come from the CSS and not hardcoded here.
 
-    const {timeScale, visibleWindowTime} = globals.frontendLocalState;
+    const {visibleTimeScale, visibleWindowTime} = globals.frontendLocalState;
     const data = this.data();
 
     if (data === undefined) return;  // Can't possibly draw anything.
@@ -219,10 +224,10 @@
     checkerboardExcept(
         ctx,
         this.getHeight(),
-        timeScale.timeToPx(visibleWindowTime.start),
-        timeScale.timeToPx(visibleWindowTime.end),
-        timeScale.timeToPx(data.start),
-        timeScale.timeToPx(data.end),
+        visibleTimeScale.hpTimeToPx(visibleWindowTime.start),
+        visibleTimeScale.hpTimeToPx(visibleWindowTime.end),
+        visibleTimeScale.tpTimeToPx(data.start),
+        visibleTimeScale.tpTimeToPx(data.end),
     );
 
     ctx.textAlign = 'center';
@@ -245,7 +250,7 @@
       const title = data.strings[titleId];
       const colorOverride = data.colors && data.strings[data.colors[i]];
       if (isIncomplete) {  // incomplete slice
-        tEnd = visibleWindowTime.end;
+        tEnd = visibleWindowTime.end.seconds;
       }
 
       const rect = this.getSliceRect(tStart, tEnd, depth);
@@ -369,10 +374,14 @@
   getSliceIndex({x, y}: {x: number, y: number}): number|void {
     const data = this.data();
     if (data === undefined) return;
-    const {timeScale} = globals.frontendLocalState;
+    const {
+      visibleTimeScale: timeScale,
+      visibleWindowTime,
+    } = globals.frontendLocalState;
     if (y < TRACK_PADDING) return;
-    const instantWidthTime = timeScale.deltaPxToDuration(HALF_CHEVRON_WIDTH_PX);
-    const t = timeScale.pxToTime(x);
+    const instantWidthTime = timeScale.pxDeltaToDuration(HALF_CHEVRON_WIDTH_PX);
+    const instantWidthTimeSec = instantWidthTime.seconds;
+    const t = timeScale.pxToHpTime(x).seconds;
     const depth = Math.floor((y - TRACK_PADDING) / SLICE_HEIGHT);
     for (let i = 0; i < data.starts.length; i++) {
       if (depth !== data.depths[i]) {
@@ -380,13 +389,13 @@
       }
       const tStart = data.starts[i];
       if (data.isInstant[i]) {
-        if (Math.abs(tStart - t) < instantWidthTime) {
+        if (Math.abs(tStart - t) < instantWidthTimeSec) {
           return i;
         }
       } else {
         let tEnd = data.ends[i];
         if (data.isIncomplete[i]) {
-          tEnd = globals.frontendLocalState.visibleWindowTime.end;
+          tEnd = visibleWindowTime.end.seconds;
         }
         if (tStart <= t && t <= tEnd) {
           return i;
@@ -435,17 +444,28 @@
 
   getSliceRect(tStart: number, tEnd: number, depth: number): SliceRect
       |undefined {
-    const {timeScale, visibleWindowTime} = globals.frontendLocalState;
-    const pxEnd = timeScale.timeToPx(visibleWindowTime.end);
-    const left = Math.max(timeScale.timeToPx(tStart), 0);
-    const right = Math.min(timeScale.timeToPx(tEnd), pxEnd);
+    const {
+      visibleTimeScale: timeScale,
+      visibleWindowTime,
+      windowSpan,
+    } = globals.frontendLocalState;
+
+    const pxEnd = windowSpan.end;
+    const left = Math.max(timeScale.secondsToPx(tStart), 0);
+    const right = Math.min(timeScale.secondsToPx(tEnd), pxEnd);
+
+    const visible =
+        !(visibleWindowTime.start.isGreaterThan(
+              HighPrecisionTime.fromSeconds(tEnd)) ||
+          visibleWindowTime.end.isLessThan(
+              HighPrecisionTime.fromSeconds(tStart)));
+
     return {
       left,
       width: Math.max(right - left, 1),
       top: TRACK_PADDING + depth * SLICE_HEIGHT,
       height: SLICE_HEIGHT,
-      visible:
-          !(tEnd <= visibleWindowTime.start || tStart >= visibleWindowTime.end),
+      visible,
     };
   }
 }
diff --git a/ui/src/tracks/counter/index.ts b/ui/src/tracks/counter/index.ts
index 9aa8f74..f22e8c7 100644
--- a/ui/src/tracks/counter/index.ts
+++ b/ui/src/tracks/counter/index.ts
@@ -19,13 +19,18 @@
 import {Actions} from '../../common/actions';
 import {
   EngineProxy,
+  LONG_NULL,
   NUM,
-  NUM_NULL,
   PluginContext,
   STR,
   TrackInfo,
 } from '../../common/plugin_api';
-import {fromNs, toNs} from '../../common/time';
+import {
+  fromNs,
+  TPDuration,
+  TPTime,
+  tpTimeFromSeconds,
+} from '../../common/time';
 import {TrackData} from '../../common/track_data';
 import {
   TrackController,
@@ -62,8 +67,8 @@
   name: string;
   maximumValue?: number;
   minimumValue?: number;
-  startTs?: number;
-  endTs?: number;
+  startTs?: TPTime;
+  endTs?: TPTime;
   namespace: string;
   trackId: number;
   scale?: CounterScaleOptions;
@@ -76,18 +81,16 @@
   private minimumValueSeen = 0;
   private maximumDeltaSeen = 0;
   private minimumDeltaSeen = 0;
-  private maxDurNs = 0;
+  private maxDurNs: TPDuration = 0n;
 
-  async onBoundsChange(start: number, end: number, resolution: number):
+  async onBoundsChange(start: TPTime, end: TPTime, resolution: TPDuration):
       Promise<Data> {
-    const startNs = toNs(start);
-    const endNs = toNs(end);
-
     const pxSize = this.pxSize();
 
     // ns per quantization bucket (i.e. ns per pixel). /2 * 2 is to force it to
     // be an even number, so we can snap in the middle.
-    const bucketNs = Math.max(Math.round(resolution * 1e9 * pxSize / 2) * 2, 1);
+    const bucketNs =
+        Math.max(Math.round(Number(resolution) * pxSize / 2) * 2, 1);
 
     if (!this.setup) {
       if (this.config.namespace === undefined) {
@@ -123,7 +126,7 @@
             ) as maxDur
           from ${this.tableName('counter_view')}
       `);
-      this.maxDurNs = maxDurResult.firstRow({maxDur: NUM_NULL}).maxDur || 0;
+      this.maxDurNs = maxDurResult.firstRow({maxDur: LONG_NULL}).maxDur || 0n;
 
       const queryRes = await this.query(`
         select
@@ -151,7 +154,7 @@
         value_at_max_ts(ts, id) as lastId,
         value_at_max_ts(ts, value) as lastValue
       from ${this.tableName('counter_view')}
-      where ts >= ${startNs - this.maxDurNs} and ts <= ${endNs}
+      where ts >= ${start - this.maxDurNs} and ts <= ${end}
       group by tsq
       order by tsq
     `);
@@ -285,7 +288,10 @@
 
   renderCanvas(ctx: CanvasRenderingContext2D): void {
     // TODO: fonts and colors should come from the CSS and not hardcoded here.
-    const {timeScale, visibleWindowTime} = globals.frontendLocalState;
+    const {
+      visibleTimeScale: timeScale,
+      windowSpan,
+    } = globals.frontendLocalState;
     const data = this.data();
 
     // Can't possibly draw anything.
@@ -321,7 +327,7 @@
       minimumValue = data.minimumRate;
     }
 
-    const endPx = Math.floor(timeScale.timeToPx(visibleWindowTime.end));
+    const endPx = windowSpan.end;
     const zeroY = MARGIN_TOP + RECT_HEIGHT / (minimumValue < 0 ? 2 : 1);
 
     // Quantize the Y axis to quarters of powers of tens (7.5K, 10K, 12.5K).
@@ -366,7 +372,7 @@
     ctx.strokeStyle = `hsl(${hue}, 45%, 45%)`;
 
     const calculateX = (ts: number) => {
-      return Math.floor(timeScale.timeToPx(ts));
+      return Math.floor(timeScale.secondsToPx(ts));
     };
     const calculateY = (value: number) => {
       return MARGIN_TOP + RECT_HEIGHT -
@@ -427,10 +433,10 @@
       ctx.fillStyle = `hsl(${hue}, 45%, 75%)`;
       ctx.strokeStyle = `hsl(${hue}, 45%, 45%)`;
 
-      const xStart = Math.floor(timeScale.timeToPx(this.hoveredTs));
+      const xStart = Math.floor(timeScale.secondsToPx(this.hoveredTs));
       const xEnd = this.hoveredTsEnd === undefined ?
           endPx :
-          Math.floor(timeScale.timeToPx(this.hoveredTsEnd));
+          Math.floor(timeScale.secondsToPx(this.hoveredTsEnd));
       const y = MARGIN_TOP + RECT_HEIGHT -
           Math.round(((this.hoveredValue - yMin) / yRange) * RECT_HEIGHT);
 
@@ -463,9 +469,10 @@
 
     // TODO(hjd): Refactor this into checkerboardExcept
     {
-      const endPx = timeScale.timeToPx(visibleWindowTime.end);
-      const counterEndPx =
-          Math.min(timeScale.timeToPx(this.config.endTs || Infinity), endPx);
+      let counterEndPx = Infinity;
+      if (this.config.endTs) {
+        counterEndPx = Math.min(timeScale.tpTimeToPx(this.config.endTs), endPx);
+      }
 
       // Grey out RHS.
       if (counterEndPx < endPx) {
@@ -479,18 +486,18 @@
     checkerboardExcept(
         ctx,
         this.getHeight(),
-        timeScale.timeToPx(visibleWindowTime.start),
-        timeScale.timeToPx(visibleWindowTime.end),
-        timeScale.timeToPx(data.start),
-        timeScale.timeToPx(data.end));
+        windowSpan.start,
+        windowSpan.end,
+        timeScale.tpTimeToPx(data.start),
+        timeScale.tpTimeToPx(data.end));
   }
 
   onMouseMove(pos: {x: number, y: number}) {
     const data = this.data();
     if (data === undefined) return;
     this.mousePos = pos;
-    const {timeScale} = globals.frontendLocalState;
-    const time = timeScale.pxToTime(pos.x);
+    const {visibleTimeScale} = globals.frontendLocalState;
+    const time = visibleTimeScale.pxToHpTime(pos.x).seconds;
 
     const values = this.config.scale === 'DELTA_FROM_PREVIOUS' ?
         data.totalDeltas :
@@ -509,8 +516,8 @@
   onMouseClick({x}: {x: number}) {
     const data = this.data();
     if (data === undefined) return false;
-    const {timeScale} = globals.frontendLocalState;
-    const time = timeScale.pxToTime(x);
+    const {visibleTimeScale} = globals.frontendLocalState;
+    const time = visibleTimeScale.pxToHpTime(x).seconds;
     const [left, right] = searchSegment(data.timestamps, time);
     if (left === -1) {
       return false;
@@ -518,8 +525,8 @@
       const counterId = data.lastIds[left];
       if (counterId === -1) return true;
       globals.makeSelection(Actions.selectCounter({
-        leftTs: toNs(data.timestamps[left]),
-        rightTs: right !== -1 ? toNs(data.timestamps[right]) : -1,
+        leftTs: tpTimeFromSeconds(data.timestamps[left]),
+        rightTs: tpTimeFromSeconds(right !== -1 ? data.timestamps[right] : -1),
         id: counterId,
         trackId: this.trackState.id,
       }));
diff --git a/ui/src/tracks/cpu_freq/index.ts b/ui/src/tracks/cpu_freq/index.ts
index 8bb2e3b..7956172 100644
--- a/ui/src/tracks/cpu_freq/index.ts
+++ b/ui/src/tracks/cpu_freq/index.ts
@@ -12,12 +12,13 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {BigintMath} from '../../base/bigint_math';
 import {searchSegment} from '../../base/binary_search';
 import {assertTrue} from '../../base/logging';
 import {hueForCpu} from '../../common/colorizer';
 import {PluginContext} from '../../common/plugin_api';
 import {NUM, NUM_NULL, QueryResult} from '../../common/query_result';
-import {fromNs, toNs} from '../../common/time';
+import {fromNs, TPDuration, TPTime, tpTimeToNanos} from '../../common/time';
 import {TrackData} from '../../common/track_data';
 import {
   TrackController,
@@ -96,15 +97,17 @@
     this.cachedBucketNs = bucketNs;
   }
 
-  async onBoundsChange(start: number, end: number, resolution: number):
+  async onBoundsChange(start: TPTime, end: TPTime, resolution: TPDuration):
       Promise<Data> {
     // The resolution should always be a power of two for the logic of this
     // function to make sense.
-    const resolutionNs = toNs(resolution);
-    assertTrue(Math.log2(resolutionNs) % 1 === 0);
+    assertTrue(
+        BigintMath.popcount(resolution) === 1,
+        `${resolution} is not a power of 2`);
+    const resolutionNs = Number(resolution);
 
-    const startNs = toNs(start);
-    const endNs = toNs(end);
+    const startNs = tpTimeToNanos(start);
+    const endNs = tpTimeToNanos(end);
 
     // ns per quantization bucket (i.e. ns per pixel). /2 * 2 is to force it to
     // be an even number, so we can snap in the middle.
@@ -289,7 +292,11 @@
 
   renderCanvas(ctx: CanvasRenderingContext2D): void {
     // TODO: fonts and colors should come from the CSS and not hardcoded here.
-    const {timeScale, visibleWindowTime} = globals.frontendLocalState;
+    const {
+      visibleTimeScale,
+      visibleWindowTime,
+      windowSpan,
+    } = globals.frontendLocalState;
     const data = this.data();
 
     if (data === undefined || data.timestamps.length === 0) {
@@ -302,7 +309,7 @@
     assertTrue(data.timestamps.length === data.maxFreqKHz.length);
     assertTrue(data.timestamps.length === data.lastIdleValues.length);
 
-    const endPx = timeScale.timeToPx(visibleWindowTime.end);
+    const endPx = windowSpan.end;
     const zeroY = MARGIN_TOP + RECT_HEIGHT;
 
     // Quantize the Y axis to quarters of powers of tens (7.5K, 10K, 12.5K).
@@ -326,17 +333,18 @@
     ctx.strokeStyle = `hsl(${hue}, ${saturation}%, 55%)`;
 
     const calculateX = (timestamp: number) => {
-      return Math.floor(timeScale.timeToPx(timestamp));
+      return Math.floor(visibleTimeScale.secondsToPx(timestamp));
     };
     const calculateY = (value: number) => {
       return zeroY - Math.round((value / yMax) * RECT_HEIGHT);
     };
 
-    const [rawStartIdx] =
-        searchSegment(data.timestamps, visibleWindowTime.start);
+    const startSec = visibleWindowTime.start.seconds;
+    const endSec = visibleWindowTime.end.seconds;
+    const [rawStartIdx] = searchSegment(data.timestamps, startSec);
     const startIdx = rawStartIdx === -1 ? 0 : rawStartIdx;
 
-    const [, rawEndIdx] = searchSegment(data.timestamps, visibleWindowTime.end);
+    const [, rawEndIdx] = searchSegment(data.timestamps, endSec);
     const endIdx = rawEndIdx === -1 ? data.timestamps.length : rawEndIdx;
 
     ctx.beginPath();
@@ -383,10 +391,10 @@
       // coordinates. Instead we use floating point which prevents flickering as
       // we pan and zoom; this relies on the browser anti-aliasing pixels
       // correctly.
-      const x = timeScale.timeToPx(data.timestamps[i]);
+      const x = visibleTimeScale.secondsToPx(data.timestamps[i]);
       const xEnd = i === data.lastIdleValues.length - 1 ?
           finalX :
-          timeScale.timeToPx(data.timestamps[i + 1]);
+          visibleTimeScale.secondsToPx(data.timestamps[i + 1]);
 
       const width = xEnd - x;
       const height = calculateY(data.lastFreqKHz[i]) - zeroY;
@@ -402,10 +410,10 @@
       ctx.fillStyle = `hsl(${hue}, 45%, 75%)`;
       ctx.strokeStyle = `hsl(${hue}, 45%, 45%)`;
 
-      const xStart = Math.floor(timeScale.timeToPx(this.hoveredTs));
+      const xStart = Math.floor(visibleTimeScale.secondsToPx(this.hoveredTs));
       const xEnd = this.hoveredTsEnd === undefined ?
           endPx :
-          Math.floor(timeScale.timeToPx(this.hoveredTsEnd));
+          Math.floor(visibleTimeScale.secondsToPx(this.hoveredTsEnd));
       const y = zeroY - Math.round((this.hoveredValue / yMax) * RECT_HEIGHT);
 
       // Highlight line.
@@ -446,18 +454,18 @@
     checkerboardExcept(
         ctx,
         this.getHeight(),
-        timeScale.timeToPx(visibleWindowTime.start),
-        timeScale.timeToPx(visibleWindowTime.end),
-        timeScale.timeToPx(data.start),
-        timeScale.timeToPx(data.end));
+        windowSpan.start,
+        windowSpan.end,
+        visibleTimeScale.tpTimeToPx(data.start),
+        visibleTimeScale.tpTimeToPx(data.end));
   }
 
   onMouseMove(pos: {x: number, y: number}) {
     const data = this.data();
     if (data === undefined) return;
     this.mousePos = pos;
-    const {timeScale} = globals.frontendLocalState;
-    const time = timeScale.pxToTime(pos.x);
+    const {visibleTimeScale} = globals.frontendLocalState;
+    const time = visibleTimeScale.pxToHpTime(pos.x).seconds;
 
     const [left, right] = searchSegment(data.timestamps, time);
     this.hoveredTs = left === -1 ? undefined : data.timestamps[left];
diff --git a/ui/src/tracks/cpu_profile/index.ts b/ui/src/tracks/cpu_profile/index.ts
index eee7b17..028d5b1 100644
--- a/ui/src/tracks/cpu_profile/index.ts
+++ b/ui/src/tracks/cpu_profile/index.ts
@@ -18,7 +18,7 @@
 import {hslForSlice} from '../../common/colorizer';
 import {PluginContext} from '../../common/plugin_api';
 import {NUM} from '../../common/query_result';
-import {fromNs, toNs} from '../../common/time';
+import {fromNs, TPDuration, TPTime} from '../../common/time';
 import {TrackData} from '../../common/track_data';
 import {
   TrackController,
@@ -46,7 +46,7 @@
 
 class CpuProfileTrackController extends TrackController<Config, Data> {
   static readonly kind = CPU_PROFILE_TRACK_KIND;
-  async onBoundsChange(start: number, end: number, resolution: number):
+  async onBoundsChange(start: TPTime, end: TPTime, resolution: TPDuration):
       Promise<Data> {
     const query = `select
         id,
@@ -105,7 +105,7 @@
 
   renderCanvas(ctx: CanvasRenderingContext2D): void {
     const {
-      timeScale,
+      visibleTimeScale: timeScale,
     } = globals.frontendLocalState;
     const data = this.data();
 
@@ -120,7 +120,7 @@
       const strokeWidth = isSelected ? 3 : 0;
       this.drawMarker(
           ctx,
-          timeScale.timeToPx(fromNs(centerX)),
+          timeScale.secondsToPx(fromNs(centerX)),
           this.centerY,
           isHovered,
           strokeWidth,
@@ -146,8 +146,8 @@
       if (clusterStartIndex !== clusterEndIndex) {
         const startX = data.tsStarts[clusterStartIndex];
         const endX = data.tsStarts[clusterEndIndex];
-        const leftPx = timeScale.timeToPx(fromNs(startX)) - this.markerWidth;
-        const rightPx = timeScale.timeToPx(fromNs(endX)) + this.markerWidth;
+        const leftPx = timeScale.secondsToPx(fromNs(startX)) - this.markerWidth;
+        const rightPx = timeScale.secondsToPx(fromNs(endX)) + this.markerWidth;
         const width = rightPx - leftPx;
         ctx.fillStyle = colorForSample(callsiteId, false);
         ctx.fillRect(leftPx, MARGIN_TOP, width, BAR_HEIGHT);
@@ -179,8 +179,10 @@
   onMouseMove({x, y}: {x: number, y: number}) {
     const data = this.data();
     if (data === undefined) return;
-    const {timeScale} = globals.frontendLocalState;
-    const time = toNs(timeScale.pxToTime(x));
+    const {
+      visibleTimeScale: timeScale,
+    } = globals.frontendLocalState;
+    const time = timeScale.pxToHpTime(x).nanos;
     const [left, right] = searchSegment(data.tsStarts, time);
     const index = this.findTimestampIndex(left, timeScale, data, x, y, right);
     this.hoveredTs = index === -1 ? undefined : data.tsStarts[index];
@@ -193,9 +195,11 @@
   onMouseClick({x, y}: {x: number, y: number}) {
     const data = this.data();
     if (data === undefined) return false;
-    const {timeScale} = globals.frontendLocalState;
+    const {
+      visibleTimeScale: timeScale,
+    } = globals.frontendLocalState;
 
-    const time = toNs(timeScale.pxToTime(x));
+    const time = timeScale.pxToHpTime(x).nanos;
     const [left, right] = searchSegment(data.tsStarts, time);
 
     const index = this.findTimestampIndex(left, timeScale, data, x, y, right);
@@ -217,13 +221,13 @@
       right: number): number {
     let index = -1;
     if (left !== -1) {
-      const centerX = timeScale.timeToPx(fromNs(data.tsStarts[left]));
+      const centerX = timeScale.secondsToPx(fromNs(data.tsStarts[left]));
       if (this.isInMarker(x, y, centerX)) {
         index = left;
       }
     }
     if (right !== -1) {
-      const centerX = timeScale.timeToPx(fromNs(data.tsStarts[right]));
+      const centerX = timeScale.secondsToPx(fromNs(data.tsStarts[right]));
       if (this.isInMarker(x, y, centerX)) {
         index = right;
       }
diff --git a/ui/src/tracks/cpu_slices/index.ts b/ui/src/tracks/cpu_slices/index.ts
index 0953d2f..4264069 100644
--- a/ui/src/tracks/cpu_slices/index.ts
+++ b/ui/src/tracks/cpu_slices/index.ts
@@ -12,6 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {BigintMath} from '../../base/bigint_math';
 import {search, searchEq, searchSegment} from '../../base/binary_search';
 import {assertTrue} from '../../base/logging';
 import {Actions} from '../../common/actions';
@@ -23,7 +24,15 @@
 import {colorForThread} from '../../common/colorizer';
 import {PluginContext} from '../../common/plugin_api';
 import {NUM} from '../../common/query_result';
-import {fromNs, timeToString, toNs} from '../../common/time';
+import {
+  fromNs,
+  toNs,
+  TPDuration,
+  TPTime,
+  tpTimeFromSeconds,
+  tpTimeToNanos,
+  tpTimeToString,
+} from '../../common/time';
 import {TrackData} from '../../common/track_data';
 import {
   TrackController,
@@ -102,16 +111,19 @@
     this.cachedBucketNs = bucketNs;
   }
 
-  async onBoundsChange(start: number, end: number, resolution: number):
+  async onBoundsChange(start: TPTime, end: TPTime, resolution: TPDuration):
       Promise<Data> {
-    const resolutionNs = toNs(resolution);
+    assertTrue(
+        BigintMath.popcount(resolution) === 1,
+        `${resolution} is not a power of 2`);
+    const resolutionNs = Number(resolution);
 
     // The resolution should always be a power of two for the logic of this
     // function to make sense.
     assertTrue(Math.log2(resolutionNs) % 1 === 0);
 
-    const boundStartNs = toNs(start);
-    const boundEndNs = toNs(end);
+    const boundStartNs = tpTimeToNanos(start);
+    const boundEndNs = tpTimeToNanos(end);
 
     // ns per quantization bucket (i.e. ns per pixel). /2 * 2 is to force it to
     // be an even number, so we can snap in the middle.
@@ -223,7 +235,7 @@
 
   renderCanvas(ctx: CanvasRenderingContext2D): void {
     // TODO: fonts and colors should come from the CSS and not hardcoded here.
-    const {timeScale, visibleWindowTime} = globals.frontendLocalState;
+    const {visibleTimeScale, windowSpan} = globals.frontendLocalState;
     const data = this.data();
 
     if (data === undefined) return;  // Can't possibly draw anything.
@@ -233,28 +245,35 @@
     checkerboardExcept(
         ctx,
         this.getHeight(),
-        timeScale.timeToPx(visibleWindowTime.start),
-        timeScale.timeToPx(visibleWindowTime.end),
-        timeScale.timeToPx(data.start),
-        timeScale.timeToPx(data.end));
+        windowSpan.start,
+        windowSpan.end,
+        visibleTimeScale.tpTimeToPx(data.start),
+        visibleTimeScale.tpTimeToPx(data.end));
 
     this.renderSlices(ctx, data);
   }
 
   renderSlices(ctx: CanvasRenderingContext2D, data: Data): void {
-    const {timeScale, visibleWindowTime} = globals.frontendLocalState;
+    const {
+      visibleTimeScale,
+      visibleWindowTime,
+    } = globals.frontendLocalState;
     assertTrue(data.starts.length === data.ends.length);
     assertTrue(data.starts.length === data.utids.length);
 
+    const visWindowEndPx = visibleTimeScale.hpTimeToPx(visibleWindowTime.end);
+
     ctx.textAlign = 'center';
     ctx.font = '12px Roboto Condensed';
     const charWidth = ctx.measureText('dbpqaouk').width / 8;
 
-    const rawStartIdx =
-        data.ends.findIndex((end) => end >= visibleWindowTime.start);
+    const startSec = visibleWindowTime.start.seconds;
+    const endSec = visibleWindowTime.end.seconds;
+
+    const rawStartIdx = data.ends.findIndex((end) => end >= startSec);
     const startIdx = rawStartIdx === -1 ? 0 : rawStartIdx;
 
-    const [, rawEndIdx] = searchSegment(data.starts, visibleWindowTime.end);
+    const [, rawEndIdx] = searchSegment(data.starts, endSec);
     const endIdx = rawEndIdx === -1 ? data.starts.length : rawEndIdx;
 
     for (let i = startIdx; i < endIdx; i++) {
@@ -266,10 +285,10 @@
       // window, else it might spill over the window and the end would not be
       // visible as a zigzag line.
       if (data.ids[i] === data.lastRowId && data.isIncomplete[i]) {
-        tEnd = visibleWindowTime.end;
+        tEnd = visibleWindowTime.end.seconds;
       }
-      const rectStart = timeScale.timeToPx(tStart);
-      const rectEnd = timeScale.timeToPx(tEnd);
+      const rectStart = visibleTimeScale.secondsToPx(tStart);
+      const rectEnd = visibleTimeScale.secondsToPx(tEnd);
       const rectWidth = Math.max(1, rectEnd - rectStart);
 
       const threadInfo = globals.threads.get(utid);
@@ -317,8 +336,7 @@
           title = `${threadInfo.threadName} [${threadInfo.tid}]`;
         }
       }
-      const right =
-          Math.min(timeScale.timeToPx(visibleWindowTime.end), rectEnd);
+      const right = Math.min(visWindowEndPx, rectEnd);
       const left = Math.max(rectStart, 0);
       const visibleWidth = Math.max(right - left, 1);
       title = cropText(title, charWidth, visibleWidth);
@@ -341,8 +359,8 @@
         const tEnd = data.ends[startIndex];
         const utid = data.utids[startIndex];
         const color = colorForThread(globals.threads.get(utid));
-        const rectStart = timeScale.timeToPx(tStart);
-        const rectEnd = timeScale.timeToPx(tEnd);
+        const rectStart = visibleTimeScale.secondsToPx(tStart);
+        const rectEnd = visibleTimeScale.secondsToPx(tEnd);
         const rectWidth = Math.max(1, rectEnd - rectStart);
 
         // Draw a rectangle around the slice that is currently selected.
@@ -353,7 +371,7 @@
         ctx.closePath();
         // Draw arrow from wakeup time of current slice.
         if (details.wakeupTs) {
-          const wakeupPos = timeScale.timeToPx(details.wakeupTs);
+          const wakeupPos = visibleTimeScale.tpTimeToPx(details.wakeupTs);
           const latencyWidth = rectStart - wakeupPos;
           drawDoubleHeadedArrow(
               ctx,
@@ -362,7 +380,8 @@
               latencyWidth,
               latencyWidth >= 20);
           // Latency time with a white semi-transparent background.
-          const displayText = timeToString(tStart - details.wakeupTs);
+          const latency = tpTimeFromSeconds(tStart) - details.wakeupTs;
+          const displayText = tpTimeToString(latency);
           const measured = ctx.measureText(displayText);
           if (latencyWidth >= measured.width + 2) {
             ctx.fillStyle = 'rgba(255,255,255,0.7)';
@@ -383,7 +402,8 @@
 
       // Draw diamond if the track being drawn is the cpu of the waker.
       if (this.config.cpu === details.wakerCpu && details.wakeupTs) {
-        const wakeupPos = Math.floor(timeScale.timeToPx(details.wakeupTs));
+        const wakeupPos =
+            Math.floor(visibleTimeScale.tpTimeToPx(details.wakeupTs));
         ctx.beginPath();
         ctx.moveTo(wakeupPos, MARGIN_TOP + RECT_HEIGHT / 2 + 8);
         ctx.fillStyle = 'black';
@@ -411,13 +431,13 @@
     const data = this.data();
     this.mousePos = pos;
     if (data === undefined) return;
-    const {timeScale} = globals.frontendLocalState;
+    const {visibleTimeScale} = globals.frontendLocalState;
     if (pos.y < MARGIN_TOP || pos.y > MARGIN_TOP + RECT_HEIGHT) {
       this.utidHoveredInThisTrack = -1;
       globals.dispatch(Actions.setHoveredUtidAndPid({utid: -1, pid: -1}));
       return;
     }
-    const t = timeScale.pxToTime(pos.x);
+    const t = visibleTimeScale.pxToHpTime(pos.x).seconds;
     let hoveredUtid = -1;
 
     for (let i = 0; i < data.starts.length; i++) {
@@ -445,8 +465,8 @@
   onMouseClick({x}: {x: number}) {
     const data = this.data();
     if (data === undefined) return false;
-    const {timeScale} = globals.frontendLocalState;
-    const time = timeScale.pxToTime(x);
+    const {visibleTimeScale} = globals.frontendLocalState;
+    const time = visibleTimeScale.pxToHpTime(x).seconds;
     const index = search(data.starts, time);
     const id = index === -1 ? undefined : data.ids[index];
     if (!id || this.utidHoveredInThisTrack === -1) return false;
diff --git a/ui/src/tracks/debug/details_tab.ts b/ui/src/tracks/debug/details_tab.ts
index 9268c17..50c2319 100644
--- a/ui/src/tracks/debug/details_tab.ts
+++ b/ui/src/tracks/debug/details_tab.ts
@@ -15,13 +15,14 @@
 import m from 'mithril';
 
 import {ColumnType} from '../../common/query_result';
+import {tpDurationFromSql, tpTimeFromSql} from '../../common/time';
 import {
   BottomTab,
   bottomTabRegistry,
   NewBottomTabArgs,
 } from '../../frontend/bottom_tab';
 import {globals} from '../../frontend/globals';
-import {timestampFromSqlNanos} from '../../frontend/sql_types';
+import {asTPTimestamp} from '../../frontend/sql_types';
 import {Duration} from '../../frontend/widgets/duration';
 import {Timestamp} from '../../frontend/widgets/timestamp';
 import {dictToTree} from '../../frontend/widgets/tree';
@@ -69,12 +70,11 @@
     if (this.data === undefined) {
       return m('h2', 'Loading');
     }
-    // TODO(stevegolton): These type assertions are dangerous, but no more
-    // dangerous than they used to be before this change.
     const left = dictToTree({
       'Name': this.data['name'] as string,
-      'Start time': m(Timestamp, {ts: timestampFromSqlNanos(this.data['ts'])}),
-      'Duration': m(Duration, {dur: Number(this.data['dur'])}),
+      'Start time':
+          m(Timestamp, {ts: asTPTimestamp(tpTimeFromSql(this.data['ts']))}),
+      'Duration': m(Duration, {dur: tpDurationFromSql(this.data['dur'])}),
       'Debug slice id': `${this.config.sqlTableName}[${this.config.id}]`,
     });
     const args: {[key: string]: m.Child} = {};
diff --git a/ui/src/tracks/debug/slice_track.ts b/ui/src/tracks/debug/slice_track.ts
index a871a9f..664840d 100644
--- a/ui/src/tracks/debug/slice_track.ts
+++ b/ui/src/tracks/debug/slice_track.ts
@@ -78,8 +78,8 @@
     globals.dispatch(Actions.selectDebugSlice({
       id: args.slice.id,
       sqlTableName: this.config.sqlTableName,
-      startS: args.slice.startS,
-      durationS: args.slice.durationS,
+      start: args.slice.start,
+      duration: args.slice.duration,
       trackId: this.trackId,
     }));
   }
diff --git a/ui/src/tracks/expected_frames/index.ts b/ui/src/tracks/expected_frames/index.ts
index f2ab086..f7e5121 100644
--- a/ui/src/tracks/expected_frames/index.ts
+++ b/ui/src/tracks/expected_frames/index.ts
@@ -19,8 +19,8 @@
 import {NewTrackArgs, Track} from '../../frontend/track';
 import {ChromeSliceTrack} from '../chrome_slices';
 
-import {NUM, NUM_NULL, STR} from '../../common/query_result';
-import {fromNs, toNs} from '../../common/time';
+import {LONG_NULL, NUM, STR} from '../../common/query_result';
+import {TPDuration, TPTime, fromNs} from '../../common/time';
 import {
   TrackController,
 } from '../../controller/track_controller';
@@ -46,27 +46,25 @@
 
 class ExpectedFramesSliceTrackController extends TrackController<Config, Data> {
   static readonly kind = EXPECTED_FRAMES_SLICE_TRACK_KIND;
-  private maxDurNs = 0;
+  private maxDurNs: TPDuration = 0n;
 
-  async onBoundsChange(start: number, end: number, resolution: number):
+  async onBoundsChange(start: TPTime, end: TPTime, resolution: TPDuration):
       Promise<Data> {
-    const startNs = toNs(start);
-    const endNs = toNs(end);
-
     const pxSize = this.pxSize();
 
     // ns per quantization bucket (i.e. ns per pixel). /2 * 2 is to force it to
     // be an even number, so we can snap in the middle.
-    const bucketNs = Math.max(Math.round(resolution * 1e9 * pxSize / 2) * 2, 1);
+    const bucketNs =
+        Math.max(Math.round(Number(resolution) * pxSize / 2) * 2, 1);
 
-    if (this.maxDurNs === 0) {
+    if (this.maxDurNs === 0n) {
       const maxDurResult = await this.query(`
         select max(iif(dur = -1, (SELECT end_ts FROM trace_bounds) - ts, dur))
           as maxDur
         from experimental_slice_layout
         where filter_track_ids = '${this.config.trackIds.join(',')}'
       `);
-      this.maxDurNs = maxDurResult.firstRow({maxDur: NUM_NULL}).maxDur || 0;
+      this.maxDurNs = maxDurResult.firstRow({maxDur: LONG_NULL}).maxDur || 0n;
     }
 
     const queryRes = await this.query(`
@@ -82,8 +80,8 @@
       from experimental_slice_layout
       where
         filter_track_ids = '${this.config.trackIds.join(',')}' and
-        ts >= ${startNs - this.maxDurNs} and
-        ts <= ${endNs}
+        ts >= ${start - this.maxDurNs} and
+        ts <= ${end}
       group by tsq, layout_depth
       order by tsq, layout_depth
     `);
diff --git a/ui/src/tracks/ftrace/index.ts b/ui/src/tracks/ftrace/index.ts
index e5913fa..8cd40f3 100644
--- a/ui/src/tracks/ftrace/index.ts
+++ b/ui/src/tracks/ftrace/index.ts
@@ -17,7 +17,8 @@
 import {colorForString} from '../../common/colorizer';
 import {PluginContext} from '../../common/plugin_api';
 import {NUM, STR} from '../../common/query_result';
-import {fromNs, toNsCeil, toNsFloor} from '../../common/time';
+import {fromNs, TPDuration} from '../../common/time';
+import {TPTime} from '../../common/time';
 import {TrackData} from '../../common/track_data';
 import {LIMIT} from '../../common/track_data';
 import {
@@ -46,14 +47,8 @@
 class FtraceRawTrackController extends TrackController<Config, Data> {
   static readonly kind = FTRACE_RAW_TRACK_KIND;
 
-  async onBoundsChange(start: number, end: number, resolution: number):
+  async onBoundsChange(start: TPTime, end: TPTime, resolution: TPDuration):
       Promise<Data> {
-    const startNs = toNsFloor(start);
-    const endNs = toNsCeil(end);
-
-    // |resolution| is in s/px the frontend wants.
-    const quantNs = toNsCeil(resolution);
-
     const excludeList = Array.from(globals.state.ftraceFilter.excludedNames);
     const excludeListSql = excludeList.map((s) => `'${s}'`).join(',');
     const cpuFilter =
@@ -61,13 +56,13 @@
 
     const queryRes = await this.query(`
       select
-        cast(ts / ${quantNs} as integer) * ${quantNs} as tsQuant,
+        cast(ts / ${resolution} as integer) * ${resolution} as tsQuant,
         type,
         name
       from ftrace_event
       where
         name not in (${excludeListSql}) and
-        ts >= ${startNs} and ts <= ${endNs} ${cpuFilter}
+        ts >= ${start} and ts <= ${end} ${cpuFilter}
       group by tsQuant
       order by tsQuant limit ${LIMIT};`);
 
@@ -107,16 +102,19 @@
   }
 
   renderCanvas(ctx: CanvasRenderingContext2D): void {
-    const {timeScale, visibleWindowTime} = globals.frontendLocalState;
+    const {
+      visibleTimeScale,
+      windowSpan,
+    } = globals.frontendLocalState;
 
     const data = this.data();
 
     if (data === undefined) return;  // Can't possibly draw anything.
 
-    const dataStartPx = timeScale.timeToPx(data.start);
-    const dataEndPx = timeScale.timeToPx(data.end);
-    const visibleStartPx = timeScale.timeToPx(visibleWindowTime.start);
-    const visibleEndPx = timeScale.timeToPx(visibleWindowTime.end);
+    const dataStartPx = visibleTimeScale.tpTimeToPx(data.start);
+    const dataEndPx = visibleTimeScale.tpTimeToPx(data.end);
+    const visibleStartPx = windowSpan.start;
+    const visibleEndPx = windowSpan.end;
 
     checkerboardExcept(
         ctx,
@@ -137,7 +135,7 @@
         ${Math.min(color.l + 10, 60)}%
       )`;
       ctx.fillStyle = hsl;
-      const xPos = Math.floor(timeScale.timeToPx(data.timestamps[i]));
+      const xPos = Math.floor(visibleTimeScale.secondsToPx(data.timestamps[i]));
 
       // Draw a diamond over the event
       ctx.save();
diff --git a/ui/src/tracks/heap_profile/index.ts b/ui/src/tracks/heap_profile/index.ts
index b3eb40a..3e656f0 100644
--- a/ui/src/tracks/heap_profile/index.ts
+++ b/ui/src/tracks/heap_profile/index.ts
@@ -18,7 +18,13 @@
 import {Actions} from '../../common/actions';
 import {PluginContext} from '../../common/plugin_api';
 import {NUM, STR} from '../../common/query_result';
-import {fromNs, toNs} from '../../common/time';
+import {
+  fromNs,
+  TPDuration,
+  TPTime,
+  tpTimeFromNanos,
+  tpTimeFromSeconds,
+} from '../../common/time';
 import {TrackData} from '../../common/track_data';
 import {profileType} from '../../controller/flamegraph_controller';
 import {
@@ -42,7 +48,7 @@
 
 class HeapProfileTrackController extends TrackController<Config, Data> {
   static readonly kind = HEAP_PROFILE_TRACK_KIND;
-  async onBoundsChange(start: number, end: number, resolution: number):
+  async onBoundsChange(start: TPTime, end: TPTime, resolution: TPDuration):
       Promise<Data> {
     if (this.config.upid === undefined) {
       return {
@@ -111,7 +117,7 @@
 
   renderCanvas(ctx: CanvasRenderingContext2D): void {
     const {
-      timeScale,
+      visibleTimeScale: timeScale,
     } = globals.frontendLocalState;
     const data = this.data();
 
@@ -122,11 +128,12 @@
       const selection = globals.state.currentSelection;
       const isHovered = this.hoveredTs === centerX;
       const isSelected = selection !== null &&
-          selection.kind === 'HEAP_PROFILE' && selection.ts === centerX;
+          selection.kind === 'HEAP_PROFILE' &&
+          selection.ts === tpTimeFromSeconds(centerX);
       const strokeWidth = isSelected ? 3 : 0;
       this.drawMarker(
           ctx,
-          timeScale.timeToPx(fromNs(centerX)),
+          timeScale.secondsToPx(fromNs(centerX)),
           this.centerY,
           isHovered,
           strokeWidth);
@@ -155,8 +162,10 @@
   onMouseMove({x, y}: {x: number, y: number}) {
     const data = this.data();
     if (data === undefined) return;
-    const {timeScale} = globals.frontendLocalState;
-    const time = toNs(timeScale.pxToTime(x));
+    const {
+      visibleTimeScale: timeScale,
+    } = globals.frontendLocalState;
+    const time = timeScale.pxToHpTime(x).nanos;
     const [left, right] = searchSegment(data.tsStarts, time);
     const index = this.findTimestampIndex(left, timeScale, data, x, y, right);
     this.hoveredTs = index === -1 ? undefined : data.tsStarts[index];
@@ -169,15 +178,18 @@
   onMouseClick({x, y}: {x: number, y: number}) {
     const data = this.data();
     if (data === undefined) return false;
-    const {timeScale} = globals.frontendLocalState;
+    const {
+      visibleTimeScale: timeScale,
+    } = globals.frontendLocalState;
 
-    const time = toNs(timeScale.pxToTime(x));
+    const time = timeScale.pxToHpTime(x).nanos;
     const [left, right] = searchSegment(data.tsStarts, time);
 
     const index = this.findTimestampIndex(left, timeScale, data, x, y, right);
 
     if (index !== -1) {
-      const ts = data.tsStarts[index];
+      // TODO(stevegolton): Remove conversion from number to bigint.
+      const ts = tpTimeFromNanos(data.tsStarts[index]);
       const type = data.types[index];
       globals.makeSelection(Actions.selectHeapProfile(
           {id: index, upid: this.config.upid, ts, type}));
@@ -192,13 +204,13 @@
       right: number): number {
     let index = -1;
     if (left !== -1) {
-      const centerX = timeScale.timeToPx(fromNs(data.tsStarts[left]));
+      const centerX = timeScale.secondsToPx(fromNs(data.tsStarts[left]));
       if (this.isInMarker(x, y, centerX)) {
         index = left;
       }
     }
     if (right !== -1) {
-      const centerX = timeScale.timeToPx(fromNs(data.tsStarts[right]));
+      const centerX = timeScale.secondsToPx(fromNs(data.tsStarts[right]));
       if (this.isInMarker(x, y, centerX)) {
         index = right;
       }
diff --git a/ui/src/tracks/perf_samples_profile/index.ts b/ui/src/tracks/perf_samples_profile/index.ts
index cfc73dd..693e295 100644
--- a/ui/src/tracks/perf_samples_profile/index.ts
+++ b/ui/src/tracks/perf_samples_profile/index.ts
@@ -17,7 +17,12 @@
 import {PluginContext} from '../../common/plugin_api';
 import {NUM} from '../../common/query_result';
 import {ProfileType} from '../../common/state';
-import {fromNs, toNs} from '../../common/time';
+import {
+  fromNs,
+  TPDuration,
+  TPTime,
+  tpTimeFromSeconds,
+} from '../../common/time';
 import {TrackData} from '../../common/track_data';
 import {
   TrackController,
@@ -39,7 +44,7 @@
 
 class PerfSamplesProfileTrackController extends TrackController<Config, Data> {
   static readonly kind = PERF_SAMPLES_PROFILE_TRACK_KIND;
-  async onBoundsChange(start: number, end: number, resolution: number):
+  async onBoundsChange(start: TPTime, end: TPTime, resolution: TPDuration):
       Promise<Data> {
     if (this.config.upid === undefined) {
       return {
@@ -99,7 +104,7 @@
 
   renderCanvas(ctx: CanvasRenderingContext2D): void {
     const {
-      timeScale,
+      visibleTimeScale,
     } = globals.frontendLocalState;
     const data = this.data();
 
@@ -115,7 +120,7 @@
       const strokeWidth = isSelected ? 3 : 0;
       this.drawMarker(
           ctx,
-          timeScale.timeToPx(fromNs(centerX)),
+          visibleTimeScale.secondsToPx(fromNs(centerX)),
           this.centerY,
           isHovered,
           strokeWidth);
@@ -144,10 +149,11 @@
   onMouseMove({x, y}: {x: number, y: number}) {
     const data = this.data();
     if (data === undefined) return;
-    const {timeScale} = globals.frontendLocalState;
-    const time = toNs(timeScale.pxToTime(x));
+    const {visibleTimeScale} = globals.frontendLocalState;
+    const time = visibleTimeScale.pxToHpTime(x).nanos;
     const [left, right] = searchSegment(data.tsStartsNs, time);
-    const index = this.findTimestampIndex(left, timeScale, data, x, y, right);
+    const index =
+        this.findTimestampIndex(left, visibleTimeScale, data, x, y, right);
     this.hoveredTs = index === -1 ? undefined : data.tsStartsNs[index];
   }
 
@@ -158,9 +164,11 @@
   onMouseClick({x, y}: {x: number, y: number}) {
     const data = this.data();
     if (data === undefined) return false;
-    const {timeScale} = globals.frontendLocalState;
+    const {
+      visibleTimeScale: timeScale,
+    } = globals.frontendLocalState;
 
-    const time = toNs(timeScale.pxToTime(x));
+    const time = timeScale.pxToHpTime(x).nanos;
     const [left, right] = searchSegment(data.tsStartsNs, time);
 
     const index = this.findTimestampIndex(left, timeScale, data, x, y, right);
@@ -170,8 +178,8 @@
       globals.makeSelection(Actions.selectPerfSamples({
         id: index,
         upid: this.config.upid,
-        leftTs: ts,
-        rightTs: ts,
+        leftTs: tpTimeFromSeconds(ts),
+        rightTs: tpTimeFromSeconds(ts),
         type: ProfileType.PERF_SAMPLE,
       }));
       return true;
@@ -185,13 +193,13 @@
       right: number): number {
     let index = -1;
     if (left !== -1) {
-      const centerX = timeScale.timeToPx(fromNs(data.tsStartsNs[left]));
+      const centerX = timeScale.secondsToPx(fromNs(data.tsStartsNs[left]));
       if (this.isInMarker(x, y, centerX)) {
         index = left;
       }
     }
     if (right !== -1) {
-      const centerX = timeScale.timeToPx(fromNs(data.tsStartsNs[right]));
+      const centerX = timeScale.secondsToPx(fromNs(data.tsStartsNs[right]));
       if (this.isInMarker(x, y, centerX)) {
         index = right;
       }
diff --git a/ui/src/tracks/process_scheduling/index.ts b/ui/src/tracks/process_scheduling/index.ts
index 95302b6..d88bc71 100644
--- a/ui/src/tracks/process_scheduling/index.ts
+++ b/ui/src/tracks/process_scheduling/index.ts
@@ -12,13 +12,20 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {BigintMath} from '../../base/bigint_math';
 import {searchEq, searchRange, searchSegment} from '../../base/binary_search';
 import {assertTrue} from '../../base/logging';
 import {Actions} from '../../common/actions';
 import {colorForThread} from '../../common/colorizer';
 import {PluginContext} from '../../common/plugin_api';
 import {NUM, QueryResult} from '../../common/query_result';
-import {fromNs, toNs} from '../../common/time';
+import {
+  fromNs,
+  TPDuration,
+  TPTime,
+  tpTimeFromSeconds,
+  tpTimeToNanos,
+} from '../../common/time';
 import {TrackData} from '../../common/track_data';
 import {
   TrackController,
@@ -91,22 +98,23 @@
     this.cachedBucketNs = bucketNs;
   }
 
-  async onBoundsChange(start: number, end: number, resolution: number):
+  async onBoundsChange(start: TPTime, end: TPTime, resolution: TPDuration):
       Promise<Data> {
     assertTrue(this.config.upid !== null);
 
     // The resolution should always be a power of two for the logic of this
     // function to make sense.
-    const resolutionNs = toNs(resolution);
-    assertTrue(Math.log2(resolutionNs) % 1 === 0);
+    assertTrue(
+        BigintMath.popcount(resolution) === 1,
+        `${resolution} is not a power of 2`);
 
-    const startNs = toNs(start);
-    const endNs = toNs(end);
+    const startNs = tpTimeToNanos(start);
+    const endNs = tpTimeToNanos(end);
 
     // ns per quantization bucket (i.e. ns per pixel). /2 * 2 is to force it to
     // be an even number, so we can snap in the middle.
     const bucketNs =
-        Math.max(Math.round(resolutionNs * this.pxSize() / 2) * 2, 1);
+        Math.max(Math.round(Number(resolution) * this.pxSize() / 2) * 2, 1);
 
     const queryRes = await this.queryData(startNs, endNs, bucketNs);
     const numRows = queryRes.numRows();
@@ -144,7 +152,8 @@
       slices.ends[row] = fromNs(endNsQ);
       slices.cpus[row] = it.cpu;
       slices.utids[row] = it.utid;
-      slices.end = Math.max(slices.ends[row], slices.end);
+      slices.end =
+          BigintMath.max(tpTimeFromSeconds(slices.ends[row]), slices.end);
     }
     return slices;
   }
@@ -208,7 +217,10 @@
 
   renderCanvas(ctx: CanvasRenderingContext2D): void {
     // TODO: fonts and colors should come from the CSS and not hardcoded here.
-    const {timeScale, visibleWindowTime} = globals.frontendLocalState;
+    const {
+      visibleTimeScale,
+      visibleWindowTime,
+    } = globals.frontendLocalState;
     const data = this.data();
 
     if (data === undefined) return;  // Can't possibly draw anything.
@@ -218,19 +230,20 @@
     checkerboardExcept(
         ctx,
         this.getHeight(),
-        timeScale.timeToPx(visibleWindowTime.start),
-        timeScale.timeToPx(visibleWindowTime.end),
-        timeScale.timeToPx(data.start),
-        timeScale.timeToPx(data.end));
+        visibleTimeScale.hpTimeToPx(visibleWindowTime.start),
+        visibleTimeScale.hpTimeToPx(visibleWindowTime.end),
+        visibleTimeScale.tpTimeToPx(data.start),
+        visibleTimeScale.tpTimeToPx(data.end));
 
     assertTrue(data.starts.length === data.ends.length);
     assertTrue(data.starts.length === data.utids.length);
 
-    const rawStartIdx =
-        data.ends.findIndex((end) => end >= visibleWindowTime.start);
+    const startSeconds = visibleWindowTime.start.seconds;
+    const rawStartIdx = data.ends.findIndex((end) => end >= startSeconds);
     const startIdx = rawStartIdx === -1 ? data.starts.length : rawStartIdx;
 
-    const [, rawEndIdx] = searchSegment(data.starts, visibleWindowTime.end);
+    const [, rawEndIdx] =
+        searchSegment(data.starts, visibleWindowTime.end.seconds);
     const endIdx = rawEndIdx === -1 ? data.starts.length : rawEndIdx;
 
     const cpuTrackHeight = Math.floor(RECT_HEIGHT / data.maxCpu);
@@ -241,8 +254,8 @@
       const utid = data.utids[i];
       const cpu = data.cpus[i];
 
-      const rectStart = timeScale.timeToPx(tStart);
-      const rectEnd = timeScale.timeToPx(tEnd);
+      const rectStart = visibleTimeScale.secondsToPx(tStart);
+      const rectEnd = visibleTimeScale.secondsToPx(tEnd);
       const rectWidth = rectEnd - rectStart;
       if (rectWidth < 0.3) continue;
 
@@ -294,8 +307,8 @@
 
     const cpuTrackHeight = Math.floor(RECT_HEIGHT / data.maxCpu);
     const cpu = Math.floor((pos.y - MARGIN_TOP) / (cpuTrackHeight + 1));
-    const {timeScale} = globals.frontendLocalState;
-    const t = timeScale.pxToTime(pos.x);
+    const {visibleTimeScale} = globals.frontendLocalState;
+    const t = visibleTimeScale.pxToHpTime(pos.x).seconds;
 
     const [i, j] = searchRange(data.starts, t, searchEq(data.cpus, cpu));
     if (i === j || i >= data.starts.length || t > data.ends[i]) {
diff --git a/ui/src/tracks/process_summary/index.ts b/ui/src/tracks/process_summary/index.ts
index d2d0ee8..8b92511 100644
--- a/ui/src/tracks/process_summary/index.ts
+++ b/ui/src/tracks/process_summary/index.ts
@@ -15,7 +15,14 @@
 import {colorForTid} from '../../common/colorizer';
 import {PluginContext} from '../../common/plugin_api';
 import {NUM} from '../../common/query_result';
-import {fromNs, toNs} from '../../common/time';
+import {
+  fromNs,
+  TPDuration,
+  TPTime,
+  tpTimeFromNanos,
+  tpTimeToNanos,
+  tpTimeToSeconds,
+} from '../../common/time';
 import {TrackData} from '../../common/track_data';
 import {LIMIT} from '../../common/track_data';
 import {
@@ -45,10 +52,10 @@
   static readonly kind = PROCESS_SUMMARY_TRACK;
   private setup = false;
 
-  async onBoundsChange(start: number, end: number, resolution: number):
+  async onBoundsChange(start: TPTime, end: TPTime, resolution: TPDuration):
       Promise<Data> {
-    const startNs = toNs(start);
-    const endNs = toNs(end);
+    const startNs = tpTimeToNanos(start);
+    const endNs = tpTimeToNanos(end);
 
     if (this.setup === false) {
       await this.query(
@@ -85,9 +92,9 @@
       this.setup = true;
     }
 
-    // |resolution| is in s/px we want # ns for 10px window:
+    // |resolution| is in ns/px we want # ns for 10px window:
     // Max value with 1 so we don't end up with resolution 0.
-    const bucketSizeNs = Math.max(1, Math.round(resolution * 10 * 1e9));
+    const bucketSizeNs = Math.max(1, Math.round(Number(resolution) * 10));
     const windowStartNs = Math.floor(startNs / bucketSizeNs) * bucketSizeNs;
     const windowDurNs = Math.max(1, endNs - windowStartNs);
 
@@ -98,14 +105,14 @@
       where rowid = 0;`);
 
     return this.computeSummary(
-        fromNs(windowStartNs), end, resolution, bucketSizeNs);
+        tpTimeFromNanos(windowStartNs), end, resolution, bucketSizeNs);
   }
 
   private async computeSummary(
-      start: number, end: number, resolution: number,
+      start: TPTime, end: TPTime, resolution: TPDuration,
       bucketSizeNs: number): Promise<Data> {
-    const startNs = toNs(start);
-    const endNs = toNs(end);
+    const startNs = Number(start);
+    const endNs = Number(end);
     const numBuckets =
         Math.min(Math.ceil((endNs - startNs) / bucketSizeNs), LIMIT);
 
@@ -167,25 +174,28 @@
   }
 
   renderCanvas(ctx: CanvasRenderingContext2D): void {
-    const {timeScale, visibleWindowTime} = globals.frontendLocalState;
+    const {
+      visibleTimeScale,
+      windowSpan,
+    } = globals.frontendLocalState;
     const data = this.data();
     if (data === undefined) return;  // Can't possibly draw anything.
 
     checkerboardExcept(
         ctx,
         this.getHeight(),
-        timeScale.timeToPx(visibleWindowTime.start),
-        timeScale.timeToPx(visibleWindowTime.end),
-        timeScale.timeToPx(data.start),
-        timeScale.timeToPx(data.end));
+        windowSpan.start,
+        windowSpan.end,
+        visibleTimeScale.tpTimeToPx(data.start),
+        visibleTimeScale.tpTimeToPx(data.end));
 
     this.renderSummary(ctx, data);
   }
 
   // TODO(dproy): Dedup with CPU slices.
   renderSummary(ctx: CanvasRenderingContext2D, data: Data): void {
-    const {timeScale, visibleWindowTime} = globals.frontendLocalState;
-    const startPx = Math.floor(timeScale.timeToPx(visibleWindowTime.start));
+    const {visibleTimeScale, windowSpan} = globals.frontendLocalState;
+    const startPx = windowSpan.start;
     const bottomY = TRACK_HEIGHT;
 
     let lastX = startPx;
@@ -202,9 +212,10 @@
     for (let i = 0; i < data.utilizations.length; i++) {
       // TODO(dproy): Investigate why utilization is > 1 sometimes.
       const utilization = Math.min(data.utilizations[i], 1);
-      const startTime = i * data.bucketSizeSeconds + data.start;
+      const startTime =
+          i * data.bucketSizeSeconds + tpTimeToSeconds(data.start);
 
-      lastX = Math.floor(timeScale.timeToPx(startTime));
+      lastX = Math.floor(visibleTimeScale.secondsToPx(startTime));
 
       ctx.lineTo(lastX, lastY);
       lastY = MARGIN_TOP + Math.round(SUMMARY_HEIGHT * (1 - utilization));
diff --git a/ui/src/tracks/scroll_jank/scroll_details_tab.ts b/ui/src/tracks/scroll_jank/scroll_details_tab.ts
index e7c257c..b13fcae 100644
--- a/ui/src/tracks/scroll_jank/scroll_details_tab.ts
+++ b/ui/src/tracks/scroll_jank/scroll_details_tab.ts
@@ -18,13 +18,14 @@
 import m from 'mithril';
 
 import {ColumnType} from '../../common/query_result';
+import {tpDurationFromSql, tpTimeFromSql} from '../../common/time';
 import {
   BottomTab,
   bottomTabRegistry,
   NewBottomTabArgs,
 } from '../../frontend/bottom_tab';
 import {globals} from '../../frontend/globals';
-import {timestampFromSqlNanos} from '../../frontend/sql_types';
+import {asTPTimestamp} from '../../frontend/sql_types';
 import {Duration} from '../../frontend/widgets/duration';
 import {Timestamp} from '../../frontend/widgets/timestamp';
 import {dictToTree} from '../../frontend/widgets/tree';
@@ -63,8 +64,9 @@
 
     const left = dictToTree({
       'Scroll Id (gesture_scroll_id)': `${this.data['id']}`,
-      'Start time': m(Timestamp, {ts: timestampFromSqlNanos(this.data['ts'])}),
-      'Duration': m(Duration, {dur: Number(this.data['dur'])}),
+      'Start time':
+          m(Timestamp, {ts: asTPTimestamp(tpTimeFromSql(this.data['ts']))}),
+      'Duration': m(Duration, {dur: tpDurationFromSql(this.data['dur'])}),
     });
     return m(
         '.details-panel',
diff --git a/ui/src/tracks/scroll_jank/scroll_track.ts b/ui/src/tracks/scroll_jank/scroll_track.ts
index 8706b9d..8532a45 100644
--- a/ui/src/tracks/scroll_jank/scroll_track.ts
+++ b/ui/src/tracks/scroll_jank/scroll_track.ts
@@ -12,6 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {TPTime} from 'src/common/time';
 import {v4 as uuidv4} from 'uuid';
 
 import {Actions} from '../../common/actions';
@@ -39,8 +40,8 @@
   kind: 'TOP_LEVEL_SCROLL';
   id: number;
   sqlTableName: string;
-  startS: number;
-  durationS: number;
+  start: TPTime;
+  duration: TPTime;
 }
 
 export {Data} from '../chrome_slices';
@@ -86,8 +87,8 @@
     globals.dispatch(Actions.selectTopLevelScrollSlice({
       id: args.slice.id,
       sqlTableName: this.tableName,
-      startS: args.slice.startS,
-      durationS: args.slice.durationS,
+      start: args.slice.start,
+      duration: args.slice.duration,
       trackId: this.trackId,
     }));
   }
diff --git a/ui/src/tracks/thread_state/index.ts b/ui/src/tracks/thread_state/index.ts
index 3713999..042a7cf 100644
--- a/ui/src/tracks/thread_state/index.ts
+++ b/ui/src/tracks/thread_state/index.ts
@@ -17,10 +17,18 @@
 import {Actions} from '../../common/actions';
 import {cropText} from '../../common/canvas_utils';
 import {colorForState} from '../../common/colorizer';
+import {
+  HighPrecisionTime,
+  HighPrecisionTimeSpan,
+} from '../../common/high_precision_time';
 import {PluginContext} from '../../common/plugin_api';
-import {NUM, NUM_NULL, STR_NULL} from '../../common/query_result';
+import {LONG, NUM, NUM_NULL, STR_NULL} from '../../common/query_result';
 import {translateState} from '../../common/thread_state';
-import {fromNs, toNs} from '../../common/time';
+import {
+  fromNs,
+  TPDuration,
+  TPTime,
+} from '../../common/time';
 import {TrackData} from '../../common/track_data';
 import {TrackController} from '../../controller/track_controller';
 import {checkerboardExcept} from '../../frontend/checkerboard';
@@ -46,7 +54,7 @@
 class ThreadStateTrackController extends TrackController<Config, Data> {
   static readonly kind = THREAD_STATE_TRACK_KIND;
 
-  private maxDurNs = 0;
+  private maxDurNs: TPDuration = 0n;
 
   async onSetup() {
     await this.query(`
@@ -66,19 +74,15 @@
       select ifnull(max(dur), 0) as maxDur
       from ${this.tableName('thread_state')}
     `);
-    this.maxDurNs = queryRes.firstRow({maxDur: NUM}).maxDur;
+    this.maxDurNs = queryRes.firstRow({maxDur: LONG}).maxDur;
   }
 
-  async onBoundsChange(start: number, end: number, resolution: number):
+  async onBoundsChange(start: TPTime, end: TPTime, resolution: TPDuration):
       Promise<Data> {
-    const resolutionNs = toNs(resolution);
-    const startNs = toNs(start);
-    const endNs = toNs(end);
-
     // ns per quantization bucket (i.e. ns per pixel). /2 * 2 is to force it to
     // be an even number, so we can snap in the middle.
     const bucketNs =
-        Math.max(Math.round(resolutionNs * this.pxSize() / 2) * 2, 1);
+        Math.max(Math.round(Number(resolution) * this.pxSize() / 2) * 2, 1);
 
     const query = `
       select
@@ -92,8 +96,8 @@
         ifnull(id, -1) as id
       from ${this.tableName('thread_state')}
       where
-        ts >= ${startNs - this.maxDurNs} and
-        ts <= ${endNs}
+        ts >= ${start - this.maxDurNs} and
+        ts <= ${end}
       group by tsq, is_sleep
       order by tsq
     `;
@@ -186,7 +190,11 @@
   }
 
   renderCanvas(ctx: CanvasRenderingContext2D): void {
-    const {timeScale, visibleWindowTime} = globals.frontendLocalState;
+    const {
+      visibleTimeScale: timeScale,
+      visibleWindowTime,
+      windowSpan,
+    } = globals.frontendLocalState;
     const data = this.data();
     const charWidth = ctx.measureText('dbpqaouk').width / 8;
 
@@ -199,10 +207,10 @@
     checkerboardExcept(
         ctx,
         this.getHeight(),
-        timeScale.timeToPx(visibleWindowTime.start),
-        timeScale.timeToPx(visibleWindowTime.end),
-        timeScale.timeToPx(data.start),
-        timeScale.timeToPx(data.end),
+        windowSpan.start,
+        windowSpan.end,
+        timeScale.tpTimeToPx(data.start),
+        timeScale.tpTimeToPx(data.end),
     );
 
     ctx.textAlign = 'center';
@@ -220,14 +228,19 @@
       const tStart = data.starts[i];
       const tEnd = data.ends[i];
       const state = data.strings[data.state[i]];
-      if (tEnd <= visibleWindowTime.start || tStart >= visibleWindowTime.end) {
+      const timeSpan = new HighPrecisionTimeSpan(
+          HighPrecisionTime.fromSeconds(tStart),
+          HighPrecisionTime.fromSeconds(tEnd),
+      );
+
+      if (!visibleWindowTime.intersects(timeSpan)) {
         continue;
       }
 
       // Don't display a slice for Task Dead.
       if (state === 'x') continue;
-      const rectStart = timeScale.timeToPx(tStart);
-      const rectEnd = timeScale.timeToPx(tEnd);
+      const rectStart = timeScale.secondsToPx(tStart);
+      const rectEnd = timeScale.secondsToPx(tEnd);
       const rectWidth = rectEnd - rectStart;
 
       const currentSelection = globals.state.currentSelection;
@@ -255,10 +268,9 @@
       if (isSelected) {
         drawRectOnSelected = () => {
           const rectStart =
-              Math.max(0 - EXCESS_WIDTH, timeScale.timeToPx(tStart));
+              Math.max(0 - EXCESS_WIDTH, timeScale.secondsToPx(tStart));
           const rectEnd = Math.min(
-              timeScale.timeToPx(visibleWindowTime.end) + EXCESS_WIDTH,
-              timeScale.timeToPx(tEnd));
+              windowSpan.end + EXCESS_WIDTH, timeScale.secondsToPx(tEnd));
           const color = colorForState(state);
           ctx.strokeStyle = `hsl(${color.h},${color.s}%,${color.l * 0.7}%)`;
           ctx.beginPath();
@@ -278,9 +290,9 @@
   onMouseClick({x}: {x: number}) {
     const data = this.data();
     if (data === undefined) return false;
-    const {timeScale} = globals.frontendLocalState;
-    const time = timeScale.pxToTime(x);
-    const index = search(data.starts, time);
+    const {visibleTimeScale} = globals.frontendLocalState;
+    const time = visibleTimeScale.pxToHpTime(x);
+    const index = search(data.starts, time.nanos);
     if (index === -1) return false;
 
     const id = data.ids[index];