How to build a countdown timer in Flutter
A countdown that survives the two things that break mobile timers: the OS suspending your app and a device clock the user controls. The controller below anchors to the server's clock, re-syncs on resume, and cleans up after itself. It talks to your own backend rather than the API directly, for reasons the first section covers.
Why the app does not hold the key
An API key compiled into a Flutter app is a public API key. Both APK and IPA files are archives; extracting strings from one takes seconds, and no amount of obfuscation changes that. Anyone who pulls the key can create timers on your account until you revoke it.
So the app calls your backend, your backend calls the API. One extra hop, and the key never leaves a server you control.
Your backend endpoint
// Your backend, in whatever language. The app calls THIS.
// Node/Express shown; the shape is the same everywhere.
app.get("/api/countdown/:id", async (req, res) => {
const upstream = await fetch(
`https://countdownshare.com/api/v1/timers/${req.params.id}/status`,
{
headers: { Authorization: `Bearer ${process.env.COUNTDOWNSHARE_API_KEY}` },
cache: "no-store",
},
);
if (!upstream.ok) return res.status(502).json({ error: "unavailable" });
const { data } = await upstream.json();
// Return only what the screen needs.
res.json({
remaining_seconds: data.remaining.total_seconds,
server_time: data.server_time,
ended: data.ended,
});
});The model and client
serverTime is the field that makes this work — without it there is nothing to anchor against and you are back to trusting the device.
lib/countdown/timer_status.dart
// lib/countdown/timer_status.dart
class TimerStatus {
const TimerStatus({
required this.remainingSeconds,
required this.serverTime,
required this.ended,
});
final int remainingSeconds;
final DateTime serverTime;
final bool ended;
factory TimerStatus.fromJson(Map<String, dynamic> json) => TimerStatus(
remainingSeconds: json['remaining_seconds'] as int,
serverTime: DateTime.parse(json['server_time'] as String),
ended: json['ended'] as bool,
);
}lib/countdown/countdown_api.dart
// lib/countdown/countdown_api.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
/// Talks to YOUR backend, never to the API directly.
///
/// An API key shipped in an app binary is a public API key —
/// mobile apps are trivially unpacked. Your server holds the key
/// and exposes only what the screen needs.
class CountdownApi {
CountdownApi({required this.baseUrl, http.Client? client})
: _client = client ?? http.Client();
final String baseUrl;
final http.Client _client;
Future<TimerStatus> status(String timerId) async {
final response = await _client
.get(Uri.parse('$baseUrl/api/countdown/$timerId'))
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw CountdownException('Status ${response.statusCode}');
}
return TimerStatus.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
}
}
class CountdownException implements Exception {
CountdownException(this.message);
final String message;
@override
String toString() => 'CountdownException: $message';
}The controller
A ChangeNotifier so any state management approach can consume it — Provider, Riverpod, or a plain AnimatedBuilder. It mixes in WidgetsBindingObserver to catch the lifecycle events that matter.
lib/countdown/countdown_controller.dart
// lib/countdown/countdown_controller.dart
import 'dart:async';
import 'package:flutter/widgets.dart';
/// Ticks locally, counts against the server.
///
/// The device clock is compared to the server's once, and the
/// offset is subtracted on every tick — so a phone set an hour
/// forward still shows the correct remaining time.
class CountdownController extends ChangeNotifier with WidgetsBindingObserver {
CountdownController({required this.api, required this.timerId}) {
WidgetsBinding.instance.addObserver(this);
_load();
}
final CountdownApi api;
final String timerId;
Duration _remaining = Duration.zero;
Duration _skew = Duration.zero;
DateTime? _endsAt;
Timer? _ticker;
bool _loading = true;
Duration get remaining => _remaining;
bool get loading => _loading;
bool get ended => !_loading && _remaining == Duration.zero;
Future<void> _load() async {
try {
final status = await api.status(timerId);
// Measure the gap between this device's clock and ours.
_skew = DateTime.now().difference(status.serverTime);
_endsAt = DateTime.now()
.subtract(_skew)
.add(Duration(seconds: status.remainingSeconds));
_loading = false;
_tick();
_ticker?.cancel();
_ticker = Timer.periodic(const Duration(seconds: 1), (_) => _tick());
} catch (_) {
_loading = false;
notifyListeners();
}
}
void _tick() {
final endsAt = _endsAt;
if (endsAt == null) return;
final now = DateTime.now().subtract(_skew);
final left = endsAt.difference(now);
_remaining = left.isNegative ? Duration.zero : left;
if (_remaining == Duration.zero) _ticker?.cancel();
notifyListeners();
}
/// A suspended app resumes with a stale value. Re-read rather
/// than trusting a timer that was frozen by the OS.
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) _load();
}
@override
void dispose() {
_ticker?.cancel();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
}The three things it gets right
_skew is measured once from server_time and subtracted on every tick, so a wrong device clock cannot shift the countdown. _endsAt is a fixed timestamp rather than a decremented counter, so a late or dropped tick skips a number instead of accumulating error. And didChangeAppLifecycleState re-reads on resume, because both iOS and Android freeze timers in the background — a five-minute suspension leaves the display five minutes wrong until something corrects it.
dispose() cancelling the ticker and removing the observer is not optional. ATimer.periodic that outlives its widget keeps firing and calling notifyListeners on a disposed notifier, which throws in debug and leaks in release.The widget
lib/countdown/countdown_text.dart
// lib/countdown/countdown_text.dart
import 'package:flutter/material.dart';
class CountdownText extends StatelessWidget {
const CountdownText({super.key, required this.controller});
final CountdownController controller;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: controller,
builder: (context, _) {
if (controller.loading) {
return const Text('--:--:--');
}
if (controller.ended) {
return const Text('This offer has closed');
}
final d = controller.remaining;
final hours = d.inHours;
final minutes = d.inMinutes.remainder(60);
final seconds = d.inSeconds.remainder(60);
return Semantics(
liveRegion: true,
label: '$hours hours $minutes minutes remaining',
child: Text(
'${hours.toString().padLeft(2, '0')}:'
'${minutes.toString().padLeft(2, '0')}:'
'${seconds.toString().padLeft(2, '0')}',
style: Theme.of(context).textTheme.headlineMedium,
),
);
},
);
}
}Semantics with liveRegion: true lets VoiceOver and TalkBack announce changes. Note the label uses hours and minutes rather than the full second-by-second value — announcing every second makes a screen reader unusable.
Acting on the deadline
Everything above is display. When the countdown reaches zero the app knows and your backend does not — and a user who never opens the app is not counted at all.
Attach a webhook rule so your server is called at the deadline regardless of whether anyone is looking. And when the deadline gates something real — a price, an unlock, a booking — verify it server-side at the moment of the action rather than trusting what the app submitted.
Common questions
Can I call the API directly from Flutter?
You should not. Anything compiled into an app binary is extractable — string-dumping an APK or IPA takes seconds. Route the call through your own backend, which holds the key and returns only the fields the screen needs.
Why does my countdown jump after the app is backgrounded?
Both iOS and Android suspend timers when an app leaves the foreground. Timer.periodic simply stops firing, so the displayed value is stale on resume. Observing didChangeAppLifecycleState and re-reading on resume is the fix — the code above does this.
Why anchor to server time rather than the device clock?
Because the device clock is user-settable and frequently wrong. Measuring the offset once and subtracting it on every tick means a phone set an hour forward still shows the correct remaining time — which matters the moment the countdown gates a discount.
Do I need a package for this?
Only http, and even that is optional if you use dart:io HttpClient. There is no CountdownShare package on pub.dev — the client here is short enough to own directly, which also means one fewer dependency to keep current.
Next steps
Point it at a real timer
Sandbox is free with any account. Create a timer, expose one proxy route, and the controller works unchanged.