Back to BlogIndustry Guides

Field-Service Scheduling SaaS: Jobs, Routes, Techs

Rupak Amin

Founder & Lead Engineer, RAITHub

10 min read

RAITHub ships and tests production software. See QA as a Service or talk to us.

A field-service scheduling SaaS turns jobs into assignments: the right technician, with the right skills, at a time that fits the route, without double-booking. Build a job state machine, an assignment that respects skills and availability, routing that suggests an order rather than forcing one, and offline-tolerant sync so a technician in a basement still works. Then the schedule stays honest and no job is double-booked or forgotten.

This is engineering guidance. If you would rather have it built and tested for you, see how RAITHub would build this below.

What makes field-service scheduling hard?

Three constraints at once: skills (not every tech can do every job), availability (a tech has working hours and existing jobs), and geography (jobs should cluster to cut travel). A naive scheduler satisfies one and breaks the others: it books the nearest tech who cannot do the job, or the right tech into a slot they are already in. On top of that, the field is offline, so the mobile side must work without signal and sync cleanly later. The engineering is an assignment that respects all the constraints atomically, plus sync that does not lose or duplicate updates.

ConstraintHow it breaksThe correct rule
SkillsA job is assigned to a tech who cannot do itAssignment filters by required skill first
AvailabilityTwo jobs land in one tech's slotAssignment is atomic; the slot is reserved in one transaction
RoutingTechs crisscross the mapRoute is a suggested order, re-planned as the day changes
Offline fieldA completed job is lost when signal dropsOffline-first mobile with idempotent sync on reconnect

What does the data model look like?

A job has a state, required skills, a time window and an assigned tech; a tech has skills and availability. Assignments are explicit so double-booking is impossible.

CREATE TABLE jobs (
  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  state         text NOT NULL DEFAULT 'unscheduled',
                -- unscheduled | scheduled | en_route | on_site | completed | failed | cancelled
  required_skill text NOT NULL,
  window_start  timestamptz,
  window_end    timestamptz,
  tech_id       bigint REFERENCES technicians(id),   -- null until scheduled
  scheduled_at  timestamptz,
  state_since   timestamptz NOT NULL DEFAULT now(),
  created_at    timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE technician_skills (
  tech_id bigint NOT NULL REFERENCES technicians(id),
  skill   text NOT NULL,
  PRIMARY KEY (tech_id, skill)
);

-- A tech's booked slots, so assignment can reserve one atomically.
CREATE TABLE assignments (
  tech_id   bigint NOT NULL REFERENCES technicians(id),
  job_id    uuid NOT NULL REFERENCES jobs(id),
  starts_at timestamptz NOT NULL,
  ends_at   timestamptz NOT NULL,
  EXCLUDE USING gist (tech_id WITH =, tstzrange(starts_at, ends_at) WITH &&)  -- no overlap
);

The exclusion constraint makes overlapping assignments for one technician impossible at the database level, so two schedulers cannot both book the same slot, the same guarantee as an atomic stock decrement.

How does assignment pick a technician?

Filter, then choose, then reserve atomically. Filter to techs with the required skill and free in the window; choose by your rule (nearest, least loaded, soonest available); then reserve the slot in a transaction so the choice cannot be undone by a race. Keep the choosing rule a pure function of the current schedule so it is testable. The assignment and dispatch concern is the same shape as SLA-driven vendor dispatch in a maintenance ticketing SaaS, applied to your own technicians rather than external vendors.

BEGIN;
-- Reserve the slot; the exclusion constraint rejects an overlap, so a race loses.
INSERT INTO assignments (tech_id, job_id, starts_at, ends_at)
VALUES ($1, $2, $3, $4);           -- fails if $1 is already booked in that range
UPDATE jobs SET state = 'scheduled', tech_id = $1, scheduled_at = $3
WHERE id = $2 AND state = 'unscheduled';
COMMIT;

How should routing work?

As a suggestion, not a straitjacket. Compute a sensible order for each tech's day (fewest miles, or honouring time windows), but let dispatch and the tech reorder it, because the real day changes: a job overruns, a customer cancels, an emergency comes in. Re-plan when the schedule changes rather than locking a route at the start of the day. Keep the routing logic server-side so the office, the dispatcher and the tech's app all see the same plan, and treat an external routing or maps service as a suggestion source you can override.

How do you handle the offline field?

Field work happens where there is no signal, so the mobile side must be offline-first: the tech can see their jobs, update status, capture a signature or photo and mark a job complete without a connection, and it all syncs when signal returns. The sync must be idempotent and conflict-aware: a status update created offline carries its own ID and device timestamp, so replaying it on reconnect applies it once and in the right order, never duplicating a completed job or losing one. This is the same out-of-order, applied-once discipline used for delivery location updates in a delivery tracking platform.

How do you test a scheduling SaaS?

Test that skills are respected, that double-booking is impossible, and that offline updates sync once.

import { describe, it, expect } from 'vitest'
import { assign, syncUpdate, jobState } from './scheduling'

describe('field-service scheduling', () => {
  it('never assigns a tech who lacks the required skill', async () => {
    const job = await seedJob({ requiredSkill: 'gas' })
    const tech = await seedTech({ skills: ['electrical'] })
    await expect(assign(job.id, tech.id)).rejects.toThrow()
  })

  it('cannot double-book a technician into one slot', async () => {
    const tech = await seedTech({ skills: ['gas'] })
    const [a, b] = await seedJobs(2, { requiredSkill: 'gas', window: sameSlot() })
    const r = await Promise.allSettled([assign(a.id, tech.id), assign(b.id, tech.id)])
    expect(r.filter((x) => x.status === 'fulfilled').length).toBe(1)
  })

  it('applies an offline status update exactly once on sync', async () => {
    const job = await seedJob({ state: 'on_site' })
    const update = { id: 'u1', jobId: job.id, to: 'completed', at: '2026-10-11T14:00:00Z' }
    await syncUpdate(update)
    await syncUpdate(update)                      // replayed on reconnect
    expect(await jobState(job.id)).toBe('completed')
    expect(await completionCount(job.id)).toBe(1) // not duplicated
  })
})

Add a test for the stuck-job check (a job overdue in a state surfaces to dispatch) and one that out-of-order offline updates leave the correct final state.

Buy, build or hire?

OptionChoose this whenThe catch
A field-service management (FSM) SaaSYour trades, scheduling and mobile flow fit a vendor's modelYou fit their assignment rules, forms and fees; bespoke skills, pricing or integrations may not map
A shared calendar and messagingA couple of techs and a few jobs a dayNo skills matching, no atomic booking, no offline support; double-bookings and lost updates
Custom buildYou are building an FSM product to sell, or have scheduling and mobile needs no tool coversYou own the scheduler, the sync and the tests that keep the schedule honest

How long does it take to build yourself, and what is the risk?

For an experienced team building the job state machine, constraint-aware assignment, routing and an offline-first mobile flow, our estimate is 4 to 6 weeks for a first version at a fixed scope, or 6 to 12 weeks with optimisation, parts, invoicing and deep integrations. The main risks are two: an assignment that is not atomic, which double-books techs under load, and a mobile side that assumes connectivity, which loses field updates. Build the booking in the database and the mobile side offline-first, and test both the race and the replay.

Why RAITHub for this

  • Scheduling and dispatch in production. RAITHub built PropDesk with maintenance routing and five daily automation jobs (1,024 tests), and models multi-role workflows with the authorization and audit patterns these need.
  • Atomic booking. The same transactional discipline that prevents overselling keeps a technician from being double-booked.
  • Offline-tolerant builds. RAITHub builds PWAs for low-connectivity settings (PadhAI), so field updates sync cleanly rather than getting lost.

When you don't need us

  • An FSM SaaS fits. If a vendor's scheduling and mobile app match your trades, use it.
  • You have a couple of techs. A shared calendar may be enough at that size.
  • You only need the model. The job state machine and assignment above are a fair start for your own developer.

How RAITHub would build this

  • Job state machine: defined statuses with legal-only transitions, status changes as idempotent events.
  • Constraint-aware assignment: skill and availability filtering, a pure-function choice rule, and an atomic slot reservation the database enforces.
  • Routing: a suggested, re-plannable day order, server-side so office and field agree.
  • Offline-first mobile: work without signal and idempotent, conflict-aware sync on reconnect, plus a stuck-job check for dispatch.

Timeline: a first version fits the 4 to 6 week fixed-scope range; optimisation, parts, invoicing and integrations push it to 6 to 12 weeks. As a new vertical for RAITHub, this builds on its SaaS development service; a related new-vertical build is a delivery tracking platform.

You receive: the product on accounts you own, tests and CI covering the scheduler and sync, handover runbooks, and full IP under NDA. The next step is the SaaS development service and a free 15-minute audit.

Next step: book the free 15-minute technical audit with your trades, skills and scheduling rules, and we will follow up with a written fixed quote.

Frequently asked questions

What makes field-service scheduling hard to build?

Three constraints at once: skills (not every tech can do every job), availability (working hours and existing jobs), and geography (clustering jobs to cut travel), plus an offline field. A naive scheduler satisfies one and breaks the others, and loses updates when signal drops.

How does the scheduler pick a technician?

Filter to techs with the required skill who are free in the window, choose by your rule (nearest, least loaded, soonest available), then reserve the slot atomically in a transaction. A database exclusion constraint makes overlapping assignments for one tech impossible, so a race cannot double-book.

How do I stop double-booking a technician?

Reserve the slot inside a transaction backed by a database constraint that rejects overlapping assignments for the same tech. Two schedulers then cannot both book the same slot. A test runs two concurrent assignments into one slot and asserts exactly one succeeds.

How should routing work in a field-service app?

As a suggestion, not a fixed plan. Compute a sensible order for each tech's day but let dispatch and the tech reorder it, and re-plan when the day changes, because jobs overrun and emergencies come in. Keep routing server-side so office and field see the same plan.

How do field technicians work without signal?

Build the mobile side offline-first: the tech sees jobs, updates status, captures signatures and photos and completes jobs without a connection, and it all syncs on reconnect. Each update carries its own ID and device time, so replaying it applies it once and in order, never duplicating or losing a job.

Should I build a scheduling SaaS or buy an FSM tool?

Buy when your trades, scheduling and mobile flow fit a vendor's model. Build when you are creating a field-service product to sell, or your skills matching, pricing, forms or integrations are not covered by an off-the-shelf tool.

field serviceschedulingtechnician assignmentroutingoffline syncsaas

Ready to discuss your project?

Book a free 15-minute technical audit with our engineering team.