● David R. Longnecker
Chapter 12 · Backend

Background Scheduling

Not everything a server does happens in response to a request. Something needs to check whether a reminder is due, refresh a cached weather value, or clean up old log rows — on its own timetable, with nobody watching. This chapter builds a small scheduler for exactly that.

Recurring work in a long-running process #

Recall from Chapter 1 that your server is a process that stays running, not a script that runs once and exits. That's what makes background work possible at all: the process is already alive, so it can also keep an eye on the clock. A few examples of the kind of thing that belongs here:

None of these are triggered by a request. They're triggered by time.

A tick-loop scheduler #

The simplest version of a scheduler is a single setInterval that fires every few seconds — a "tick" — and on each tick, checks a list of jobs to see which ones are due. Each job knows how often it wants to run; the scheduler just tracks when each one last ran (or is next due) and calls it when the time comes.

apps/server/scheduler.js
class Scheduler {
  #jobs = [];

  register(name, intervalMs, run) {
    this.#jobs.push({ name, intervalMs, run, nextDue: Date.now() });
  }

  start() {
    setInterval(() => this.#tick(), 5000);
  }

  #tick() {
    const now = Date.now();
    for (const job of this.#jobs) {
      if (now >= job.nextDue) {
        job.nextDue = now + job.intervalMs;
        job.run();
      }
    }
  }
}
registering jobs at startup
const scheduler = new Scheduler();

scheduler.register('check-reminders', 30_000, checkDueReminders);
scheduler.register('refresh-weather', 15 * 60_000, refreshWeatherCache);
scheduler.register('prune-old-logs', 60 * 60_000, pruneOldLogs);

scheduler.start();

Five seconds is the tick's own resolution, not any individual job's interval — a job asking for every 30 seconds will actually run on the next tick at or after that mark, so its real cadence is accurate to within one tick. That's normally fine for background work; nothing here needs millisecond precision.

Why you need to track "in-flight" jobs #

The loop above has a real bug hiding in it: job.run() is called and the loop moves on immediately, but if run does anything asynchronous — a database call, a network request to refresh weather data — it might still be running when the next tick arrives. If that next tick sees the job is due again and starts a second overlapping run, you can end up with two copies of the same job racing each other against the same rows.

The fix is to track which jobs currently have an execution in-flight, and skip starting a new run if the previous one hasn't finished:

guarding against overlapping runs
class Scheduler {
  #jobs = [];
  #inFlight = new Set();

  register(name, intervalMs, run) {
    this.#jobs.push({ name, intervalMs, run, nextDue: Date.now() });
  }

  start() {
    setInterval(() => this.#tick(), 5000);
  }

  async #tick() {
    const now = Date.now();
    for (const job of this.#jobs) {
      if (now < job.nextDue) continue;
      if (this.#inFlight.has(job.name)) continue; // still running, skip this tick

      job.nextDue = now + job.intervalMs;
      this.#inFlight.add(job.name);
      Promise.resolve(job.run())
        .catch((err) => console.error(`job ${job.name} failed`, err))
        .finally(() => this.#inFlight.delete(job.name));
    }
  }
}

Now a slow job simply gets skipped on ticks where it's still busy, instead of stacking a second concurrent run on top of the first. The next tick after it finishes will see nextDue has passed and run it normally.

A timeline of ticks every five seconds, where a slow job's execution overlaps two ticks and is skipped on the second because it is still in-flight tick @0s tick @5s tick @10s tick @12s refresh-weather running (0s–8.5s) tick @5s: still in-flight, skipped tick @10s: runs again
The job takes longer than one tick interval. The scheduler notices it's still in-flight at the 5s tick and skips it, rather than starting a second overlapping run.
Watch out

Without the in-flight check, a job that occasionally runs slow (a flaky network call, a large cleanup query) can silently accumulate overlapping executions over time, each one competing for the same rows. This tends to show up as "the server got slower for no reason" long after the code that caused it shipped.

This scheduler only solves one of the three ways duplicate runs happen

The in-flight set above prevents overlap within a single running process. It does not protect against two things that matter just as much in production:

  • Crash mid-job. If the process dies while run() is halfway through — say, after emailing a reminder but before marking it sent — the next tick after restart has no memory that a run was ever attempted, and will happily run it again. A job whose effects are safe to repeat (an idempotent job — "delete rows older than 30 days" gives the same result whether it runs once or five times) doesn't need special handling for this. A job with a one-time side effect (sending an email, charging a card) does, typically by recording "attempted" before doing the risky part and checking that record on the next run.
  • More than one server process. This scheduler's #jobs and #inFlight live in one process's memory. Run two copies of this server — which is exactly what happens the moment you scale horizontally for reliability or load — and both copies independently believe they're the only one ticking, so both run every job on schedule, duplicated. Fixing this needs a lock or a claim that lives outside any one process (a database row, a dedicated job queue), not more code inside this class.

Neither fix is built here; both are worth knowing you'll eventually need before this pattern goes into anything beyond a single-instance side project.

One more gap worth a mention: a failed job here is caught and logged, then simply waits for its next scheduled tick — there's no retry with backoff for jobs that fail transiently and would likely succeed if tried again a few seconds later. Real job systems usually add one; this scheduler doesn't, to keep the core in-flight-tracking idea easy to see.

A second common job: pruning old data #

Scheduled work isn't only about refreshing things — it's just as often about deleting things. An events or activity log table that never trims itself will grow without bound. A pruning job is usually the simplest kind: one prepared statement, run on a slow cadence.

a pruning job, using the prepared-statement pattern from Chapter 10
function pruneOldLogs(db) {
  const cutoff = Date.now() - 90 * 24 * 60 * 60 * 1000; // 90 days
  const result = db.prepare('DELETE FROM events WHERE created_at < ?').run(cutoff);
  console.log(`pruned ${result.changes} old event rows`);
}

Because it's registered through the same scheduler as everything else, it automatically gets the same in-flight protection — if a prune run ever takes long enough to still be going at the next tick (unlikely for a single DELETE, but possible on a huge table), it won't be started twice.

Try it

Sketch a job called expire-reminders that runs every 60 seconds and marks any reminder whose due time has passed as done. What table and WHERE clause would it need? Would it need to publish an event over the bus from Chapter 11 so an open browser tab finds out immediately, rather than waiting for its own poll or reload?

You've now covered the four pillars of a backend: routing requests, storing data durably, pushing updates out, and doing work on a schedule. The next section turns to how all of this — frontend and backend alike — gets organized into a system that can grow without collapsing under its own complexity, starting with plugin architecture.