HTMLNov 6, 2026 · 6 min read

building an entire app with a single HTML file

I kept hearing that a single HTML file could be a real app, and I did not quite believe it. HTML is markup, the thinking went, and real apps need a build step, a framework, a server. So I built one to find out. A Pomodoro timer with a task list, in one file, no build step, no framework, no server. Here is what happened.

Live: the timer, try it below
25:00

0 focus sessions done

No tasks yet. Add one above, it saves in your browser.

What I built

A focus timer with three modes, 25 minutes for focus, 5 for a short break, 15 for a long one. A task list underneath, add a task, check it off, delete it. The tasks save in localStorage, so they are still there when you come back. And a count of finished focus sessions, because watching the number climb is half the point.

The shape of it

One file, three parts. HTML for the structure, a style block for the CSS, a script block for the JS. No imports, no bundler, nothing to install. You double-click the file and it opens in the browser. That is the whole install step. Here is the shape of the file, trimmed down:

pomodoro.htmlTypeScript
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>Pomodoro</title>
  <style>
    body { font-family: system-ui; max-width: 560px; margin: 40px auto; }
    #timer { font-size: 64px; font-family: monospace; }
  </style>
</head>
<body>
  <h1>Pomodoro</h1>
  <div id="timer">25:00</div>
  <button id="start">Start</button>
  <ul id="tasks"></ul>
  <script>
    let endAt = null;
    let timerId = null;

    function render(seconds) {
      const mm = String(Math.floor(seconds / 60)).padStart(2, '0');
      const ss = String(seconds % 60).padStart(2, '0');
      document.getElementById('timer').textContent = mm + ':' + ss;
    }

    function tick() {
      const left = Math.max(0, Math.round((endAt - Date.now()) / 1000));
      render(left);
      if (left === 0) clearInterval(timerId);
    }

    document.getElementById('start').addEventListener('click', () => {
      endAt = Date.now() + 25 * 60 * 1000;
      timerId = setInterval(tick, 250);
    });

    // tasks: load from localStorage, save on change
    const tasks = JSON.parse(localStorage.getItem('tasks') || '[]');
  </script>
</body>
</html>

What worked

More than I expected. The timer counts down, the tasks persist, the whole thing fits in one file you can send to someone. localStorage just works, and the browser gives you a real app shell for free. I opened the file, started a session, and forgot it was a single HTML file. That is the point.

Where I hit the wall

The timer, first. My first version used setInterval to subtract a second on every tick, and it drifted when I switched tabs. Browsers throttle timers in background tabs, first to once a second, then to once a minute after five minutes hidden. A tick counter loses exactly the time the browser withholds. The fix is a deadline: store when the session ends and recompute the remaining time from the clock on every tick. That is what the demo above does, and it is the version that stays accurate.

Notifications, second. A Pomodoro wants to tell you when the session ends, even if the tab is closed. That needs a service worker, and a service worker cannot be registered from a file:// URL. The browser blocks it because a service worker needs a secure context, and a file opened from disk is not one. So the single file can count down while it is open, but it cannot tap you on the shoulder after you close it. That is the wall.

And sync. localStorage lives in one browser on one machine. There is no sync without a server, and a single file has no server. The tasks are yours, on this device, and that is as far as they go.