Keep a timer or service alive
Some work should not happen only because the user pressed a button once.
A clock needs to tick while the screen is visible. A sync connection may need to stay open while a workspace is active. A background watcher might need to keep sending events until the user leaves the screen.
Fission handles these cases with runtime-managed resources.
This matters because repeating work is where user interface code often becomes hard to trust. People start background loops in random places, forget when they stop, or let hidden timers outlive the screen that needed them. Fission keeps the lifetime explicit: the widget declares the resource during build(), and the runtime keeps it alive for as long as the declaration stays present.
This recipe starts with a ticking clock, because that is the easiest repeating task to understand. Then it shows when you should move up to a long-running service.
Step 1: decide whether you need a timer, a service, or a job
Before writing code, choose the right shape.
A job is for one request and one result. Saving one file is job-shaped.
A timer is for repeated wakeups on a schedule. Updating "Last synced 12 seconds ago" is timer-shaped.
A service is for long-lived work with identity over time. A sync session, watcher, or streaming connection is service-shaped.
This page uses both timer and service because they solve different problems.
Step 2: model a ticking screen in app state
Imagine a dashboard card that shows the current time and a sync status line.
use fission::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardState {
pub now_seconds: u64,
pub sync_status: String,
pub sync_connected: bool,
}
impl Default for DashboardState {
fn default() -> Self {
Self {
now_seconds: 0,
sync_status: "Not connected".into(),
sync_connected: false,
}
}
}
impl AppState for DashboardState {}
The state stays product-focused. It stores what the user should see, not the internals of timer scheduling.
Step 3: use a TimerResource for repeated wakeups
A timer resource is the right tool when the runtime should keep delivering ticks while a widget subtree exists.
Start by defining an action that will run on every tick.
#[fission_action]
pub struct ClockTick;
fn on_clock_tick(
state: &mut DashboardState,
_action: ClockTick,
ctx: &mut ReducerContext<DashboardState>,
) {
if let Some(now) = ctx.input.timer_tick::<u64>() {
state.now_seconds = now;
}
}
The reducer is still simple. The timer payload comes in through ctx.input.timer_tick::<u64>(), and the reducer moves that value into state.
Now declare the timer during build().
impl Widget<DashboardState> for DashboardScreen {
fn build(&self, ctx: &mut BuildCtx<DashboardState>, view: &View<DashboardState>) -> Node {
let tick = ctx.bind(
ClockTick,
reduce_with!(on_clock_tick),
);
ctx.resources.timer(
TimerResource::new(
ResourceKey::new("dashboard-clock"),
std::time::Duration::from_secs(1),
0_u64,
)
.immediate()
.on_tick(tick),
);
Text::new(format!("Seconds: {}", view.state.now_seconds)).into_node()
}
}
This is the key pattern.
You declare the timer in build(), but build() is still pure because it is only describing a runtime resource. It is not starting a thread itself. After the build pass, the runtime reconciles that declaration and keeps the timer alive while the widget remains mounted.
The stable ResourceKey gives the timer identity across rebuilds. The runtime can see that this is still the same timer resource rather than a brand-new unrelated one.
Use a timer when you need scheduled wakeups and nothing more.
Step 4: know when a timer stops being enough
A timer is not a general background worker. It is just a scheduler.
If your screen needs a real long-lived conversation, such as a background sync loop that can emit events and accept commands over time, you need a service.
That is why services exist at all.
A service is not just "a job that runs for longer." A job gives you one result and then it is done. A service keeps an identity over time. It can start once, stay alive, emit multiple events, receive later commands, and stop cleanly.
That difference matters for production apps because a long-running session should have an explicit lifecycle.
Step 5: define a service when the work has identity over time
Here is a small sync-style service shape.
use fission::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Debug)]
pub struct SyncService;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SyncConfig {
pub workspace_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum SyncCommand {
RefreshNow,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SyncCommandOk;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SyncCommandErr {
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum SyncEvent {
Connected,
SyncFinished { summary: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SyncStartErr {
pub message: String,
}
impl ServiceSpec for SyncService {
type Config = SyncConfig;
type Command = SyncCommand;
type CommandOk = SyncCommandOk;
type CommandErr = SyncCommandErr;
type Event = SyncEvent;
type StartErr = SyncStartErr;
const NAME: &'static str = "sync-service";
}
const SYNC_SERVICE: ServiceType<SyncService> = ServiceType::new("sync-service");
This type definition looks longer than the timer example because a service can do more. It needs a config for startup, an event stream, and command types for later interaction.
That extra shape is the cost of making the lifecycle explicit instead of magical.
Step 6: mount the service with a ServiceResource
If the sync session should exist while a screen is visible, declare it as a resource.
#[fission_action]
pub struct SyncStarted;
#[fission_action]
pub struct SyncEventArrived;
#[fission_action]
pub struct SyncStartFailed;
impl Widget<DashboardState> for DashboardScreen {
fn build(&self, ctx: &mut BuildCtx<DashboardState>, view: &View<DashboardState>) -> Node {
let on_started = ctx.bind(
SyncStarted,
reduce_with!(on_sync_started),
);
let on_event = ctx.bind(
SyncEventArrived,
reduce_with!(on_sync_event),
);
let on_start_failed = ctx.bind(
SyncStartFailed,
reduce_with!(on_sync_start_failed),
);
ctx.resources.service(
ServiceResource::new(
ResourceKey::new("workspace-sync"),
ServiceSlot::singleton(SYNC_SERVICE),
SyncConfig {
workspace_id: "primary".into(),
},
)
.on_started(on_started)
.on_event(on_event)
.on_start_failed(on_start_failed),
);
Text::new(view.state.sync_status.clone()).into_node()
}
}
This declaration says: while this part of the widget tree exists, keep a sync service mounted with this configuration.
That is much easier to reason about than starting a background thread from a button handler and hoping you remember to stop it later.
Step 7: feed service events back into reducers
The service lifecycle still returns to ordinary reducer code.
fn on_sync_started(
state: &mut DashboardState,
_action: SyncStarted,
_ctx: &mut ReducerContext<DashboardState>,
) {
state.sync_connected = true;
state.sync_status = "Connected".into();
}
fn on_sync_event(
state: &mut DashboardState,
_action: SyncEventArrived,
ctx: &mut ReducerContext<DashboardState>,
) {
if let Some(event) = ctx.input.service_event(SYNC_SERVICE) {
match event {
SyncEvent::Connected => {
state.sync_connected = true;
state.sync_status = "Connected".into();
}
SyncEvent::SyncFinished { summary } => {
state.sync_status = summary;
}
}
}
}
fn on_sync_start_failed(
state: &mut DashboardState,
_action: SyncStartFailed,
ctx: &mut ReducerContext<DashboardState>,
) {
state.sync_connected = false;
state.sync_status = ctx
.input
.service_start_error_message(SYNC_SERVICE)
.unwrap_or("Sync failed to start")
.to_string();
}
This is the same architectural pattern you have already seen with actions and reducers. Long-running behavior is still explicit, and state changes still happen in one place.
When to use a reducer-started service instead
This page used ServiceResource because the service lifetime was tied to the screen.
If the service should exist because of an explicit product action rather than because a subtree is mounted, start it from a reducer with ctx.effects.start_service(...) instead.
That is a good fit when the user deliberately turns sync on, starts a session, or connects to something that should outlive one route's visible widget tree.
The question is always the same: who should own the lifetime?
If the user interface subtree owns it, declare a resource.
If the product flow owns it, start it from the reducer.
What to avoid
Do not use a one-shot job for something that should keep running over time. You will end up rebuilding a fragile service out of repeated job calls.
Do not create ad hoc background loops in build() or shell hooks when the runtime can own the lifecycle for you.
Do not forget stable resource keys. If a key changes every build, the runtime will treat the resource as new work every time.
Where to go next
If your repeating work depends on host permissions or device integrations, pair this page with Run typed host work. If you want the deeper architecture behind jobs, services, resources, and ReducerContext, read Resources and async.