HarmonyOS Timer Deep Dive: 4 Real-World Scenarios with Production Code

This article implements four timer scenarios — countdown, stopwatch, Pomodoro, and alarm — in HarmonyOS ArkUI, explaining design choices like setInterval vs Date.now() for precision, state machines for phase transitions, and lifecycle cleanup to avoid memory leaks.

51CTO HarmonyOS Developer Community
51CTO HarmonyOS Developer Community
51CTO HarmonyOS Developer Community
HarmonyOS Timer Deep Dive: 4 Real-World Scenarios with Production Code

Why Timers Are More Complex Than They Seem

Timers are a basic capability in every app. Countdown, stopwatch, Pomodoro, and alarm all deal with time, but each requires fundamentally different timing logic:

Countdown : decrements from a set value (60 → 0)

Stopwatch : increments from zero (0 → ∞)

Pomodoro : two alternating countdowns that switch automatically (focus → break → done)

Alarm : absolute time comparison (current time === set time)

Many developers simply use setInterval(() => {}, 1000) and move on. But when product requirements arrive — animation at zero, millisecond precision, automatic phase switching, background triggering — the pitfalls of setInterval surface: event-loop latency, cumulative drift, lifecycle management, and state-machine design.

Countdown: setInterval + Ring Progress

1. Scenario

Set a duration (e.g., 5 minutes), decrement each second, alert at zero. Core APIs: setInterval and clearInterval, plus a ring Progress component showing remaining proportion.

2. Technical Choice: Why setInterval Over Recursive setTimeout?

Approach          Pros                              Cons
setInterval       Concise; one start, one stop      Possible callback pile-up
setTimeout recursion Theoretically more precise; no pile-up risk  Requires recursive management; more complex code

Recursive setTimeout schedules the next callback inside each callback. But countdown only needs second-level precision; setInterval(1000) is sufficient and simpler — one setInterval to start, one clearInterval to stop, no recursion management.

Key point: setInterval 's delay is an expected value, not a guaranteed one. In the JavaScript event loop, long-running main-thread tasks delay timer callbacks. For second-level precision this drift is negligible; the stopwatch scenario demands higher precision and therefore uses Date.now() delta compensation.

3. Complete Implementation: Countdown with Ring Progress

// File: entry/src/main/ets/pages/CountdownDemo.ets
import { promptAction } from '@kit.ArkUI';

@ComponentV2
export struct CountdownDemo {
  // ========== State ==========
  @Local remainingSeconds: number = 0;
  @Local totalSeconds: number = 60;
  @Local isRunning: boolean = false;
  @Local isFinished: boolean = false;
  @Local selectedMinutes: number = 1;
  private intervalId: number = -1;

  // ========== Computed ==========
  get progress(): number {
    return this.totalSeconds > 0 ? this.remainingSeconds / this.totalSeconds : 0;
  }

  get timeString(): string {
    const min = Math.floor(this.remainingSeconds / 60);
    const sec = this.remainingSeconds % 60;
    return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
  }

  // ========== Business Methods ==========
  startCountdown(): void {
    if (this.isRunning) return;
    this.totalSeconds = this.selectedMinutes * 60;
    this.remainingSeconds = this.totalSeconds;
    this.isRunning = true;
    this.isFinished = false;

    this.intervalId = setInterval(() => {
      if (this.remainingSeconds > 0) {
        this.remainingSeconds--;
        if (this.remainingSeconds === 10) {
          // vibrator.vibrate(500);
        }
      } else {
        this.stopCountdown();
        this.isFinished = true;
        promptAction.showToast({ message: '⏰ Time is up!' });
        // vibrator.vibrate(1000);
      }
    }, 1000);
  }

  stopCountdown(): void {
    if (this.intervalId !== -1) {
      clearInterval(this.intervalId);
      this.intervalId = -1;
    }
    this.isRunning = false;
  }

  resetCountdown(): void {
    this.stopCountdown();
    this.remainingSeconds = 0;
    this.totalSeconds = this.selectedMinutes * 60;
    this.isFinished = false;
  }

  // ========== Lifecycle ==========
  aboutToDisappear(): void {
    if (this.intervalId !== -1) {
      clearInterval(this.intervalId);
      this.intervalId = -1;
    }
  }

  // ========== UI ==========
  build() {
    Column({ space: 24 }) {
      Text('⏱️ Countdown')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1A1A2E')
        .margin({ top: 20 })

      Stack() {
        Progress({
          value: this.progress * 100,
          total: 100,
          type: ProgressType.Ring
        })
          .width(220)
          .height(220)
          .color(this.isFinished ? '#FF4444' : '#007DFF')
          .backgroundColor('#F0F4F8')
          .style({ strokeWidth: 14 })
          .animation({ duration: 300, curve: Curve.EaseOut })

        if (this.isFinished) {
          Text('⏰ Time is up!')
            .fontSize(22)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FF4444')
            .textAlign(TextAlign.Center)
        } else {
          Column() {
            Text(this.timeString)
              .fontSize(48)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1A1A2E')
              .fontFamily('monospace')

            Text(this.isRunning ? 'Counting down...' : 'Ready')
              .fontSize(14)
              .fontColor('#888888')
              .margin({ top: 4 })
          }
          .alignItems(HorizontalAlign.Center)
        }
      }

      if (!this.isRunning && !this.isFinished) {
        Column({ space: 12 }) {
          Text('Select duration')
            .fontSize(15)
            .fontColor('#666666')

          Row({ space: 10 }) {
            ForEach([1, 3, 5, 10, 15], (min: number) => {
              Text(`${min} min`)
                .fontSize(14)
                .fontWeight(this.selectedMinutes === min ? FontWeight.Bold : FontWeight.Normal)
                .fontColor(this.selectedMinutes === min ? Color.White : '#666666')
                .backgroundColor(this.selectedMinutes === min ? '#007DFF' : '#F5F7FA')
                .borderRadius(20)
                .padding({ left: 16, right: 16, top: 8, bottom: 8 })
                .onClick(() => {
                  if (!this.isRunning) {
                    this.selectedMinutes = min;
                    this.totalSeconds = min * 60;
                    this.remainingSeconds = this.totalSeconds;
                  }
                })
            }, (min: number) => `${min}`)
          }
          .width('100%')
          .alignItems(HorizontalAlign.Center)
        }
      }

      Divider().width('80%').margin({ top: 8, bottom: 8 })

      if (!this.isRunning && !this.isFinished) {
        Button('Start Countdown')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(Color.White)
          .backgroundColor('#007DFF')
          .borderRadius(24)
          .height(48)
          .width('80%')
          .onClick(() => this.startCountdown())
      } else if (this.isRunning) {
        Row({ space: 12 }) {
          Button('Pause')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FF9800')
            .backgroundColor('#FFF3E0')
            .borderRadius(24)
            .height(48)
            .layoutWeight(1)
            .onClick(() => this.stopCountdown())

          Button('Cancel')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#666666')
            .backgroundColor('#F0F0F0')
            .borderRadius(24)
            .height(48)
            .layoutWeight(1)
            .onClick(() => this.resetCountdown())
        }
        .width('80%')
      } else {
        Button('Restart')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(Color.White)
          .backgroundColor('#007DFF')
          .borderRadius(24)
          .height(48)
          .width('80%')
          .onClick(() => this.resetCountdown())
      }

      Text(this.isFinished ? '✅ Completed' :
           this.isRunning ? `Remaining ${this.remainingSeconds}s` :
           this.remainingSeconds > 0 ? '⏸️ Paused' : '💡 Select duration to start')
        .fontSize(13)
        .fontColor('#888888')
        .margin({ bottom: 16 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor(Color.White)
    .alignItems(HorizontalAlign.Center)
  }
}

4. Key Design Decisions

(1) Dual-state design: remainingSeconds and totalSeconds remainingSeconds is the current remaining seconds (dynamic), totalSeconds is the fixed total for this countdown. Together they compute progress remainingSeconds / totalSeconds. This lets the progress bar remain correct after pause/resume.

(2) Pause vs Cancel semantics

Pause : preserves current progress, can resume

Cancel : resets to initial state, progress zeroed

Code separates stopCountdown() (pause) and resetCountdown() (cancel+reset).

(3) Ring progress animation .animation({ duration: 300, curve: Curve.EaseOut }) gives a 300 ms smooth transition, more natural than a hard cut.

(4) Timer cleanup timing

Cleaning up in aboutToDisappear() is critical. If the component is destroyed while the timer runs, it causes "destroyed component still modifying state" errors.

(5) Edge cases

Vibration at 10 seconds remaining (commented, enable as needed)

Auto-stop and toast on completion

Stopwatch: Date.now() Delta + Millisecond Display

1. Scenario

Stopwatch differs from countdown: it accumulates from zero and requires millisecond precision.

Using setInterval(30) to tick every 30 ms seems straightforward, but actual intervals aren't exactly 30 ms — event-loop latency and UI rendering overhead cause cumulative drift.

2. Technical Choice: Why Date.now() Delta Over Per-Frame Increment?

Approach          Implementation          Precision   Cumulative Drift
Per-frame +30 ms  elapsedMs += 30         Low         ❌ Yes
Date.now() delta  Date.now() - baseTime   High        ✅ None

If you do this.elapsedMs += 30 and the real interval is 35 ms, after 10 seconds the error is (35-30)/30 * 10000 ≈ 1667 ms — nearly 2 seconds off.

With Date.now() delta, record baseTime = Date.now() at start, then each frame compute elapsedMs = Date.now() - baseTime. Precision depends on the system clock, not setInterval intervals.

3. Complete Implementation: Stopwatch with Lap Recording

// File: entry/src/main/ets/pages/StopwatchDemo.ets
@ComponentV2
export struct StopwatchDemo {
  @Local elapsedMs: number = 0;
  @Local isRunning: boolean = false;
  @Local laps: string[] = [];
  private intervalId: number = -1;
  private baseTime: number = 0;

  get timeString(): string {
    const min = Math.floor(this.elapsedMs / 60000);
    const sec = Math.floor((this.elapsedMs % 60000) / 1000);
    const ms = Math.floor((this.elapsedMs % 1000) / 10);
    return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}.${ms.toString().padStart(2, '0')}`;
  }

  get displayTime(): string {
    const min = Math.floor(this.elapsedMs / 60000);
    const sec = Math.floor((this.elapsedMs % 60000) / 1000);
    const ms = Math.floor((this.elapsedMs % 1000) / 10);
    return `${min}:${sec.toString().padStart(2, '0')}`;
  }

  get displayMs(): string {
    return `.${Math.floor((this.elapsedMs % 1000) / 10).toString().padStart(2, '0')}`;
  }

  start(): void {
    if (this.isRunning) return;
    this.isRunning = true;
    this.baseTime = Date.now() - this.elapsedMs;
    this.intervalId = setInterval(() => {
      this.elapsedMs = Date.now() - this.baseTime;
    }, 30); // ~30 ms refresh ≈ 33 fps, smooth enough
  }

  pause(): void {
    if (this.intervalId !== -1) {
      clearInterval(this.intervalId);
      this.intervalId = -1;
    }
    this.isRunning = false;
  }

  lap(): void {
    if (!this.isRunning) return;
    this.laps = [this.timeString, ...this.laps];
  }

  reset(): void {
    this.pause();
    this.elapsedMs = 0;
    this.laps = [];
  }

  aboutToDisappear(): void {
    if (this.intervalId !== -1) {
      clearInterval(this.intervalId);
      this.intervalId = -1;
    }
  }

  build() { /* UI omitted for brevity */ }
}

4. Key Design Decisions

(1) baseTime calculation this.baseTime = Date.now() - this.elapsedMs; Critical for pause/resume: when resuming, elapsedMs already holds the accumulated time, so baseTime is shifted back by that amount, making Date.now() - baseTime continue correctly.

(2) Refresh interval choice setInterval(30) ≈ 33 fps. For a stopwatch, 30 fps is smooth while avoiding excessive CPU. Milliseconds display only two digits (10 ms precision), and 30 ms refresh covers that.

(3) Lap list insertion this.laps = [this.timeString, ...this.laps] prepends to the array, putting the latest lap on top — matches user reading habit.

(4) Lap anti-mistouch .enabled(this.elapsedMs > 100) disables the lap button until at least 100 ms have elapsed; sub-100 ms laps are meaningless.

(5) Pause state preservation

On pause, elapsedMs and baseTime are retained. On resume, baseTime is recalculated for seamless continuation.

Pomodoro: State Machine + Dual Countdown Auto-Switch

1. Scenario

Pomodoro adds automatic switching : after 25 minutes focus, automatically enter 5 minutes break; after break, prompt for next round. Two countdowns chained automatically require a state machine to manage "idle → focus → break → done" phases.

2. Technical Choice: Why State Machine for Phase Transitions?

Without a state machine, each setInterval callback would need a nest of if/else to decide whether we're in focus or break and what to do next. With a state machine ( phase variable), logic becomes clear:

idle (idle)
  ↓ click "Start Focus"
work (focus)
  ↓ countdown ends → auto-switch
break (break)
  ↓ countdown ends → auto-switch
done (done)
  ↓ click "Next Round"
idle (idle)

Each state does its own job; switching only changes phase.

3. Complete Implementation: Pomodoro with Round Tracking

// File: entry/src/main/ets/pages/PomodoroDemo.ets
import { promptAction } from '@kit.ArkUI';

@ComponentV2
export struct PomodoroDemo {
  @Local phase: string = 'idle'; // idle | work | break | done
  @Local remainingSeconds: number = 25 * 60;
  @Local totalSeconds: number = 25 * 60;
  @Local completedRounds: number = 0;
  @Local workDuration: number = 25;
  @Local breakDuration: number = 5;
  private intervalId: number = -1;

  get phaseLabel(): string {
    switch (this.phase) {
      case 'idle': return 'Ready';
      case 'work': return '🎯 Focus';
      case 'break': return '☕ Break';
      case 'done': return '✅ Done';
      default: return '';
    }
  }

  get phaseColor(): string {
    switch (this.phase) {
      case 'idle': return '#007DFF';
      case 'work': return '#FF4444';
      case 'break': return '#43e97b';
      case 'done': return '#43e97b';
      default: return '#007DFF';
    }
  }

  get progress(): number {
    return this.totalSeconds > 0 ? this.remainingSeconds / this.totalSeconds : 0;
  }

  get timeString(): string {
    const min = Math.floor(this.remainingSeconds / 60);
    const sec = this.remainingSeconds % 60;
    return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
  }

  private startPhase(isWork: boolean): void {
    if (this.intervalId !== -1) {
      clearInterval(this.intervalId);
      this.intervalId = -1;
    }
    const duration = isWork ? this.workDuration : this.breakDuration;
    this.totalSeconds = duration * 60;
    this.remainingSeconds = this.totalSeconds;
    this.phase = isWork ? 'work' : 'break';

    this.intervalId = setInterval(() => {
      if (this.remainingSeconds > 0) {
        this.remainingSeconds--;
      } else {
        clearInterval(this.intervalId);
        this.intervalId = -1;
        if (this.phase === 'work') {
          this.completedRounds++;
          promptAction.showToast({ message: `🎉 Focus done! Break ${this.breakDuration} min` });
          this.startPhase(false);
        } else if (this.phase === 'break') {
          this.phase = 'done';
          promptAction.showToast({ message: '☕ Break over, ready for next round!' });
        }
      }
    }, 1000);
  }

  startWork(): void {
    if (this.phase === 'work' || this.phase === 'break') return;
    if (this.phase === 'done') this.completedRounds = 0;
    this.startPhase(true);
  }

  stopTimer(): void {
    if (this.intervalId !== -1) {
      clearInterval(this.intervalId);
      this.intervalId = -1;
    }
    this.phase = 'idle';
    this.remainingSeconds = this.workDuration * 60;
    this.totalSeconds = this.workDuration * 60;
  }

  aboutToDisappear(): void {
    if (this.intervalId !== -1) {
      clearInterval(this.intervalId);
      this.intervalId = -1;
    }
  }

  build() { /* UI omitted for brevity */ }
}

4. Key Design Decisions

(1) Four-state machine

State   Meaning                 Allowed Action
idle    Waiting to start        Start Focus
work    Focus (25 min)          Stop
break   Break (5 min)           Stop
done    Round completed         Next Round

(2) Auto-switch trigger

When focus countdown ends, call startPhase(false) to switch to break. When break ends, set phase = 'done'. Zero user interaction required.

(3) Round indicator

Four dots show completed rounds (mod 4). completedRounds % 4 computes position.

(4) Time-setting bounds

Focus: 1–60 minutes

Break: 1–30 minutes

Increment/decrement buttons enforce bounds

(5) Stop vs Done distinction

Stop: user taps stop → returns to idle Done: countdown finishes naturally → enters

done

Alarm: Absolute Time Comparison + setInterval Polling

1. Scenario

Alarm differs from countdown: countdown is "N seconds from now", alarm is "at a specific clock time". User sets "7:00 AM", not "8 hours from now". Detection logic is not a decrementing counter but polling each second to see if current hour and minute match the alarm .

2. Technical Choice: Why Absolute Time Comparison Over Countdown?

With a countdown you'd compute 7:00 - now to get seconds, then start a countdown. Problems:

App killed in background → countdown lost

Phone reboot → countdown lost

User changes system time → countdown desyncs

Absolute comparison is more robust : each second read new Date().getHours() and getMinutes() and compare with alarm settings. Regardless of what the app went through, if current time matches, it fires.

3. Complete Implementation: Alarm List with Toggle & Delete

// File: entry/src/main/ets/pages/AlarmDemo.ets
import { promptAction } from '@kit.ArkUI';

class AlarmItem {
  id: string;
  hour: number;
  minute: number;
  label: string;
  enabled: boolean;
  triggered: boolean;

  constructor(id: string, hour: number, minute: number, label: string) {
    this.id = id;
    this.hour = hour;
    this.minute = minute;
    this.label = label;
    this.enabled = true;
    this.triggered = false;
  }
}

@ComponentV2
export struct AlarmDemo {
  @Local currentHour: number = 0;
  @Local currentMinute: number = 0;
  @Local currentSecond: number = 0;
  @Local alarms: AlarmItem[] = [];
  @Local showAddDialog: boolean = false;
  @Local newHour: number = 8;
  @Local newMinute: number = 0;
  @Local newLabel: string = '';
  private clockId: number = -1;

  aboutToAppear(): void {
    this.alarms = [
      new AlarmItem('1', 7, 0, '🌅 Wake up'),
      new AlarmItem('2', 8, 30, '📋 Morning meeting'),
      new AlarmItem('3', 12, 0, '🍱 Lunch reminder'),
      new AlarmItem('4', 18, 0, '🏃 Workout'),
    ];
    this.updateClock();
    this.clockId = setInterval(() => {
      this.updateClock();
    }, 1000);
  }

  aboutToDisappear(): void {
    if (this.clockId !== -1) {
      clearInterval(this.clockId);
      this.clockId = -1;
    }
  }

  private updateClock(): void {
    const now: Date = new Date();
    this.currentHour = now.getHours();
    this.currentMinute = now.getMinutes();
    this.currentSecond = now.getSeconds();

    for (const alarm of this.alarms) {
      if (alarm.enabled &&
          !alarm.triggered &&
          alarm.hour === this.currentHour &&
          alarm.minute === this.currentMinute &&
          this.currentSecond === 0) {
        alarm.triggered = true;
        promptAction.showToast({ message: `⏰ Alarm: ${alarm.label}` });
        // vibrator.vibrate(1000);
      }
    }
  }

  private toggleAlarm(alarm: AlarmItem): void {
    alarm.enabled = !alarm.enabled;
    if (alarm.enabled) alarm.triggered = false;
  }

  private deleteAlarm(alarm: AlarmItem): void {
    this.alarms = this.alarms.filter((a: AlarmItem) => a.id !== alarm.id);
  }

  private addAlarm(): void {
    if (this.newLabel.trim().length === 0) {
      promptAction.showToast({ message: 'Please enter alarm name' });
      return;
    }
    const id = Date.now().toString();
    this.alarms.push(new AlarmItem(id, this.newHour, this.newMinute, this.newLabel.trim()));
    this.showAddDialog = false;
    this.newLabel = '';
    promptAction.showToast({ message: 'Alarm added' });
  }

  build() { /* UI omitted for brevity */ }
}

4. Key Design Decisions

(1) currentSecond === 0 prevents duplicate firing

Without this, the alarm would trigger every second from 7:00:00 to 7:00:59 — 60 times.

(2) triggered flag

Set alarm.triggered = true after firing to prevent repeats within the same minute. Reset to false when re-enabled so it can fire again next day.

(3) Toggle switch animation

Uses translate({ x: alarm.enabled ? 12 : -12 }) + .animation() for a sliding effect, more polished than a simple color flip.

(4) Real-time clock update setInterval(1000) updates currentHour, currentMinute, currentSecond each second, making the UI clock tick live.

(5) Default time for new alarm newHour = (now.getHours() + 1) % 24 defaults to the next hour, reducing user adjustment.

Summary: Timer Knowledge Map

1. Three Timing Modes Compared

Mode        Implementation                Use Case    Precision
Decrement   remainingSeconds--            Countdown   Second
Increment   Date.now() - baseTime         Stopwatch   Millisecond
Match       Current time === set time     Alarm       Second

2. Four Scenarios Compared

Scenario    Direction   Precision   Special Mechanism   State Management
Countdown   Decrement   Second      Ring progress       Simple (run/pause/done)
Stopwatch   Increment   Millisecond Lap recording       Simple (run/pause/reset)
Pomodoro    Decrement   Second      Auto-switch         State machine (4 states)
Alarm       Match       Second      Exact-minute trigger Enabled/disabled/triggered

3. Timer Lifecycle Management

Page created (aboutToAppear)
  ↓
Start timer (setInterval)
  ↓
Timer callback fires (every N ms)
  ↓
Page destroyed (aboutToDisappear)
  ↓
Cleanup timer (clearInterval) ← MUST run!

Timer cleanup is the key to preventing memory leaks. If the component is destroyed while the timer runs, it causes "destroyed component still modifying state" errors.

4. Precision Comparison

Method              Drift Source          Suitable Precision
setInterval         Event-loop latency    Second
setTimeout recursion Event-loop latency   Second
Date.now() delta    System clock          Millisecond
performance.now()   System clock          Microsecond

Selection guide:

Countdown, Pomodoro, Alarm: setInterval — second precision sufficient

Stopwatch: Date.now() delta — guarantees millisecond precision

Performance benchmarking: performance.now() — microsecond precision

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

HarmonyOSState MachineprecisionArkUITimersetIntervalStopwatchPomodoroCountdownAlarmDate.nowETS
51CTO HarmonyOS Developer Community
Written by

51CTO HarmonyOS Developer Community

The HarmonyOS Developer Community is a learning-oriented community for developers to learn, communicate, ask questions, and share.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.