<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Om Vaghani</title>
    <description>The latest articles on DEV Community by Om Vaghani (@om_vaghani_9f90828ac042e7).</description>
    <link>https://dev.to/om_vaghani_9f90828ac042e7</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3930017%2F2e5239dd-0481-4100-b1e3-4b4299503e08.png</url>
      <title>DEV Community: Om Vaghani</title>
      <link>https://dev.to/om_vaghani_9f90828ac042e7</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/om_vaghani_9f90828ac042e7"/>
    <language>en</language>
    <item>
      <title>Keeping a browser timer accurate in background tabs (what I learned building ClockAura)</title>
      <dc:creator>Om Vaghani</dc:creator>
      <pubDate>Sat, 17 Oct 2026 11:45:49 +0000</pubDate>
      <link>https://dev.to/om_vaghani_9f90828ac042e7/keeping-a-browser-timer-accurate-in-background-tabs-what-i-learned-building-clockaura-3461</link>
      <guid>https://dev.to/om_vaghani_9f90828ac042e7/keeping-a-browser-timer-accurate-in-background-tabs-what-i-learned-building-clockaura-3461</guid>
      <description>&lt;p&gt;I run &lt;a href="https://clockaura.com" rel="noopener noreferrer"&gt;ClockAura&lt;/a&gt;, a free site of online timers: countdowns, a Pomodoro timer, a stopwatch, interval timers for the gym. The first versions had the bug I suspect every browser timer has at some point: start a countdown, switch to another tab, and it rings late. Sometimes minutes late.&lt;/p&gt;

&lt;p&gt;The bug wasn't in one line of code. It came from an assumption: that the browser keeps running your code at the same pace when nobody is looking. It doesn't. This post covers what I changed and why. The code snippets are lightly trimmed from the real hook that runs every countdown on the site.&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive version
&lt;/h2&gt;

&lt;p&gt;My first countdown looked like most tutorials:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Don't do this&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setRemaining&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two separate things are wrong here.&lt;/p&gt;

&lt;p&gt;First, &lt;code&gt;setInterval(fn, 1000)&lt;/code&gt; doesn't promise a call every 1000 ms. It promises &lt;em&gt;at least&lt;/em&gt; 1000 ms between calls. Every late call adds a little drift, and subtracting a fixed 1000 bakes that drift into the result. Over a 25-minute Pomodoro you can lose whole seconds even in a foreground tab.&lt;/p&gt;

&lt;p&gt;Second, background tabs. Chrome limits timers in hidden tabs to roughly one wake-up per second, and after a page has been hidden for a few minutes it can batch chained timers much more aggressively, down to about once a minute. Firefox and Safari have their own versions of this. A countdown that counts &lt;em&gt;ticks&lt;/em&gt; is at the mercy of how many ticks the browser lets you have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: store the end, not the remaining
&lt;/h2&gt;

&lt;p&gt;The fix most people land on, and the one that matters most: when the timer starts, compute the moment it should end, and from then on derive everything from that moment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;running&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;targetRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;liveRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;rafRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;armBackup&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// cleanup cancels the frame and the timeout&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;running&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each tick then asks "how far are we from the end?" instead of "subtract one more second":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;liveRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;targetRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="nf"&gt;setRemaining&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* finish: see Rule 3 */&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;rafRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A late tick no longer causes drift. If a frame arrives 300 ms late, the display jumps 300 ms and is correct again. Nothing accumulates.&lt;/p&gt;

&lt;p&gt;I use &lt;code&gt;performance.now()&lt;/code&gt; here, not &lt;code&gt;Date.now()&lt;/code&gt;. &lt;code&gt;performance.now()&lt;/code&gt; is monotonic: it doesn't move when the user's clock syncs with a time server or when someone changes the system time. &lt;code&gt;Date.now()&lt;/code&gt; can jump backwards or forwards, and a countdown that gains five minutes because Windows corrected the clock is a fun bug report to read.&lt;/p&gt;

&lt;p&gt;There is one place where I do use &lt;code&gt;Date.now()&lt;/code&gt;: when a running timer is saved to &lt;code&gt;localStorage&lt;/code&gt; so it can be resumed after a reload. &lt;code&gt;performance.now()&lt;/code&gt; restarts at zero on every page load, so it can't be stored. The saved record keeps a wall-clock &lt;code&gt;endsAt&lt;/code&gt;, and the page turns it back into a duration when you come back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endsAt&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endsAt&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Rule 2: don't trust requestAnimationFrame to finish the job
&lt;/h2&gt;

&lt;p&gt;I moved the display loop to &lt;code&gt;requestAnimationFrame&lt;/code&gt; because it lines up with the screen refresh and makes the digits look smooth. The catch: browsers pause rAF entirely in hidden tabs. Not slow it down. Pause it.&lt;/p&gt;

&lt;p&gt;With a rAF-only loop, the end-timestamp math is still correct, but nothing runs the check. So the timer "finishes" the moment you switch back to the tab, which might be ten minutes after it should have rung. That was most of the late-alarm reports.&lt;/p&gt;

&lt;p&gt;The fix is a second, much simpler mechanism: one &lt;code&gt;setTimeout&lt;/code&gt; aimed straight at the end time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;armBackup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;backupRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;liveRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;targetRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;backupRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;liveRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;targetRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;armBackup&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details that took me more than one try:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One long timeout, not a ticking one.&lt;/strong&gt; Background throttling hits chained timers hardest: timers that schedule timers, again and again. A single timeout set for 25 minutes is about the friendliest thing you can ask a throttled tab for. At worst it gets lined up with the next one-second boundary, which is fine for a kitchen timer and a Pomodoro.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The +15 ms.&lt;/strong&gt; Timer callbacks and &lt;code&gt;performance.now()&lt;/code&gt; don't share an exact clock, and &lt;code&gt;performance.now()&lt;/code&gt; is deliberately coarsened for security reasons. Without the padding, the timeout sometimes fired a hair &lt;em&gt;before&lt;/em&gt; the target, saw 2 ms left, and had to re-arm. The padding makes the common case a single wake-up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-arm if early.&lt;/strong&gt; If it does fire early anyway, it schedules itself again for the real remainder instead of giving up or ringing early.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When the user adds a minute with the +1 button, the end moves, so the backup has to move too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;addMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;deltaMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setRemaining&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;deltaMs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;targetRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;armBackup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// end time moved: re-aim the backstop&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;armBackup&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;setTimeout(armBackup, 0)&lt;/code&gt; is there because the state updater runs later than you might expect. Re-arming on the next turn means the backup reads the updated target.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 3: two ways to finish means two ways to ring twice
&lt;/h2&gt;

&lt;p&gt;Once both rAF and &lt;code&gt;setTimeout&lt;/code&gt; can end the timer, you have a race. Come back to the tab at exactly the wrong moment and both fire within a few milliseconds. The user hears the alarm twice and the "focus minutes" stat would count the session twice.&lt;/p&gt;

&lt;p&gt;The guard is one ref that both paths check, and the finishing path flips it &lt;em&gt;first&lt;/em&gt;, before doing anything else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;liveRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// close the door first&lt;/span&gt;
  &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;backupRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// cancel the other path&lt;/span&gt;
  &lt;span class="nf"&gt;cancelAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rafRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;setRunning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;onDoneRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;              &lt;span class="c1"&gt;// ring exactly once&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It has to be a ref, not state. State updates are batched and applied on the next render, so two callbacks in the same turn would both read the old &lt;code&gt;running === true&lt;/code&gt;. A ref is just a mutable box, and the second caller sees &lt;code&gt;false&lt;/code&gt; immediately.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;onDone&lt;/code&gt; lives in a ref for a related reason: the page passes a fresh callback on every render, and I didn't want &lt;code&gt;tick&lt;/code&gt; to be recreated 60 times a second because of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 4: keep the screen on, but only when it matters
&lt;/h2&gt;

&lt;p&gt;A lot of timers end up on a projector or a TV: &lt;a href="https://clockaura.com/classroom-mode" rel="noopener noreferrer"&gt;classroom timers&lt;/a&gt;, workout timers across the room. Nothing kills that faster than the laptop dimming its screen at minute nine.&lt;/p&gt;

&lt;p&gt;The Screen Wake Lock API fixes it in a few lines, with two catches. The lock is released automatically whenever the page is hidden, and it's not re-acquired when you come back. So the hook listens for &lt;code&gt;visibilitychange&lt;/code&gt; and asks again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onVisible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;active&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;visibilityState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;visible&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;lockRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;acquire&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;visibilitychange&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onVisible&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The other catch is about scope. The lock is held while a timer runs &lt;em&gt;or&lt;/em&gt; while the page is full screen, and released otherwise. A wake lock that stays on after the timer ends drains a laptop battery for nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 5: the service worker must never serve an old timer
&lt;/h2&gt;

&lt;p&gt;Timers are the kind of page people open with bad Wi-Fi: in a gym basement, a classroom, a kitchen. So the site has a service worker that caches the core timer pages after the first visit, and they open offline.&lt;/p&gt;

&lt;p&gt;The thing I was most nervous about was the opposite problem: shipping a fix and having half my users stuck on the cached old page. The rules I settled on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HTML is network-first.&lt;/strong&gt; The cached copy is only used if the network fails, or on a repeat visit when the network hasn't answered within 700 ms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hashed assets are cache-first.&lt;/strong&gt; &lt;code&gt;/assets/index-a1b2c3d4.js&lt;/code&gt; never changes content, so caching it forever is safe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The cache name includes the build id.&lt;/strong&gt; Every deploy creates a new cache and the &lt;code&gt;activate&lt;/code&gt; handler deletes the others, so a cached page always matches the scripts cached with it.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;VERSION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;__BUILD_ID__&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// replaced at build time&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CACHE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`clockaura-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;VERSION&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;activate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;caches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;CACHE&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;caches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clients&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;})());&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The service worker has nothing to do with timing accuracy, but it's part of the same promise: when you press Start, the timer works, whatever the tab or the network is doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  One gap I found while writing this
&lt;/h2&gt;

&lt;p&gt;The tab title ("04:59 · Countdown") and the little progress ring in the favicon were updated from the same render loop as the digits, and that loop is driven by rAF. In a background tab the &lt;em&gt;ending&lt;/em&gt; was reliable, thanks to the backup timeout, but the title could sit still until you looked at the tab again. Ironically, a background tab is exactly when you'd look at the title. The fix is a one-second interval that only runs while &lt;code&gt;document.hidden&lt;/code&gt; is true and only updates the title. Background throttling allows one wake-up per second, and that's all a title needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;If you're building anything that counts down in a browser:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Store the end time and derive the remaining time from it. Never subtract fixed ticks.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;performance.now()&lt;/code&gt; inside a page and &lt;code&gt;Date.now()&lt;/code&gt; only for anything you save.&lt;/li&gt;
&lt;li&gt;Drive the display with rAF, but back up the finish with one &lt;code&gt;setTimeout&lt;/code&gt; aimed at the end.&lt;/li&gt;
&lt;li&gt;Guard the finish with a ref so it can only happen once.&lt;/li&gt;
&lt;li&gt;Re-acquire the wake lock on &lt;code&gt;visibilitychange&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want to see it all working, start a &lt;a href="https://clockaura.com/pomodoro-timer" rel="noopener noreferrer"&gt;Pomodoro timer&lt;/a&gt;, switch to another tab, and wait for the bell. Or open a link timer like &lt;a href="https://clockaura.com/t/25m" rel="noopener noreferrer"&gt;clockaura.com/t/25m&lt;/a&gt;, which starts a 25-minute countdown straight from the URL.&lt;/p&gt;

&lt;p&gt;I'm happy to answer questions in the comments, especially if you've found a browser where this still rings late.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>react</category>
      <category>performance</category>
    </item>
    <item>
      <title>I Built ClockAura: A Free Online Timer Platform with React + Vite (My Solo Dev Journey)</title>
      <dc:creator>Om Vaghani</dc:creator>
      <pubDate>Wed, 13 May 2026 19:44:24 +0000</pubDate>
      <link>https://dev.to/om_vaghani_9f90828ac042e7/i-built-clockaura-a-free-online-timer-platform-with-react-vite-my-solo-dev-journey-52p0</link>
      <guid>https://dev.to/om_vaghani_9f90828ac042e7/i-built-clockaura-a-free-online-timer-platform-with-react-vite-my-solo-dev-journey-52p0</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update — October 2026:&lt;/strong&gt; ClockAura has grown a lot since I first wrote this in May. It now has 190+ pages, 8 UI languages (English, Hindi, Gujarati, Spanish, Portuguese, French, German, Indonesian), link timers (type &lt;code&gt;clockaura.com/t/5m&lt;/code&gt; and a 5-minute timer starts), and it opens offline after your first visit. Numbers below are marked as "at launch" where they changed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I built &lt;strong&gt;ClockAura&lt;/strong&gt; — a free online timer site with countdowns, a stopwatch, Pomodoro, alarm clock, interval timers and classroom tools. Built with React + Vite. No sign-up needed. Live at &lt;a href="https://clockaura.com" rel="noopener noreferrer"&gt;clockaura.com&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I Built ClockAura
&lt;/h2&gt;

&lt;p&gt;As a solo developer, I kept running into the same problems with "free online timer" sites:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dated, cluttered interfaces&lt;/li&gt;
&lt;li&gt;Intrusive ads that cover the timer&lt;/li&gt;
&lt;li&gt;Sign-up walls for basic features&lt;/li&gt;
&lt;li&gt;Only one timer type per site&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wanted one clean place for all of it, so I built the timer site I wanted to use myself. About three months later, ClockAura went live.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Inside
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Core tools
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://clockaura.com/pomodoro-timer" rel="noopener noreferrer"&gt;Pomodoro Timer&lt;/a&gt;&lt;/strong&gt; — 25/5 cycles, auto-start, a task list and daily stats&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://clockaura.com/stopwatch" rel="noopener noreferrer"&gt;Online Stopwatch&lt;/a&gt;&lt;/strong&gt; — full screen, laps, CSV export&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://clockaura.com/countdown-timer" rel="noopener noreferrer"&gt;Countdown Timer&lt;/a&gt;&lt;/strong&gt; — any duration, saved presets&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href="https://clockaura.com/alarm-clock" rel="noopener noreferrer"&gt;Alarm Clock&lt;/a&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://clockaura.com/interval-timer" rel="noopener noreferrer"&gt;Interval Timer&lt;/a&gt;&lt;/strong&gt; — HIIT, Tabata, EMOM&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://clockaura.com/world-clock" rel="noopener noreferrer"&gt;World Clock&lt;/a&gt;&lt;/strong&gt; and a meeting planner&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Countdown to a date&lt;/strong&gt; — events, exams, festivals&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Things I'm proud of
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Link timers:&lt;/strong&gt; &lt;code&gt;clockaura.com/t/90s&lt;/code&gt;, &lt;code&gt;/t/1h30m&lt;/code&gt;, &lt;code&gt;/t/until/17:00&lt;/code&gt; start instantly — handy as bookmarks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Classroom mode&lt;/strong&gt; for projectors, with a QR code to share the timer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embeddable widget&lt;/strong&gt; for blogs and LMS pages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OBS overlay&lt;/strong&gt; mode for streamers (transparent background)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;8 languages&lt;/strong&gt;, with proper hreflang&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Tech Stack
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"frontend"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"React 18"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Vite"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"styling"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Tailwind CSS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"routing"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"React Router"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hosting"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hostinger (static + .htaccess)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"analytics"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Google Analytics 4"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"monetization"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Google AdSense"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  SEO Challenges in a SPA
&lt;/h2&gt;

&lt;p&gt;The biggest challenge was making every tool page crawlable. My first fix (at launch) was a static fallback inside &lt;code&gt;index.html&lt;/code&gt;. Later I moved to &lt;strong&gt;prerendering every route at build time&lt;/strong&gt;, so each URL ships its own HTML with title, description, canonical and structured data, and React takes over in the browser.&lt;/p&gt;

&lt;p&gt;Lessons from that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Keep the prerendered HTML and the runtime &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; in sync — it's easy for the two to drift.&lt;/li&gt;
&lt;li&gt;Replace page-owned JSON-LD and hreflang on every client-side route change, not just on first load.&lt;/li&gt;
&lt;li&gt;Treat redirects and the SPA router as one system, or a page can work on click but redirect on reload.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Keeping Timers Accurate
&lt;/h2&gt;

&lt;p&gt;Browsers throttle &lt;code&gt;requestAnimationFrame&lt;/code&gt; in background tabs, so a timer that counts frames drifts. ClockAura stores the &lt;strong&gt;end timestamp&lt;/strong&gt; and computes the remaining time from it, with a single &lt;code&gt;setTimeout&lt;/code&gt; as a backstop so the alarm still fires when the tab is hidden.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance
&lt;/h2&gt;

&lt;p&gt;At launch, PageSpeed showed about 87 mobile / 94 desktop. After moving CSS out of the HTML, self-hosting fonts and deferring third-party scripts until interaction, the homepage scores 100 on mobile in Lighthouse (October 2026). Lab scores aren't a ranking guarantee, but a fast first load matters on cheap phones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Start simple, then iterate.&lt;/strong&gt; Launch with a few tools, then listen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SEO takes time.&lt;/strong&gt; Organic clicks were tiny for the first months and only started growing after real fixes and useful pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Thin pages don't help.&lt;/strong&gt; I pruned dozens of near-empty pages and merged them into stronger ones.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Performance is UX.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't skip the boring stuff&lt;/strong&gt; — Privacy, Terms, About and a clear contact page matter for trust.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Better classroom workflows with real teacher feedback&lt;/li&gt;
&lt;li&gt;More useful content around study and workout timing&lt;/li&gt;
&lt;li&gt;A mobile app, eventually&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;🔗 &lt;a href="https://clockaura.com" rel="noopener noreferrer"&gt;clockaura.com&lt;/a&gt; — or just type &lt;code&gt;clockaura.com/t/5m&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I'd love feedback: what timer feature is missing for you?&lt;/p&gt;

&lt;h2&gt;
  
  
  Connect
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;X: &lt;a href="https://x.com/ClockAuraApp" rel="noopener noreferrer"&gt;@ClockAuraApp&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Website: &lt;a href="https://clockaura.com" rel="noopener noreferrer"&gt;clockaura.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thanks for reading!&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>react</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
