Tech With Galvan
Back to Articles
Development4/21/2025 • 3 min read

Countdown Timer App using Python and Streamlit

Build a countdown timer with Python and Streamlit — live-updating display, pause/resume, and presets using st.empty and autorefresh.

Galvan
Galvan

Founder & Creator

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

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_autorefresh isn’t registered (check it runs before any return/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 reads duration before a preset sets it; initialize both keys together.
  • Pause resumes from the wrong time — resume created timedelta(seconds=remaining) but remaining was 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.audio and a short beep file.
  • Chain Pomodoro rounds — 25 min focus, 5 min break, automatically.
  • Show a circular progress ring with st.plotly_chart instead 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.