Introduction
A countdown timer looks trivial until you hit Streamlit’s core constraint: the script only runs when something happens. A timer needs to tick with no user input. This tutorial solves that properly with st.empty placeholders and automatic refresh — and the solution is the foundation for any real-time Streamlit app, from the typing test to live dashboards.
By the end you’ll have a timer with presets, a big live display, pause/resume, and a completion celebration — about 90 lines total.
Features
- Presets — 5, 10, 25 (Pomodoro), and 60 minutes via buttons.
- Live display — MM:SS updates every second without clicks.
- Pause / resume — freezes the deadline, not the display.
- Progress ring — visual fraction of time remaining.
- Completion state — success message when time runs out.
Prerequisites
- Python 3.8+ — from python.org.
- Dependencies:
pip install streamlit streamlit-autorefresh
Step 1: Create the Script
Save as countdown.py:
import streamlit as st
import time
from datetime import datetime, timedelta
from streamlit_autorefresh import st_autorefresh
st.set_page_config(page_title="Countdown Timer", page_icon="⏲️")
st.title("⏲️ Countdown Timer")
# Tick every second while a timer is running
if st.session_state.get("deadline") and not st.session_state.get("paused"):
st_autorefresh(interval=1000, key="tick")
preset_cols = st.columns(4)
for mins, label in [(5, "5 min"), (10, "10 min"), (25, "Pomodoro"), (60, "1 hour")]:
if preset_cols[list((5, 10, 25, 60)).index(mins)].button(label):
st.session_state.deadline = datetime.now() + timedelta(minutes=mins)
st.session_state.duration = mins * 60
st.session_state.paused = False
st.rerun()
if "deadline" in st.session_state:
remaining = (st.session_state.deadline - datetime.now()).total_seconds()
if st.session_state.get("paused"):
remaining = st.session_state["paused_remaining"]
if remaining <= 0:
st.success("⏰ Time's up!")
st.balloons()
if st.button("Start another"):
del st.session_state["deadline"]
st.rerun()
else:
mins, secs = divmod(int(remaining), 60)
placeholder = st.empty()
placeholder.metric("Time remaining", f"{mins:02d}:{secs:02d}")
st.progress(1 - remaining / st.session_state.duration)
c1, c2 = st.columns(2)
if c1.button("⏸ Pause"):
st.session_state.paused = True
st.session_state.paused_remaining = remaining
st.rerun()
if c2.button("▶️ Resume"):
st.session_state.deadline = datetime.now() + timedelta(seconds=remaining)
st.session_state.paused = False
st.rerun()
else:
st.info("Pick a preset above to start the countdown.")
Step 2: Run the App
streamlit run countdown.py
Start a Pomodoro and watch the display tick down every second — no clicks needed.
How It Works
The design stores a deadline, not a countdown: deadline = now + duration. Every rerun recomputes remaining from the current clock. This matters because reruns are unpredictable — storing “seconds left” and decrementing it drifts, while comparing against a fixed deadline never does. The same deadline thinking drives the typing test’s start-time snapshot.
The ticking comes from st_autorefresh(interval=1000), which makes the browser re-request the page every second — each refresh reruns the script, recomputes the remaining time, and repaints the st.metric. It only registers while a timer runs, so idle pages don’t churn requests.
Pause is deadline surgery: pausing snapshots the remaining seconds and stops the autorefresh; resuming builds a new deadline from the snapshot. The timer never counts time while paused because the deadline itself moves.
Common Errors & Fixes
- Display doesn’t tick —
st_autorefreshisn’t registered (check it runs before anyreturn/branch skips it), or the timer isn’t in session state. - Countdown drifts or jumps — you stored remaining seconds and decremented per tick; always derive remaining from
deadline - now. KeyError: 'duration'— the progress bar readsdurationbefore a preset sets it; initialize both keys together.- Pause resumes from the wrong time — resume created
timedelta(seconds=remaining)butremainingwas recomputed after unpausing; capture the snapshot at pause time only.
Key Concepts
- Deadline vs countdown — anchor to wall-clock time, never accumulate ticks.
st_autorefresh— server-push-free polling for live UIs.st.empty/ placeholders — repaint a region on each rerun.- Pause = moving the deadline — one mechanism, no separate timer thread.
What to Try Next
- Add a sound alert on completion with
st.audioand a short beep file. - Chain Pomodoro rounds — 25 min focus, 5 min break, automatically.
- Show a circular progress ring with
st.plotly_chartinstead of a bar. - Persist sessions to CSV and chart daily focus minutes — the expense tracker’s storage pattern reused.
FAQ
Why not just time.sleep(1) in a loop?
It blocks the entire Streamlit script — no other widget responds while it sleeps. Autorefresh reruns the whole script cleanly instead, keeping the UI interactive.
Does the timer keep running if I close the tab?
The deadline lives on the server, so yes — reopening the page shows the correct remaining time. Only a server restart clears it.
Can I run multiple timers at once?
Yes — store a dict of deadlines in session state and render one column per timer, each with its own placeholder and pause state.