🔴 The Error You're Seeing

Confirm this matches your console output. If it does, you're in the right place.

ERROR LOGjava.lang.IllegalStateException: Timer already cancelled. at java.base/java.util.Timer.sched(Timer.java:408) at java.base/java.util.Timer.schedule(Timer.java:204) at com.devinhyderabad.heartbeat.Heartbeat.pulse(Heartbeat.java:29)

⚡ Quick Fix Works 80% of the time

Guard the timer’s lifecycle with a flag, or better, move to ScheduledThreadPoolExecutor — Timer is one-shot by design.

private final AtomicBoolean cancelled = new AtomicBoolean(); public void stop() { if (cancelled.compareAndSet(false, true)) { timer.cancel(); // exactly one caller wins } } public void pulse() { if (!cancelled.get()) { timer.schedule(task, 1_000); // never schedules post-cancel } }

🧠 Why this Happens

Tap to expand the deep technical explanation

Timer keeps a queue guarded by a state field; cancel() transitions it to terminal, and sched() — reached by every schedule variant — throws this IllegalStateException for any subsequent submission. The deeper trap sits nearby: Timer runs all tasks on ONE thread, so any task that throws an unchecked exception kills that thread silently and every future task simply never runs, with no exception anywhere. Cancelled-or-dead, the class has no recovery story beyond constructing a new instance.

The HITEC City Parking Spot Analogy:

A club with one doorkeeper who quits the moment anything odd happens (task exception) or the manager closes the venue (cancel). New guest requests bounce off a padlocked door — the sign either says CLOSED forever or nothing says anything at all.

🔁 How to Reproduce Confirm this is your error

new Timer().cancel(), then schedule(anyTask, 1000): IllegalStateException straight out of Timer.sched. (Lab capture: OpenJDK Temurin 25.0.2.)

🛠️ Solutions (5 Ways to Fix)

Solution 1✓ Most common cause

Migrate to ScheduledThreadPoolExecutor

👉 Use this when/if you control the scheduling code and want exceptions isolated.

A scheduled pool runs tasks on multiple workers, survives individual task failures (with a wrapped handler to log them), exposes pool diagnostics like any executor, and supports fixed-rate and fixed-delay semantics identically to Timer. This migration removes both the cancelled-state trap and the silent-death trap at once.

ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2); scheduler.scheduleAtFixedRate( () -> { try { heartbeat.beat(); } catch (RuntimeException e) { log.warn("heartbeat failed; continuing", e); } }, 1, 1, TimeUnit.SECONDS);
Solution 2

Make cancellation single-shot and idempotent

👉 Use this when/if staying on Timer through a transition period.

An AtomicBoolean gate ensures cancel() executes once and every schedule() consults the same truth, converting the race between teardown and background producers into orderly refusal instead of an exception storm during shutdown.

if (live.compareAndSet(true, false)) { timer.cancel(); // exactly one thread closes it } // producers check live.get() before every schedule
Solution 3

Wrap TimerTasks so one failure cannot kill the thread

👉 Use this when/if legacy constraints force Timer to remain.

Since Timer dies silently on any uncaught RuntimeException, wrapping each task body in try/catch preserves the scheduler for all remaining tasks. Log loudly — the wrapper is the only thing standing between one bad task and total scheduling blackout.

timer.schedule(new TimerTask() { public void run() { try { business.work(); } catch (Throwable t) { log.error("timer task failed; timer kept alive", t); } } }, delay);
Solution 4

Treat Timer instances as disposable: recreate to restart

👉 Use this when/if restart-after-cancel genuinely reflects your lifecycle.

A cancelled Timer can never accept work again, so model restart as construction: a factory method returns a fresh Timer plus its guard state. Explicit recreation documents reality — versus callers hoping a dead instance revives.

Timer freshTimer() { Timer t = new Timer("heartbeat", true); live.set(true); return t; }
Solution 5

DEV ONLY: share one static Timer across unrelated components

👉 Use this only to observe cross-component coupling explode — never in real code.

A global Timer means any component’s cancel() (or one component’s throwing task) terminates scheduling for every other feature sharing the instance, each discovering it through this very exception or through silent non-execution.

// ANTI-PATTERN — do not ship static final Timer SHARED = new Timer(); // component A cancels; component B's schedule() now explodes

📋 Version Notes

Java 8

Identical terminal-state behavior; message unchanged since introduction.

Java 11

No change; Timer remains legacy with silent-death semantics intact.

Java 21

Prefer virtual-thread-friendly scheduling or delayedExecutor for lightweight deferred work.

🛡️ How to Prevent This Next Time

Ban raw Timer in favor of ScheduledThreadPoolExecutor via architecture tests, wrap every scheduled body defensively regardless of scheduler, and exercise shutdown ordering in CI so cancel-versus-schedule races fail tests instead of production heartbeats.