Skip to content

Timer Patterns

Promise-based timers with explicit state management and cancellation, plus lightweight utilities for measuring elapsed time.

When to Use

  • Scheduling a one-shot delayed action that can be canceled
  • Measuring elapsed time for performance logging
  • Tracking lap times across multiple checkpoints
  • Needing to await a timer as a promise

The Patterns

Timer class

A promise-based timer with four states: READY, RUNNING, COMPLETED, CANCELED. The timer is thenable — it works with await.

javascript
const timer = Timer.start({
  duration: 5000,
  name: "session-timeout",
  handler: () => logout(),
});

// Cancel if user interacts
document.addEventListener("click", () => {
  timer.cancel();
});

// Or await it
try {
  await timer;
  console.log("Timer completed");
} catch {
  console.log("Timer was canceled");
}

Implementing Timer

Wraps setTimeout in a promise-based state machine with explicit READY, RUNNING, COMPLETED, and CANCELED transitions:

javascript
class Timer {
  #state = "ready";
  #name;
  #duration;
  #resolvers = Promise.withResolvers();
  #timeoutId;
  
  static start({ duration, name = "", handler }) {
    return new Timer({
      duration, name, handler,
    }).start();
  }
  
  constructor({ duration, name = "", handler }) {
    if (typeof duration !== "number") {
      throw new TypeError(
        "duration must be a number"
      );
    }
    if (duration <= 0) {
      throw new RangeError(
        "duration must be greater than 0"
      );
    }
    this.#duration = duration;
    this.#name = String(name);
    if (handler) this.then(handler);
  }
  
  start() {
    if (this.#state !== "ready") {
      console.warn(
        `Timer '${this.name}' cannot start `
        + `(state: ${this.#state})`
      );
      return this;
    }
    this.#state = "running";
    this.#timeoutId = setTimeout(
      () => this.#timeout(),
      this.duration,
    );
    return this;
  }
  
  #timeout() {
    this.#state = "completed";
    this.#resolvers.resolve(this.duration);
  }
  
  cancel() {
    if (this.#state !== "running") return;
    clearTimeout(this.#timeoutId);
    this.#state = "canceled";
    this.#resolvers.reject(
      new Error(`Timer '${this.name}' canceled.`)
    );
  }
  
  get promise() { return this.#resolvers.promise; }
  get state() { return this.#state; }
  get name() { return this.#name; }
  get duration() { return this.#duration; }
  
  then(onFulfilled, onRejected) {
    return this.#resolvers.promise.then(
      onFulfilled, onRejected,
    );
  }
  
  catch(onRejected) {
    return this.#resolvers.promise.then(
      undefined, onRejected,
    );
  }
  
  finally(onSettled) {
    return this.#resolvers.promise.finally(
      onSettled,
    );
  }
}

Stopwatch with laps

Tracks multiple checkpoints and returns the duration of each lap:

javascript
const sw = stopWatch();
await fetchData();
console.log(`Fetch: ${sw.lap()}ms`);
await processData();
console.log(`Process: ${sw.lap()}ms`);

Implementing stopWatch()

Records performance.now() timestamps on each lap() call and returns the delta between consecutive checkpoints:

javascript
function stopWatch() {
  const laps = [performance.now()];
  return {
    lap() {
      laps.push(performance.now());
      return laps.at(-1) - laps.at(-2);
    },
  };
}

Simple elapsed timer

Minimal utility for measuring elapsed time from a starting point:

javascript
function createTimer() {
  const start = performance.now();
  return {
    elapsed() {
      return performance.now() - start;
    },
  };
}

Elapsed timer with logging

Adds a logElapsed method that formats the duration:

javascript
const timer = useTimer();
await migrate();
timer.logElapsed("Migration complete");
// [1.2s] Migration complete

Implementing useTimer()

Extends the simple elapsed timer with a logElapsed() method that formats durations for console output:

javascript
function useTimer() {
  const start = performance.now();
  return {
    elapsed() {
      return performance.now() - start;
    },
    logElapsed(...args) {
      const time = `[${ms(this.elapsed())}]`;
      console.log(time, ...args);
    },
  };
}

Trade-offs

ApproachProsCons
Timer classAwaitable, cancelableMore code
setTimeoutSimple, universalNot promise-based
stopWatchLap-based, chainableNo timestamps
createTimerMinimalNo formatting
useTimerBuilt-in loggingNeeds ms() helper