diff --git a/garnet/lib/ui/gfx/BUILD.gn b/garnet/lib/ui/gfx/BUILD.gn
index b3a13bde76e42a2d9768722c59e882f19dab1dbf..978e572f2f37a7b7fd6e350e0d05090a19374c68 100644
--- a/garnet/lib/ui/gfx/BUILD.gn
+++ b/garnet/lib/ui/gfx/BUILD.gn
@@ -254,9 +254,13 @@ source_set("display") {
 source_set("frame_scheduler") {
   sources = [
     # TODO(SCN-1398): Move files out of engine/.
+    "engine/duration_predictor.cc",
+    "engine/duration_predictor.h",
     "engine/default_frame_scheduler.cc",
     "engine/default_frame_scheduler.h",
     "engine/frame_scheduler.h",
+    "engine/frame_predictor.cc",
+    "engine/frame_predictor.h",
     "engine/frame_timings.cc",
     "engine/frame_timings.h",
 
diff --git a/garnet/lib/ui/gfx/engine/default_frame_scheduler.cc b/garnet/lib/ui/gfx/engine/default_frame_scheduler.cc
index b7d20558a6e7f869fd159ed9155575e16da18fec..7b083e570beb4a15a00c2896ab671559126d88ff 100644
--- a/garnet/lib/ui/gfx/engine/default_frame_scheduler.cc
+++ b/garnet/lib/ui/gfx/engine/default_frame_scheduler.cc
@@ -199,8 +199,9 @@ void DefaultFrameScheduler::MaybeRenderFrame(async_dispatcher_t*,
   // calls until this point are applied to the next Scenic frame.
   delegate_.session_updater->RatchetPresentCallbacks();
 
-  auto frame_timings =
-      fxl::MakeRefCounted<FrameTimings>(this, frame_number_, presentation_time);
+  const zx_time_t frame_render_start_time = async_now(dispatcher_);
+  auto frame_timings = fxl::MakeRefCounted<FrameTimings>(
+      this, frame_number_, presentation_time, frame_render_start_time);
   inspect_frame_number_.Set(frame_number_);
 
   // Render the frame.
diff --git a/garnet/lib/ui/gfx/engine/duration_predictor.cc b/garnet/lib/ui/gfx/engine/duration_predictor.cc
new file mode 100644
index 0000000000000000000000000000000000000000..c474d527c81193ba90853aff5b430b8171d4041f
--- /dev/null
+++ b/garnet/lib/ui/gfx/engine/duration_predictor.cc
@@ -0,0 +1,45 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "garnet/lib/ui/gfx/engine/duration_predictor.h"
+
+#include <src/lib/fxl/logging.h>
+
+namespace scenic_impl {
+namespace gfx {
+
+DurationPredictor::DurationPredictor(size_t optimism_window_size,
+                    zx::duration initial_prediction)
+    : kWindowSize(optimism_window_size),
+      window_(kWindowSize, initial_prediction) {
+  FXL_DCHECK(kWindowSize > 0);
+  current_minimum_duration_index_ = kWindowSize - 1;
+}
+
+zx::duration DurationPredictor::GetPrediction() const {
+  return window_[current_minimum_duration_index_];
+}
+
+void DurationPredictor::InsertNewMeasurement(zx::duration duration) {
+    // Move window forward.
+    window_.push_front(duration);
+    window_.pop_back();
+    ++current_minimum_duration_index_;
+
+    if (current_minimum_duration_index_ >= kWindowSize) {
+      // If old min went out of scope, find the new min.
+      current_minimum_duration_index_ = 0;
+      for (size_t i = 1; i < kWindowSize; ++i) {
+        if (window_[i] < window_[current_minimum_duration_index_]) {
+          current_minimum_duration_index_ = i;
+        }
+      }
+    } else if (window_.front() <= window_[current_minimum_duration_index_]) {
+      // Use newest possible minimum.
+      current_minimum_duration_index_ = 0;
+    }
+}
+
+}  // namespace gfx
+}  // namespace scenic_impl
diff --git a/garnet/lib/ui/gfx/engine/duration_predictor.h b/garnet/lib/ui/gfx/engine/duration_predictor.h
new file mode 100644
index 0000000000000000000000000000000000000000..e6111fcd5d4a705336d1defca776b276346f0055
--- /dev/null
+++ b/garnet/lib/ui/gfx/engine/duration_predictor.h
@@ -0,0 +1,44 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#ifndef GARNET_LIB_UI_GFX_ENGINE_DURATION_PREDICTOR_H_
+#define GARNET_LIB_UI_GFX_ENGINE_DURATION_PREDICTOR_H_
+
+#include <lib/zx/time.h>
+
+#include <deque>
+
+namespace scenic_impl {
+namespace gfx {
+
+// Class for predicting future durations based on previous measurements. Uses an
+// optimistic approach that determines the "most optimistic duration" based on
+// the last N measurements, where N is a range of values set by the client.
+//
+// TODO(SCN-1415) When Scenic has priority gpu vk queues, revisit this
+// prediction strategy. Scenic currently cannot report accurate GPU duration
+// measurements because it currently has no way to preempt work on the GPU.
+// This causes render durations to be very noisy and not representative of the
+// work Scenic is doing.
+class DurationPredictor {
+ public:
+  DurationPredictor(size_t optimism_window_size,
+                    zx::duration initial_prediction);
+  ~DurationPredictor() = default;
+
+  zx::duration GetPrediction() const;
+
+  void InsertNewMeasurement(zx::duration duration);
+
+ private:
+  const size_t kWindowSize;
+  std::deque<zx::duration> window_;  // Ring buffer.
+
+  size_t current_minimum_duration_index_ = 0;
+};
+
+}  // namespace gfx
+}  // namespace scenic_impl
+
+#endif  // GARNET_LIB_UI_GFX_ENGINE_DURATION_PREDICTOR_H_
diff --git a/garnet/lib/ui/gfx/engine/frame_predictor.cc b/garnet/lib/ui/gfx/engine/frame_predictor.cc
new file mode 100644
index 0000000000000000000000000000000000000000..dcbc100ffb00044eef12b650857d988497dc341e
--- /dev/null
+++ b/garnet/lib/ui/gfx/engine/frame_predictor.cc
@@ -0,0 +1,102 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "garnet/lib/ui/gfx/engine/frame_predictor.h"
+
+#include <src/lib/fxl/logging.h>
+#include <trace/event.h>
+
+#include <algorithm>
+
+namespace scenic_impl {
+namespace gfx {
+
+FramePredictor::FramePredictor(zx::duration initial_render_duration_prediction,
+                               zx::duration initial_update_duration_prediction)
+    : render_duration_predictor_(kRenderPredictionWindowSize,
+                                 initial_render_duration_prediction),
+      update_duration_predictor_(kUpdatePredictionWindowSize,
+                                 initial_update_duration_prediction) {}
+
+void FramePredictor::ReportRenderDuration(zx::duration time_to_render) {
+  FXL_DCHECK(time_to_render >= zx::duration(0));
+  render_duration_predictor_.InsertNewMeasurement(time_to_render);
+}
+
+void FramePredictor::ReportUpdateDuration(zx::duration time_to_update) {
+  FXL_DCHECK(time_to_update >= zx::duration(0));
+  update_duration_predictor_.InsertNewMeasurement(time_to_update);
+}
+
+zx::duration FramePredictor::PredictTotalRequiredDuration() const {
+  const zx::duration predicted_time_to_update =
+      update_duration_predictor_.GetPrediction();
+  const zx::duration predicted_time_to_render =
+      render_duration_predictor_.GetPrediction();
+
+  const zx::duration predicted_frame_duration =
+      predicted_time_to_update + predicted_time_to_render + kHardcodedMargin;
+
+  TRACE_INSTANT("gfx", "FramePredictor::PredictRequiredFrameRenderTime",
+                TRACE_SCOPE_THREAD, "Predicted frame duration",
+                predicted_frame_duration.get());
+
+  return predicted_frame_duration;
+}
+
+// static
+zx::time FramePredictor::ComputeNextSyncTime(zx::time last_sync_time,
+                                              zx::duration sync_interval,
+                                              zx::time min_sync_time) {
+  // If the last sync time is greater than or equal to the minimum acceptable
+  // sync time, just return the last sync.
+  // Note: in practice, these numbers will likely differ. The "equal to"
+  // comparison is necessary for tests, which have much tighter control on time.
+  if (last_sync_time >= min_sync_time) {
+    return last_sync_time;
+  }
+
+  const int64_t num_intervals = (min_sync_time - last_sync_time) / sync_interval;
+  return last_sync_time + (sync_interval * (num_intervals + 1));
+}
+
+PredictedTimes FramePredictor::GetPrediction(PredictionRequest request) const {
+#if SCENIC_IGNORE_VSYNC
+  // Predict that the frame should be rendered immediately.
+  return {.presentation_time = request.now, .latch_point_time = request.now};
+#endif
+
+  const zx::duration required_frame_duration = PredictTotalRequiredDuration();
+
+  // Calculate minimum time this would sync to. It is last vsync time plus half
+  // a vsync-interval (to allow for jitter for the VSYNC signal), or the current
+  // time plus the expected render time, whichever is larger, so we know we have
+  // enough time to render for that sync.
+  zx::time min_sync_time =
+      std::max((request.last_vsync_time + (request.vsync_interval / 2)),
+               (request.now + required_frame_duration));
+  const zx::time target_vsync_time = ComputeNextSyncTime(
+      request.last_vsync_time, request.vsync_interval, min_sync_time);
+
+  // Ensure the requested presentation time is current.
+  zx::time target_presentation_time =
+      request.requested_presentation_time < request.now
+          ? request.now
+          : request.requested_presentation_time;
+  // Compute the next presentation time from the target vsync time (inclusive),
+  // that is at least the current requested present time.
+  target_presentation_time = ComputeNextSyncTime(
+      target_vsync_time, request.vsync_interval, target_presentation_time);
+
+  // Find time the client should latch and start rendering in order to
+  // frame in time for the target present.
+  zx::time latch_point = target_presentation_time - required_frame_duration;
+
+  return {.presentation_time = target_presentation_time,
+          .latch_point_time = latch_point};
+
+}
+
+}  // namespace gfx
+}  // namespace scenic_impl
diff --git a/garnet/lib/ui/gfx/engine/frame_predictor.h b/garnet/lib/ui/gfx/engine/frame_predictor.h
new file mode 100644
index 0000000000000000000000000000000000000000..cbbb6e840a84b7348a603b4e371a141ac7781e4a
--- /dev/null
+++ b/garnet/lib/ui/gfx/engine/frame_predictor.h
@@ -0,0 +1,91 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#ifndef GARNET_LIB_UI_GFX_ENGINE_FRAME_PREDICTOR_H_
+#define GARNET_LIB_UI_GFX_ENGINE_FRAME_PREDICTOR_H_
+
+#include <lib/zx/time.h>
+
+#include <vector>
+
+#include "garnet/lib/ui/gfx/engine/duration_predictor.h"
+
+namespace scenic_impl {
+namespace gfx {
+
+struct PredictedTimes {
+  // The point at which a client should begin an update and render a frame,
+  // so that it is done by the |presentation_time|.
+  zx::time latch_point_time;
+  // The predicted presentation time. This corresponds to a future VSYNC.
+  zx::time presentation_time;
+};
+
+struct PredictionRequest {
+  zx::time now;
+  // The minimum presentation time a client would like to hit.
+  zx::time requested_presentation_time;
+  zx::time last_vsync_time;
+  zx::duration vsync_interval;
+};
+
+// Predicts viable presentation times and corresponding latch-points for a
+// frame, based on previously reported update and render durations.
+class FramePredictor {
+ public:
+  FramePredictor(zx::duration initial_render_duration_prediction,
+                 zx::duration initial_update_duration_prediction);
+  ~FramePredictor() = default;
+
+  // Computes the target presentation time for
+  // |request.requested_presentation_time|, and a latch-point that is early
+  // enough to apply one update and render a frame, in order to hit the
+  // predicted presentation time.
+  //
+  // Both |PredictedTimes.latch_point_time| and |PredictedTimes.presentation_time|
+  // are guaranteed to be after |request.now|.
+  // |PredictedTimes.presentation_time| is guaranteed to be later than or equal
+  // to |request.requested_presentation_time|.
+  PredictedTimes GetPrediction(PredictionRequest request) const;
+
+  // Used by the client to report a measured render duration. The render
+  // duration is the CPU + GPU time it takes to build and render a frame. This
+  // will be considered in subsequent calls to |GetPrediction|.
+  void ReportRenderDuration(zx::duration time_to_render);
+
+  // Used by the client to report a measured update duration. The update
+  // duration is the time it takes to apply a batch of updates. This will be
+  // considered in subsequent calls to |GetPrediction|.
+  void ReportUpdateDuration(zx::duration time_to_update);
+
+ private:
+  // Returns the next time to synchronize to.
+  // |last_sync_time| The last known good sync time.
+  // |sync_interval| The expected time between syncs.
+  // |min_sync_time| The minimum time allowed to return.
+  static zx::time ComputeNextSyncTime(zx::time last_sync_time,
+                                       zx::duration sync_interval,
+                                       zx::time min_sync_time);
+  // Returns a prediction for how long in total the next frame will take to
+  // update and render.
+  zx::duration PredictTotalRequiredDuration() const;
+
+  // Safety margin added to prediction time to reduce impact of noise and
+  // misprediction. Unfortunately means minimum possible latency is increased
+  // by the same amount.
+  const zx::duration kHardcodedMargin = zx::usec(500);  // 0.5ms
+
+  // Render time prediction.
+  const size_t kRenderPredictionWindowSize = 3;
+  DurationPredictor render_duration_predictor_;
+
+  // Update time prediction.
+  const size_t kUpdatePredictionWindowSize = 1;
+  DurationPredictor update_duration_predictor_;
+};
+
+}  // namespace gfx
+}  // namespace scenic_impl
+
+#endif  // GARNET_LIB_UI_GFX_ENGINE_FRAME_PREDICTOR_H_
diff --git a/garnet/lib/ui/gfx/engine/frame_timings.cc b/garnet/lib/ui/gfx/engine/frame_timings.cc
index 5aefce662ff580775351ee2abcb5385084daa1d9..49cffbad61aaad02a5dd27b4c11068b224338a9d 100644
--- a/garnet/lib/ui/gfx/engine/frame_timings.cc
+++ b/garnet/lib/ui/gfx/engine/frame_timings.cc
@@ -9,14 +9,14 @@
 namespace scenic_impl {
 namespace gfx {
 
-FrameTimings::FrameTimings() : FrameTimings(nullptr, 0, 0) {}
-
 FrameTimings::FrameTimings(FrameScheduler* frame_scheduler,
                            uint64_t frame_number,
-                           zx_time_t target_presentation_time)
+                           zx_time_t target_presentation_time,
+                           zx_time_t rendering_started_time)
     : frame_scheduler_(frame_scheduler),
       frame_number_(frame_number),
-      target_presentation_time_(target_presentation_time) {}
+      target_presentation_time_(target_presentation_time),
+      rendering_started_time_(rendering_started_time) {}
 
 size_t FrameTimings::AddSwapchain(Swapchain* swapchain) {
   // All swapchains that we are timing must be added before any of them finish.
diff --git a/garnet/lib/ui/gfx/engine/frame_timings.h b/garnet/lib/ui/gfx/engine/frame_timings.h
index d425cd601bd59286a0a244451dc4417b363f2a55..3180c0a0eced5548b11ae1630fa90ba4fa1fb6a1 100644
--- a/garnet/lib/ui/gfx/engine/frame_timings.h
+++ b/garnet/lib/ui/gfx/engine/frame_timings.h
@@ -6,6 +6,7 @@
 #define GARNET_LIB_UI_GFX_ENGINE_FRAME_TIMINGS_H_
 
 #include <lib/zx/time.h>
+
 #include <vector>
 
 #include "src/ui/lib/escher/base/reffable.h"
@@ -24,9 +25,9 @@ using FrameTimingsPtr = fxl::RefPtr<FrameTimings>;
 // FrameScheduler is notified via OnFramePresented().
 class FrameTimings : public escher::Reffable {
  public:
-  FrameTimings();
   FrameTimings(FrameScheduler* frame_scheduler, uint64_t frame_number,
-               zx_time_t target_presentation_time);
+               zx_time_t target_presentation_time,
+               zx_time_t rendering_started_time);
 
   // Add a swapchain that is used as a render target this frame.  Return an
   // index that can be used to indicate when rendering for that swapchain is
@@ -61,6 +62,8 @@ class FrameTimings : public escher::Reffable {
     return actual_presentation_time_;
   }
 
+  zx_time_t rendering_started_time() const { return rendering_started_time_; }
+
   zx_time_t rendering_finished_time() const { return rendering_finished_time_; }
 
  private:
@@ -85,6 +88,7 @@ class FrameTimings : public escher::Reffable {
   FrameScheduler* const frame_scheduler_;
   const uint64_t frame_number_;
   const zx_time_t target_presentation_time_;
+  const zx_time_t rendering_started_time_;
   zx_time_t actual_presentation_time_ = 0;
   zx_time_t rendering_finished_time_ = 0;
   size_t frame_rendered_count_ = 0;
diff --git a/garnet/lib/ui/gfx/tests/BUILD.gn b/garnet/lib/ui/gfx/tests/BUILD.gn
index 5f2dc5c59f9a737520c0f7fbc1a86975fc6d229d..1ba496754cf8b5f0ad016597dbe9717af2550353 100644
--- a/garnet/lib/ui/gfx/tests/BUILD.gn
+++ b/garnet/lib/ui/gfx/tests/BUILD.gn
@@ -76,8 +76,10 @@ executable("unittests") {
   sources = [
     "compositor_unittest.cc",
     "default_frame_scheduler_unittest.cc",
+    "duration_predictor_unittest.cc",
     "escher_vulkan_smoke_test.cc",
     "event_timestamper_unittest.cc",
+    "frame_predictor_unittest.cc",
     "frame_timings_unittest.cc",
     "gfx_command_applier_unittest.cc",
     "hardware_layer_assignment_unittest.cc",
diff --git a/garnet/lib/ui/gfx/tests/duration_predictor_unittest.cc b/garnet/lib/ui/gfx/tests/duration_predictor_unittest.cc
new file mode 100644
index 0000000000000000000000000000000000000000..0f71602e16812395ef4d4edb240d44025f417527
--- /dev/null
+++ b/garnet/lib/ui/gfx/tests/duration_predictor_unittest.cc
@@ -0,0 +1,90 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "garnet/lib/ui/gfx/engine/duration_predictor.h"
+
+#include <lib/gtest/test_loop_fixture.h>
+
+namespace scenic_impl {
+namespace gfx {
+namespace test {
+
+TEST(DurationPredictor, FirstPredictionIsInitialPrediction) {
+  const size_t kWindowSize = 4;
+  const zx::duration kInitialPrediction = zx::usec(500);
+  DurationPredictor predictor(kWindowSize, kInitialPrediction);
+  EXPECT_EQ(predictor.GetPrediction(), kInitialPrediction);
+}
+
+TEST(DurationPredictor, PredictionAfterWindowFlushIsMeasurement) {
+  const size_t kWindowSize = 4;
+  const zx::duration kInitialPrediction = zx::msec(1);
+  DurationPredictor predictor(kWindowSize, kInitialPrediction);
+
+  const zx::duration measurement = zx::msec(5);
+  EXPECT_GT(measurement, kInitialPrediction);
+  predictor.InsertNewMeasurement(measurement);
+  EXPECT_EQ(predictor.GetPrediction(), kInitialPrediction);
+
+  for (size_t i = 0; i < kWindowSize - 1; ++i) {
+    predictor.InsertNewMeasurement(measurement);
+  }
+  EXPECT_EQ(predictor.GetPrediction(), measurement);
+}
+
+TEST(DurationPredictor, PredictionIsSmallestInWindowAsMeasurementsIncrease) {
+  size_t window_size = 10;
+  DurationPredictor predictor(window_size, /* initial prediction */ zx::usec(0));
+
+  for (size_t i = 1; i <= window_size; ++i) {
+    predictor.InsertNewMeasurement(zx::msec(i));
+  }
+  EXPECT_EQ(predictor.GetPrediction(), zx::msec(1));
+}
+
+TEST(DurationPredictor, PredictionIsSmallestInWindowAsMeasurementsDecrease) {
+  size_t window_size = 10;
+  DurationPredictor predictor(window_size, /* initial prediction */ zx::usec(0));
+
+  for (size_t i = window_size; i > 0; --i) {
+    predictor.InsertNewMeasurement(zx::msec(i));
+  }
+  EXPECT_EQ(predictor.GetPrediction(), zx::msec(1));
+}
+
+TEST(DurationPredictor, PredictionIsSmallestInWindow) {
+  size_t window_size = 10;
+  DurationPredictor predictor(window_size, /* initial prediction */ zx::usec(0));
+
+  const std::vector<zx_duration_t> measurements{12, 4, 5, 2, 8, 55, 13, 6, 8, 9};
+  for (size_t i = 0; i < measurements.size(); ++i) {
+    predictor.InsertNewMeasurement(zx::msec(measurements[i]));
+  }
+  EXPECT_EQ(predictor.GetPrediction(), zx::msec(2));
+}
+
+TEST(DurationPredictor, MinIsResetWhenSmallestIsOutOfWindow) {
+  size_t window_size = 4;
+  DurationPredictor predictor(window_size, /* initial prediction */ zx::usec(0));
+
+  const std::vector<zx_duration_t> measurements{12, 4, 5, 2, 8, 55, 13, 6, 8, 9};
+  for (size_t i = 0; i < measurements.size(); ++i) {
+    predictor.InsertNewMeasurement(zx::msec(measurements[i]));
+  }
+  EXPECT_EQ(predictor.GetPrediction(), zx::msec(6));
+}
+
+TEST(DurationPredictor, WindowSizeOfOneWorks) {
+  size_t window_size = 1;
+  DurationPredictor predictor(window_size, /* initial prediction */ zx::usec(0));
+
+  for (size_t i = 0; i < 5; ++i) {
+    predictor.InsertNewMeasurement(zx::msec(i));
+  }
+  EXPECT_EQ(predictor.GetPrediction(), zx::msec(4));
+}
+
+}  // namespace test
+}  // namespace gfx
+}  // namespace scenic_impl
diff --git a/garnet/lib/ui/gfx/tests/frame_predictor_unittest.cc b/garnet/lib/ui/gfx/tests/frame_predictor_unittest.cc
new file mode 100644
index 0000000000000000000000000000000000000000..6ca6a6bfb3a90ff0bfd52fde6a2ff6dd1ba330cc
--- /dev/null
+++ b/garnet/lib/ui/gfx/tests/frame_predictor_unittest.cc
@@ -0,0 +1,227 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "garnet/lib/ui/gfx/engine/frame_predictor.h"
+
+#include <lib/gtest/test_loop_fixture.h>
+
+#include "garnet/lib/ui/gfx/tests/error_reporting_test.h"
+
+namespace scenic_impl {
+namespace gfx {
+namespace test {
+
+class FramePredictorTest : public ErrorReportingTest {
+ protected:
+  // | ::testing::Test |
+  void SetUp() override {
+    predictor_ = std::make_unique<FramePredictor>(kInitialRenderTimePrediction,
+                                                  kInitialUpdateTimePrediction);
+  }
+  // | ::testing::Test |
+  void TearDown() override { predictor_.reset(); }
+
+  zx::time ms_to_time(uint64_t ms) {
+    return zx::time(0) + zx::msec(ms);
+  }
+
+  static constexpr zx::duration kInitialRenderTimePrediction =
+      zx::msec(4);
+  static constexpr zx::duration kInitialUpdateTimePrediction =
+      zx::msec(2);
+
+  std::unique_ptr<FramePredictor> predictor_;
+};
+
+TEST_F(FramePredictorTest, BasicPredictions_ShouldBeReasonable) {
+  PredictionRequest request = {
+      .now = ms_to_time(5),
+      .requested_presentation_time = ms_to_time(10),
+      .last_vsync_time = ms_to_time(0),
+      .vsync_interval = zx::msec(10)};
+
+  auto prediction = predictor_->GetPrediction(request);
+
+  EXPECT_GT(prediction.presentation_time, request.now);
+  EXPECT_GE(prediction.latch_point_time, request.now);
+  EXPECT_LT(prediction.latch_point_time, prediction.presentation_time);
+}
+
+TEST_F(FramePredictorTest, PredictionsAfterUpdating_ShouldBeMoreReasonable) {
+  const zx::duration update_duration = zx::msec(2);
+  const zx::duration render_duration = zx::msec(5);
+
+  const size_t kBiggerThanAllPredictionWindows = 5;
+  for (size_t i = 0; i < kBiggerThanAllPredictionWindows; ++i) {
+    predictor_->ReportRenderDuration(render_duration);
+    predictor_->ReportUpdateDuration(update_duration);
+  }
+
+  PredictionRequest request = {.now = ms_to_time(5),
+                               .requested_presentation_time = ms_to_time(0),
+                               .last_vsync_time = ms_to_time(0),
+                               .vsync_interval = zx::msec(10)};
+
+  auto prediction = predictor_->GetPrediction(request);
+
+  EXPECT_GT(prediction.presentation_time, request.now);
+  EXPECT_GE(prediction.latch_point_time, request.now);
+
+  EXPECT_GE(prediction.presentation_time - prediction.latch_point_time,
+            update_duration + render_duration);
+}
+
+TEST_F(FramePredictorTest,
+       OneExpensiveTime_ShouldNotPredictForFutureVsyncIntervals) {
+  const zx::duration update_duration = zx::msec(4);
+  const zx::duration render_duration = zx::msec(10);
+  const zx::duration vsync_interval = zx::msec(10);
+
+  predictor_->ReportRenderDuration(render_duration);
+  predictor_->ReportUpdateDuration(update_duration);
+
+  PredictionRequest request = {.now = ms_to_time(0),
+                               .requested_presentation_time = ms_to_time(0),
+                               .last_vsync_time = ms_to_time(0),
+                               .vsync_interval = vsync_interval};
+  auto prediction = predictor_->GetPrediction(request);
+
+  EXPECT_GE(prediction.latch_point_time, request.now);
+  EXPECT_LE(prediction.presentation_time,
+            request.last_vsync_time + request.vsync_interval);
+}
+
+TEST_F(FramePredictorTest,
+       ManyExpensiveTimes_ShouldPredictForFutureVsyncIntervals) {
+  const zx::duration update_duration = zx::msec(4);
+  const zx::duration render_duration = zx::msec(10);
+  const zx::duration vsync_interval = zx::msec(10);
+
+  for (size_t i = 0; i < 10; i++) {
+    predictor_->ReportRenderDuration(render_duration);
+    predictor_->ReportUpdateDuration(update_duration);
+  }
+
+  PredictionRequest request = {.now = ms_to_time(3),
+                               .requested_presentation_time = ms_to_time(0),
+                               .last_vsync_time = ms_to_time(0),
+                               .vsync_interval = vsync_interval};
+  auto prediction = predictor_->GetPrediction(request);
+
+  EXPECT_GE(prediction.latch_point_time, request.now);
+  EXPECT_GE(prediction.presentation_time,
+            request.last_vsync_time + request.vsync_interval);
+  EXPECT_LE(prediction.presentation_time,
+            request.last_vsync_time + request.vsync_interval * 2);
+  EXPECT_LE(prediction.latch_point_time,
+            prediction.presentation_time - request.vsync_interval);
+}
+
+TEST_F(FramePredictorTest, ManyFramesOfPredictions_ShouldBeReasonable) {
+  const zx::duration vsync_interval = zx::msec(10);
+
+  zx::time now = ms_to_time(0);
+  zx::time requested_present = ms_to_time(8);
+  zx::time last_vsync_time = ms_to_time(0);
+  for (uint64_t i = 0; i < 50; ++i) {
+    zx::duration update_duration = zx::msec(i % 5);
+    zx::duration render_duration = zx::msec(5);
+    predictor_->ReportUpdateDuration(update_duration);
+    predictor_->ReportRenderDuration(render_duration);
+    EXPECT_GE(vsync_interval, update_duration + render_duration);
+
+    PredictionRequest request = {.now = now,
+                               .requested_presentation_time = requested_present,
+                               .last_vsync_time = last_vsync_time,
+                               .vsync_interval = vsync_interval};
+    auto prediction = predictor_->GetPrediction(request);
+
+    EXPECT_GE(prediction.latch_point_time, request.now);
+    EXPECT_GE(prediction.presentation_time, requested_present);
+    EXPECT_LE(prediction.presentation_time,
+              requested_present + vsync_interval * 2);
+
+    // For the next frame, increase time to be after the predicted present to
+    // emulate a client that is regularly scheduling frames.
+    now = prediction.presentation_time + zx::msec(1);
+    requested_present = prediction.presentation_time + vsync_interval;
+    last_vsync_time = prediction.presentation_time;
+  }
+}
+
+TEST_F(FramePredictorTest, MissedLastVsync_ShouldPredictWithInterval) {
+  const zx::duration update_duration = zx::msec(4);
+  const zx::duration render_duration = zx::msec(5);
+  predictor_->ReportRenderDuration(render_duration);
+  predictor_->ReportUpdateDuration(update_duration);
+
+  const zx::duration vsync_interval = zx::msec(16);
+  zx::time last_vsync_time = ms_to_time(16);
+  // Make now be more than a vsync_interval beyond the last_vsync_time
+  zx::time now = last_vsync_time + (vsync_interval * 2) + zx::msec(3);
+  zx::time requested_present = now + zx::msec(9);
+  PredictionRequest request = {.now = now,
+                               .requested_presentation_time = requested_present,
+                               .last_vsync_time = last_vsync_time,
+                               .vsync_interval = vsync_interval};
+  auto prediction = predictor_->GetPrediction(request);
+
+  // The predicted presentation and wakeup times should be greater than one
+  // vsync interval since the last reported vsync time.
+  EXPECT_GE(prediction.presentation_time, last_vsync_time + vsync_interval);
+  EXPECT_LE(prediction.presentation_time, now + (request.vsync_interval * 2));
+  EXPECT_LE(prediction.presentation_time - prediction.latch_point_time,
+            vsync_interval);
+}
+
+TEST_F(FramePredictorTest, MissedPresentRequest_ShouldTargetNextVsync) {
+  const zx::duration update_duration = zx::msec(2);
+  const zx::duration render_duration = zx::msec(4);
+  predictor_->ReportRenderDuration(render_duration);
+  predictor_->ReportUpdateDuration(update_duration);
+
+  const zx::duration vsync_interval = zx::msec(10);
+  zx::time last_vsync_time = ms_to_time(10);
+  zx::time now = ms_to_time(12);
+  // Request a present time in the past.
+  zx::time requested_present = now - zx::msec(1);
+  PredictionRequest request = {.now = now,
+                               .requested_presentation_time = requested_present,
+                               .last_vsync_time = last_vsync_time,
+                               .vsync_interval = vsync_interval};
+  auto prediction = predictor_->GetPrediction(request);
+
+  EXPECT_GE(prediction.presentation_time, last_vsync_time + vsync_interval);
+  EXPECT_LE(prediction.presentation_time, last_vsync_time + (vsync_interval * 2));
+  EXPECT_GE(prediction.latch_point_time,
+            prediction.presentation_time - vsync_interval);
+}
+
+TEST_F(FramePredictorTest, AttemptsToBeLowLatent_ShouldBePossible) {
+  const zx::duration update_duration = zx::msec(1);
+  const zx::duration render_duration = zx::msec(3);
+  predictor_->ReportRenderDuration(render_duration);
+  predictor_->ReportUpdateDuration(update_duration);
+
+  const zx::duration vsync_interval = zx::msec(10);
+  zx::time last_vsync_time = ms_to_time(10);
+  zx::time requested_present = last_vsync_time + vsync_interval;
+  zx::time now =
+      requested_present - update_duration - render_duration - zx::msec(1);
+  EXPECT_GT(now, last_vsync_time);
+
+  PredictionRequest request = {.now = now,
+                               .requested_presentation_time = requested_present,
+                               .last_vsync_time = last_vsync_time,
+                               .vsync_interval = vsync_interval};
+  auto prediction = predictor_->GetPrediction(request);
+
+  // The prediction should be for the next vsync.
+  EXPECT_LE(prediction.presentation_time, last_vsync_time + vsync_interval);
+  EXPECT_GE(prediction.latch_point_time, now);
+}
+
+}  // namespace test
+}  // namespace gfx
+}  // namespace scenic_impl
diff --git a/garnet/lib/ui/gfx/tests/frame_timings_unittest.cc b/garnet/lib/ui/gfx/tests/frame_timings_unittest.cc
index 717ff5d030e4673cbd5c0f905f8fb22b32d70cc5..4086a8f1573971516b421a03b93d496bfacafcda 100644
--- a/garnet/lib/ui/gfx/tests/frame_timings_unittest.cc
+++ b/garnet/lib/ui/gfx/tests/frame_timings_unittest.cc
@@ -2,9 +2,9 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-#include "garnet/lib/ui/gfx/tests/error_reporting_test.h"
-
 #include "garnet/lib/ui/gfx/engine/frame_timings.h"
+
+#include "garnet/lib/ui/gfx/tests/error_reporting_test.h"
 #include "garnet/lib/ui/gfx/tests/frame_scheduler_mocks.h"
 
 namespace scenic_impl {
@@ -19,7 +19,8 @@ class FrameTimingsTest : public ErrorReportingTest {
     frame_timings_ =
         fxl::MakeRefCounted<FrameTimings>(frame_scheduler_.get(),
                                           /* frame number */ 1,
-                                          /* target presentation time*/ 1);
+                                          /* target presentation time*/ 1,
+                                          /* render started time */ 0);
     frame_timings_->AddSwapchain(nullptr);
   }
   void TearDown() override { frame_scheduler_.reset(); }