Testing Alarms and Timers Deterministically

Unit-test chrome.alarms logic without waiting: a fake alarm registry, fake timers for debounce and backoff, and assertions that catch the minimum-period clamp.

Published August 1, 2026 Updated August 1, 2026 9 min read
Table of Contents

A test that calls chrome.alarms.create({ periodInMinutes: 1 }) and then waits is not a test, it is a delay. A test that mocks chrome.alarms.create and asserts it was called is barely better — it proves you called an API, not that the schedule is correct, that a restart re-creates it, or that the browser’s minimum-period clamp has not silently turned your thirty-second poll into a one-minute one. This page is part of Unit & Integration Testing for MV3 Extensions and builds a fake that makes scheduling logic testable in milliseconds.

Root cause: two clocks, and neither is under the test’s control

Extension scheduling runs on two mechanisms with different owners. chrome.alarms is owned by the browser: it survives worker eviction, it clamps short periods, and it fires whenever the browser decides. setTimeout is owned by the JavaScript runtime: it is precise, and it dies with the worker. A test that cannot advance both independently cannot test the code that uses both.

What each scheduling mechanism guaranteeschrome.alarms and setTimeout compared on survival across worker eviction, minimum granularity and how a test can control them.MechanismSurvives evictionMinimum periodTest controlchrome.alarmsYes30 s – 1 minFake registrysetTimeout / setIntervalNoMillisecondsFake timersDate.now()N/AN/AFake clockReal waitingN/AN/ANone — flaky
The right-hand column is what makes each one testable — a registry you drive, or fake timers.

Step 1. Build a fake alarm registry you can fire by hand

The real API is small enough to reimplement faithfully in thirty lines, which is far more useful than a spy.

 1// test/fake-alarms.js
 2export function createFakeAlarms() {
 3  const alarms = new Map();
 4  const listeners = new Set();
 5
 6  return {
 7    api: {
 8      create: (name, info) => {
 9        // Mirror the browser: a period below one minute is clamped in a packed extension.
10        const periodInMinutes = info.periodInMinutes
11          ? Math.max(info.periodInMinutes, 1)
12          : undefined;
13        alarms.set(name, { name, periodInMinutes, scheduledTime: Date.now() + (info.delayInMinutes ?? 0) * 60_000 });
14      },
15      get: async (name) => alarms.get(name) ?? undefined,
16      getAll: async () => [...alarms.values()],
17      clear: async (name) => alarms.delete(name),
18      clearAll: async () => { const had = alarms.size > 0; alarms.clear(); return had; },
19      onAlarm: {
20        addListener: (fn) => listeners.add(fn),
21        removeListener: (fn) => listeners.delete(fn),
22      },
23    },
24    // Test-only controls.
25    fire: async (name) => {
26      const alarm = alarms.get(name);
27      if (!alarm) throw new Error(`no alarm named ${name}`);
28      for (const fn of listeners) await fn(alarm);
29    },
30    names: () => [...alarms.keys()],
31    periodOf: (name) => alarms.get(name)?.periodInMinutes,
32  };
33}

Execution context: Node, under Jest or Vitest — no browser, no jsdom requirement, because none of this touches the DOM. Reproducing the one-minute clamp in the fake is the point of writing it rather than using a generic mock: a test that asserts periodOf("poll") === 1 after asking for 0.5 documents real browser behaviour and fails if someone “fixes” the code to request thirty seconds again. Chrome and Firefox both clamp packed extensions to one minute; Safari applies additional coalescing on top, which is a runtime characteristic rather than something a unit test should model.

Step 2. Install it alongside the rest of the chrome double

 1// test/setup.js
 2import { createFakeAlarms } from "./fake-alarms.js";
 3import { createFakeStorage } from "./fake-storage.js";
 4
 5export function installChrome() {
 6  const alarms = createFakeAlarms();
 7  const storage = createFakeStorage();
 8
 9  globalThis.chrome = {
10    alarms: alarms.api,
11    storage: storage.api,
12    runtime: { onInstalled: { addListener: () => {} }, onStartup: { addListener: () => {} } },
13  };
14
15  return { alarms, storage };
16}

Execution context: Test setup file, imported by each suite. Assigning to globalThis.chrome matches how the API is reached in the worker, so the module under test needs no dependency injection and ships unchanged. Returning the control handles separately from the API keeps the seam obvious: production code sees only chrome.alarms, tests see alarms.fire. The storage double this composes with is covered in mocking Chrome APIs in Jest.

Step 3. Test the schedule, not the call

A scheduling test with no waitingThe test installs the fake, runs the setup routine, asserts the registry, fires the alarm by hand, and asserts the effect.TestFake alarmsModule under testFake storagescheduleSync()alarms.create("sync", { periodInMinutes: 0.5 })expect periodOf("sync") === 1the clamp is assertedfire("sync")onAlarm handler runswrites lastSyncAtexpect lastSyncAt to be set
Wall-clock time never advances — the alarm fires because the test says so.
 1// sync.test.js
 2import { installChrome } from "./test/setup.js";
 3import { scheduleSync, registerHandlers } from "../src/sync.js";
 4
 5test("sync alarm is clamped and writes a timestamp when it fires", async () => {
 6  const { alarms, storage } = installChrome();
 7  registerHandlers();
 8
 9  await scheduleSync({ everyMinutes: 0.5 });
10
11  expect(alarms.names()).toEqual(["sync"]);
12  expect(alarms.periodOf("sync")).toBe(1);        // the browser's floor, not our request
13
14  await alarms.fire("sync");
15
16  expect(await storage.get("lastSyncAt")).toEqual(expect.any(Number));
17});
18
19test("re-scheduling does not create a duplicate alarm", async () => {
20  const { alarms } = installChrome();
21  await scheduleSync({ everyMinutes: 5 });
22  await scheduleSync({ everyMinutes: 5 });
23  expect(alarms.names()).toEqual(["sync"]);       // create() replaces by name
24});

Execution context: Jest or Vitest in Node. The second test encodes behaviour people get wrong in both directions: alarms.create with an existing name replaces rather than adding, so a guard against duplicates is unnecessary — but a guard against recreating one on every event is very necessary, because replacing resets the period and an alarm re-created every thirty seconds never fires at all. That failure mode is described in alarms that don’t fire after browser restart.

Step 4. Use fake timers for the in-memory side

Debounce, backoff and keep-alive loops use setTimeout, which fake timers control exactly.

 1// backoff.test.js
 2import { retryWithBackoff } from "../src/backoff.js";
 3
 4beforeEach(() => { jest.useFakeTimers(); });
 5afterEach(() => { jest.useRealTimers(); });
 6
 7test("backs off 1s, 2s, 4s and then gives up", async () => {
 8  const attempt = jest.fn()
 9    .mockRejectedValueOnce(new Error("503"))
10    .mockRejectedValueOnce(new Error("503"))
11    .mockRejectedValueOnce(new Error("503"))
12    .mockResolvedValue("ok");
13
14  const promise = retryWithBackoff(attempt, { tries: 3 });
15
16  await jest.advanceTimersByTimeAsync(1000);
17  await jest.advanceTimersByTimeAsync(2000);
18  await jest.advanceTimersByTimeAsync(4000);
19
20  await expect(promise).rejects.toThrow("503");
21  expect(attempt).toHaveBeenCalledTimes(3);
22});

Execution context: Node with Jest’s modern fake timers. advanceTimersByTimeAsync rather than the synchronous variant is what lets awaited promises between the timers resolve — with the synchronous version the timer fires but the continuation never runs and the test hangs. Vitest exposes the same behaviour through vi.advanceTimersByTimeAsync. Keep this separate from the alarm tests: setTimeout state does not survive worker eviction, so anything scheduled with it is by definition short-lived.

Step 5. Test the restart path, which is where scheduling actually breaks

Alarms survive eviction; the listener registration does not. A worker that registers onAlarm inside an await misses the event entirely.

Why a late listener misses the event that woke the workerThe browser fires the alarm on the turn after the worker script finishes evaluating, so a listener added inside a promise chain is registered too late.Alarm firesworker is asleepWorker script evaluatessynchronous phaseEvent dispatchedthe very next turnwhere was the listener registered?Top level → receivedhandler runsAfter an await → missedsilently, every time
The test asserts the listener exists the moment evaluation ends — the only window that counts.
 1// startup.test.js
 2test("handlers are registered synchronously at module evaluation", async () => {
 3  const { alarms } = installChrome();
 4  const added = [];
 5  chrome.alarms.onAlarm.addListener = (fn) => added.push(fn);
 6
 7  await import("../src/sw.js");                   // evaluate the worker entry point
 8
 9  expect(added).toHaveLength(1);                  // registered during evaluation, not later
10  expect(alarms.names()).not.toContain("sync");   // creating alarms is NOT evaluation work
11});

Execution context: Node. Asserting that the listener exists immediately after the module is evaluated is the closest a unit test gets to the real failure — the browser dispatches the alarm on the turn after the worker script finishes, so a listener added inside a promise chain is too late. Asserting the absence of alarm creation at evaluation is the complementary check: creating alarms on every cold start resets their periods, which is the same never-fires bug from the other direction.

Cross-browser variation

  • Chrome/Edge: packed extensions clamp periodInMinutes to a minimum of 1; unpacked builds allow 30 seconds, which is why a schedule that works in development can stop working after upload.
  • Firefox: the same one-minute floor for packed add-ons. browser.alarms returns native Promises, so a shared fake should return promises from get, getAll and clear as the one above does.
  • Safari: honours the API but coalesces firings for power management, so real intervals drift by tens of seconds. No unit test should encode a tolerance for this — assert ordering and effects, never timing.
  • All engines: alarms persist across worker eviction and browser restart, but not across an extension update in every case. Re-assert the schedule from onInstalled and onStartup both.

Verification

  1. Run the suite and confirm it completes in well under a second — any test that takes seconds is still waiting on something real.
  2. Change the production code to request periodInMinutes: 0.5 without the clamp and confirm the clamp assertion still passes, proving the fake models the browser rather than the code.
  3. Move the onAlarm registration inside an await in the worker entry point and confirm the registration test fails.
  4. Delete the advanceTimersByTimeAsync calls and confirm the backoff test times out rather than passing silently.

FAQ

Should I test against the real chrome.alarms in an end-to-end run?

Yes, once — a single smoke test that an alarm fires at all. Everything about which alarms exist, with what periods, and what they do belongs in unit tests.

Why not use a generic auto-mocking library for chrome?

Auto-mocks return undefined and record calls. They cannot model the clamp, the replace-by-name behaviour, or persistence, which is where the real bugs are.

Do I need jsdom for these tests?

No. Alarms and timers are worker-side concerns with no DOM. Running them in the Node environment is faster and avoids jsdom’s gaps.

How do I test something that uses both an alarm and a debounce?

Fire the alarm through the registry, then advance fake timers. The two controls are independent, which is exactly why they are separate.

Other Testing, Debugging & Performance Optimization Resources