diff --git a/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php b/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php
--- a/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php
+++ b/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php
@@ -21,10 +21,10 @@
   private $cursorSecond;
   private $cursorMinute;
   private $cursorHour;
+  private $cursorHourState;
   private $cursorWeek;
   private $cursorDay;
-  private $cursorDayMonth;
-  private $cursorDayYear;
+  private $cursorDayState;
   private $cursorMonth;
   private $cursorYear;
 
@@ -379,8 +379,15 @@
       $this->minimumEpoch = null;
     }
 
-    $this->cursorDayMonth = $this->cursorMonth;
-    $this->cursorDayYear = $this->cursorYear;
+    $cursor_state = array(
+      'year' => $this->cursorYear,
+      'month' => $this->cursorMonth,
+      'day' => $this->cursorDay,
+      'hour' => $this->cursorHour,
+    );
+
+    $this->cursorDayState = $cursor_state;
+    $this->cursorHourState = $cursor_state;
 
     $by_hour = $this->getByHour();
     $by_minute = $this->getByMinute();
@@ -567,14 +574,14 @@
     while (!$this->setHours) {
       $this->nextDay();
 
-      if ($is_hourly || $by_hour) {
+      $is_dynamic = $is_hourly
+        || $by_hour
+        || ($scale < self::SCALE_HOURLY);
+
+      if ($is_dynamic) {
         $hours = $this->newHoursSet(
           ($is_hourly ? $interval : 1),
           $by_hour);
-      } else if ($scale < self::SCALE_HOURLY) {
-        $hours = $this->newHoursSet(
-          1,
-          array());
       } else {
         $hours = array(
           $this->cursorHour,
@@ -609,14 +616,14 @@
     while (!$this->setDays) {
       $this->nextMonth();
 
-      $is_dyanmic = $is_daily
+      $is_dynamic = $is_daily
         || $by_day
         || $by_monthday
         || $by_yearday
         || $by_weekno
         || ($scale < self::SCALE_DAILY);
 
-      if ($is_dyanmic) {
+      if ($is_dynamic) {
         $weeks = $this->newDaysSet(
           ($is_daily ? $interval : 1),
           ($is_weekly ? $interval : null),
@@ -771,8 +778,15 @@
     // events.
     $hours_in_day = 24;
 
-    if ($this->cursorHour >= $hours_in_day) {
-      $this->cursorHour -= $hours_in_day;
+    // If the hour cursor is behind the current time, we need to forward it in
+    // INTERVAL increments so we end up with the right offset.
+    list($skip, $this->cursorHourState) = $this->advanceCursorState(
+      $this->cursorHourState,
+      self::SCALE_HOURLY,
+      $interval,
+      $this->getWeekStart());
+
+    if ($skip) {
       return array();
     }
 
@@ -796,7 +810,6 @@
     $by_weekno,
     $week_start) {
 
-
     $selection = array();
     if ($interval_week) {
       $year_map = $this->getYearMap($this->stateYear, $week_start);
@@ -827,43 +840,30 @@
       // If the day cursor is behind the current year and month, we need to
       // forward it in INTERVAL increments so we end up with the right offset
       // in the current month.
-      $year_map = $this->getYearMap($this->cursorDayYear, $week_start);
-      while (($this->cursorDayYear < $this->stateYear) ||
-             ($this->cursorDayYear == $this->stateYear &&
-               $this->cursorDayMonth < $this->stateMonth)) {
-        $this->cursorDay += $interval_day;
-        if ($this->cursorDay > $year_map['monthDays'][$this->cursorDayMonth]) {
-          $this->cursorDay -= $year_map['monthDays'][$this->cursorDayMonth];
-          $this->cursorDayMonth++;
-          if ($this->cursorDayMonth > 12) {
-            $this->cursorDayMonth = 1;
-            $this->cursorDayYear++;
-            $year_map = $this->getYearMap($this->cursorDayYear, $week_start);
-          }
-        }
-      }
+      list($skip, $this->cursorDayState) = $this->advanceCursorState(
+        $this->cursorDayState,
+        self::SCALE_DAILY,
+        $interval_day,
+        $week_start);
 
-      while (true) {
-        $month_idx = $this->stateMonth;
-        $month_days = $year_map['monthDays'][$month_idx];
-        if ($this->cursorDay > $month_days) {
-          $this->cursorDay -= $month_days;
-          $this->cursorDayMonth++;
-          if ($this->cursorDayMonth > 12) {
-            $this->cursorDayMonth = 1;
-            $this->cursorDayYear++;
+      if (!$skip) {
+        $year_map = $this->getYearMap($this->stateYear, $week_start);
+        while (true) {
+          $month_idx = $this->stateMonth;
+          $month_days = $year_map['monthDays'][$month_idx];
+          if ($this->cursorDay > $month_days) {
             // NOTE: The year map is now out of date, but we're about to break
             // out of the loop anyway so it doesn't matter.
+            break;
           }
-          break;
-        }
 
-        $day_idx = $this->cursorDay;
+          $day_idx = $this->cursorDay;
 
-        $key = "{$month_idx}M{$day_idx}D";
-        $selection[] = $year_map['info'][$key];
+          $key = "{$month_idx}M{$day_idx}D";
+          $selection[] = $year_map['info'][$key];
 
-        $this->cursorDay += $interval_day;
+          $this->cursorDay += $interval_day;
+        }
       }
     }
 
@@ -1247,4 +1247,117 @@
     $this->cursorMonth += 12;
   }
 
+  private function advanceCursorState(
+    array $cursor,
+    $scale,
+    $interval,
+    $week_start) {
+
+    $state = array(
+      'year' => $this->stateYear,
+      'month' => $this->stateMonth,
+      'day' => $this->stateDay,
+      'hour' => $this->stateHour,
+    );
+
+    // In the common case when the interval is 1, we'll visit every possible
+    // value so we don't need to do any math and can just jump to the first
+    // hour, day, etc.
+    if ($interval == 1) {
+      if ($this->isCursorBehind($cursor, $state, $scale)) {
+        switch ($scale) {
+          case self::SCALE_DAILY:
+            $this->cursorDay = 1;
+            break;
+          case self::SCALE_HOURLY:
+            $this->cursorHour = 0;
+            break;
+        }
+      }
+      return array(false, $state);
+    }
+
+    $year_map = $this->getYearMap($cursor['year'], $week_start);
+    while ($this->isCursorBehind($cursor, $state, $scale)) {
+      switch ($scale) {
+        case self::SCALE_DAILY:
+          $cursor['day'] += $interval;
+          break;
+        case self::SCALE_HOURLY:
+          $cursor['hour'] += $interval;
+          break;
+      }
+
+      if ($scale <= self::SCALE_HOURLY) {
+        while ($cursor['hour'] >= 24) {
+          $cursor['hour'] -= 24;
+          $cursor['day']++;
+        }
+      }
+
+      if ($scale <= self::SCALE_DAILY) {
+        while ($cursor['day'] > $year_map['monthDays'][$cursor['month']]) {
+          $cursor['day'] -= $year_map['monthDays'][$cursor['month']];
+          $cursor['month']++;
+          if ($cursor['month'] > 12) {
+            $cursor['month'] -= 12;
+            $cursor['year']++;
+            $year_map = $this->getYearMap($cursor['year'], $week_start);
+          }
+        }
+      }
+    }
+
+    switch ($scale) {
+      case self::SCALE_DAILY:
+        $this->cursorDay = $cursor['day'];
+        break;
+      case self::SCALE_HOURLY:
+        $this->cursorHour = $cursor['hour'];
+        break;
+    }
+
+    $skip = $this->isCursorBehind($state, $cursor, $scale);
+
+    return array($skip, $cursor);
+  }
+
+  private function isCursorBehind(array $cursor, array $state, $scale) {
+    if ($cursor['year'] < $state['year']) {
+      return true;
+    } else if ($cursor['year'] > $state['year']) {
+      return false;
+    }
+
+    if ($cursor['month'] < $state['month']) {
+      return true;
+    } else if ($cursor['month'] > $state['month']) {
+      return false;
+    }
+
+    if ($scale >= self::SCALE_DAILY) {
+      return false;
+    }
+
+    if ($cursor['day'] < $state['day']) {
+      return true;
+    } else if ($cursor['day'] > $state['day']) {
+      return false;
+    }
+
+    if ($scale >= self::SCALE_HOURLY) {
+      return false;
+    }
+
+    if ($cursor['hour'] < $state['hour']) {
+      return true;
+    } else if ($cursor['hour'] > $state['hour']) {
+      return false;
+    }
+
+    return false;
+  }
+
+
+
 }
diff --git a/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php b/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php
--- a/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php
+++ b/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php
@@ -815,6 +815,54 @@
       $expect);
   }
 
+  public function testHourlyRecurrenceRules() {
+    $tests = array();
+    $expect = array();
+
+    $tests[] = array();
+    $expect[] = array(
+      '19970902T090000Z',
+      '19970902T100000Z',
+      '19970902T110000Z',
+    );
+
+    $tests[] = array(
+      'INTERVAL' => 2,
+    );
+    $expect[] = array(
+      '19970902T090000Z',
+      '19970902T110000Z',
+      '19970902T130000Z',
+    );
+
+    $tests[] = array(
+      'INTERVAL' => 769,
+    );
+    $expect[] = array(
+      '19970902T090000Z',
+      '19971004T100000Z',
+      '19971105T110000Z',
+    );
+
+    $tests[] = array(
+      'BYMONTH' => array(1, 3),
+    );
+    $expect[] = array(
+      '19980101T000000Z',
+      '19980101T010000Z',
+      '19980101T020000Z',
+    );
+
+    $this->assertRules(
+      array(
+        'FREQ' => 'HOURLY',
+        'COUNT' => 3,
+        'DTSTART' => '19970902T090000Z',
+      ),
+      $tests,
+      $expect);
+  }
+
 
   private function assertRules(array $defaults, array $tests, array $expect) {
     foreach ($tests as $key => $test) {