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.
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 codeRecursive 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 ✅ NoneIf 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
doneAlarm: 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 Second2. 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/triggered3. 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 MicrosecondSelection guide:
Countdown, Pomodoro, Alarm: setInterval — second precision sufficient
Stopwatch: Date.now() delta — guarantees millisecond precision
Performance benchmarking: performance.now() — microsecond precision
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
51CTO HarmonyOS Developer Community
The HarmonyOS Developer Community is a learning-oriented community for developers to learn, communicate, ask questions, and share.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
