Skip to content

Time and scheduled functions

Fireemu has a clock of its own. Set where it starts and move it, and every service sees the new time: scheduled functions come due, tokens expire, and Security Rules see the new request.time.

schemaVersion = 1
[daemon]
clockStart = "2026-01-31T23:59:00Z"

Configure the time zone, catch-up and overlap behavior of scheduled functions in the scheduler section.

A schedule is driven by the virtual clock alone: real time passing runs nothing. Move the clock from the Runtime page of the Emulator UI, or with the control API (clock:advance, clock:advanceTo, clock:set). Each move makes every occurrence in (previous time, new time] due.

  • scheduler.catchUp decides what runs when a move passes several occurrences: all runs each one (at most scheduler.maxCatchUpRuns per job and move), latest runs the newest, and none runs none.
  • scheduler.overlap (allow, skip, queue, reject, skip-in-flight) decides what happens when a run comes due while the previous one is still running. Under strict the default is skip-in-flight, as production’s Cloud Scheduler behaves; under emulator it is allow.

The Functions page of the UI can also run a scheduled function immediately, or advance the clock to its next occurrence. A manual run does not consume the next occurrence.

The clock can move backwards (clock:set with "allowBackwards": true, or Allow moving backwards on the Runtime page). A rewind never replays occurrences that already ran.

Under strict, Authentication, Firestore and Storage refuse an ID token issued after the new time until the clock reaches its issue time again. In both profiles an expired token is still refused. If a rewind leaves tokens unusable, move the clock forward again or reset the session.

By default your function code sees real time. To make Date.now(), timers and Cloud Tasks follow the virtual clock too, set functions.clock together with a pinned daemon.clockStart:

schemaVersion = 1
[daemon]
clockStart = "2026-01-31T23:59:00Z"
[functions]
source = "./functions"
[functions.clock]
date = "virtual"
timers = "real"
tasks = "virtual"

Each mode accepts real or virtual. Virtual timers require virtual date.

With tasks set to virtual, a Cloud Tasks task enqueued with a scheduleTime becomes due when the virtual clock reaches it, so a test advances the clock instead of waiting. With real, the task waits for the wall clock under strict; the Functions page of the Emulator UI lists waiting tasks and can run them now.

A session inside one daemon shares the daemon’s clock, Rules and Functions. For parallel tests that change any of them, the fireemu/testing helper starts independent worlds, each with its own daemon, ports, clock and data, even with the same project ID:

import { withTestWorld } from 'fireemu/testing';
await withTestWorld(
{
projectId: 'demo-parallel',
clockStart: '2026-01-31T23:59:00Z',
functionsSource: './functions',
services: ['auth', 'firestore', 'storage', 'functions'],
},
async (world) => {
await world.clock.advance(59999); // milliseconds: one millisecond before midnight
await world.run(process.execPath, ['./trial-before-expiry.mjs']);
await world.clock.advance(1);
await world.run(process.execPath, ['./trial-expired.mjs']);
},
);

world.run launches a command with the world’s project and emulator hosts. world.reset() starts the world again from its creation snapshot, and world.dispose() stops it and removes its files; withTestWorld disposes it for you, also when the callback throws.

To inspect the same world in the debug UI, pass ui: true to createTestWorld() and open world.uiUrl. The UI is disabled by default. Each enabled world receives its own loopback port and shares its live Firestore, Auth, Storage and Functions state. Keep the world alive while inspecting it; dispose() also stops its UI. After reset(), read world.uiUrl again and reopen the page. See test worlds for an interactive example.

Functions worlds snapshot local sources and dependencies so caller edits do not affect reset. In published v0.25.0 and v0.26.0 they also copy inherited NODE_PATH directories, including linked global development repositories. An unused linked repository can therefore slow startup or fail copying before tests begin.

For local-only dependencies on those versions, pass env: { ...process.env, NODE_PATH: '' }. If globals are required, select only their directories in env.NODE_PATH; nodePathExcludes omits exact unused package names, including scoped names, before following their links. Required dependency failures are still errors.

v0.27.0+: ambient NODE_PATH is cleared by default. Ordinary parent environment variables remain inherited. Pass env: { ...process.env } to explicitly retain globals, or supply a narrowed env.NODE_PATH. An environment object containing NODE_PATH is an opt-in even when constructed with a spread. Local sources and ancestor node_modules are still snapshotted. See dependency snapshot limits.

Enable hub: true to export a test world and import its data into a fresh one.

Since v0.26.0, POST /v1/sessions/default:awaitIdle accepts { "scope": "runnable" }. In a world:

await world.control('POST', 'sessions/default:awaitIdle', { scope: 'runnable' });

This excludes future task schedules and future retry backoff from the wait. The default scope still waits for all work. Due tasks blocked by rate or concurrency limits and active HTTP requests remain pending. Advance the applicable clock or wait for the active work; diagnostics identify the waiting phase and deadline. This does not wait for arbitrary user-created JavaScript timers. See idle scopes and task clocks.