DevelopmentApril 21, 20253 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

  • Python 3.8+ — from python.org.
  • Dependencies:
  • code
    pip install streamlit streamlit-autorefresh

    Step 1: Create the Script

    Save as countdown.py:

    code
    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

    code
    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 tickst_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.