I have started learning low level system design and elevator is the first problem i picked up. In this i have picked the single elvator for simplicity and not get overwhelmed by it and get demotivated.

So I built an elevator system. Lets improve this in stages.

Not because it’s trendy. I have thought countless times standing in elevator does how it works. and after this i have my answer.

What is an Elevator System?

An elevator system manages the movement of elevators withing in a building serving different floor. When someone request an elevator, the sytem desides which request to serve and in what order to move the elevator to serve the request.

Requirements (What We’re Actually Building)

Before writing a single line of code, I sat down and defined what the system needs to do. This gives the clarity what we the system is required to behave.

  • A 10-floor building, floors 1 to 10

  • One elevator — no parallelism, no fleet management, just one car

  • The elevator moves up or down

  • Doors only open when the elevator is idle at a floor — not mid-journey

  • Door closes before elvator start moving or after some time it being kept open

  • Requests come in two flavours:

    • Outside button — someone on floor 7 presses “going up”
    • Inside button — someone already in the car presses “take me to floor 12”
  • The algorithm must be efficient — minimum movement, maximum throughput

  • No starvation — every request eventually gets served, no floor gets ignored forever


Planning the Solution

States and Directions

The elevator lives in one of four states at any given moment:

StateMeaning
idleParked, doors closed, waiting for work
movingTravelling between floors
openDoors open at a floor
closedDoors closing before departure

And it knows two directions: up and down.


The Queue Strategy (This Is the Important Part)

This is where the design actually gets interesting.

Instead of one flat list of requested floors, I maintain two sorted queues:

  • Up queue — sorted ascending (e.g. [3, 7, 11]) — floors to serve while going up
  • Down queue — sorted descending (e.g. [9, 5, 2]) — floors to serve while going down

This mirrors how a real elevator works. It doesn’t bounce between floor 1 and floor 10 randomly. It sweeps in one direction, serving everyone along the way, then reverses.

This is essentially a variant of the SCAN algorithm — the same idea used in hard disk scheduling. Serve one direction completely before switching. No starvation because both queues drain before the elevator parks.


Request Handling

  • Outside requests know the direction a person wants to travel, so they get dropped into the right queue automatically.
  • Inside requests know the current floor and the destination floor — the direction is inferred from the difference. Then it delegates to the outside request logic.

The Code Walkthrough

The Core Loop

The heart of the system is a work() loop. It runs while either queue has entries:

while (upQueue.isNotEmpty || downQueue.isNotEmpty) {
  // Switch direction if current queue is empty
  if (currentDirection == up && upQueue.isEmpty) {
    currentDirection = down;
  } else if (currentDirection == down && downQueue.isEmpty) {
    currentDirection = up;
  }

  final target = currentDirection == up ? upQueue.first : downQueue.first;
  final arrived = await operate(requestFloor: target, direction: currentDirection);

  if (arrived) {
    // Remove served floor from queue
  }
}

Clean. The elevator picks the next target based on its current direction, moves toward it floor by floor, and when it arrives — opens the door, waits, closes, and loops again.


Movement — One Floor at a Time

The operate() function moves the elevator one floor per call. It’s recursive-ish in nature — the loop calls it repeatedly until the elevator arrives:

if (currentFloor < requestFloor) {
  currentFloor++;
} else if (currentFloor > requestFloor) {
  currentFloor--;
}

On arrival, the door sequence kicks in: open → wait → close → continue.


Preventing Double Work

Both queues use a contains() check before inserting. If floor 5 is already queued going up, a second request for floor 5 going up gets dropped. No duplicate stops, no wasted trips.

if (!upQueue.contains(requestFloor)) {
  upQueue.add(requestFloor);
  upQueue.sort();
}

The Design Decisions I’m Proud Of

1. Two Queues Over One

A single queue with a priority value would work. But two purpose-built queues make the direction logic easy to read and debug. Clarity is a feature.


2. Async/Await for Real-Time Feel

The elevator runs on Dart’s async system. Each floor transition is a Future.delayed(). To mimic real life deplays. This means you can fire off new requests while the elevator is moving and they’ll be picked up on the next loop iteration — exactly like real life.

// Elevator is heading to floor 10
await Future.delayed(Duration(seconds: 3));

// New request arrives mid-journey — gets picked up automatically
elevator.outsideReq(requestFloor: 4, requestDirection: ElevatorDirections.up);

3. The isWorking Flag

To prevent race conditions and multiple work() spawnning, a boolean flag isWorking is used. If a request arrives while the elevator is already running, the loop just picks up the new queue entry on its next iteration.


Full Code

enum ElevatorDirections { up, down }

enum ElevatorState { open, closed, idle, moving }
class Elevator {
  final List<int> upQueue = [];
  final List<int> downQueue = [];

  int currentFloor = 0;
  int minFloor = 0;
  int maxFloor = 10;

  bool isWorking = false;

  ElevatorDirections currentDirection = ElevatorDirections.up;
  ElevatorState currentState = ElevatorState.idle;

  // ================= LOGGER =================
  void log(String message) {
    final time = DateTime.now().toIso8601String().substring(11, 19);
    print("[$time] $message");
  }

  // ================= REQUESTS =================
  void outsideReq({
    required int requestFloor,
    required ElevatorDirections requestDirection,
  }) {
    if (requestDirection == ElevatorDirections.up) {
      if (!upQueue.contains(requestFloor)) {
        upQueue.add(requestFloor);
        upQueue.sort();
      }
    } else {
      if (!downQueue.contains(requestFloor)) {
        downQueue.add(requestFloor);
        downQueue.sort((a, b) => b.compareTo(a));
      }
    }

    log("📥 OUTSIDE REQUEST → floor=$requestFloor direction=$requestDirection");
    log("📊 QUEUES → ↑$upQueue$downQueue");

    if (!isWorking) {
      work();
    }
  }

  void insideReq({required int currentFloor, required int destinationFloor}) {
    log("🧍 INSIDE REQUEST → from=$currentFloor to=$destinationFloor");

    if (destinationFloor > currentFloor) {
      outsideReq(
        requestFloor: destinationFloor,
        requestDirection: ElevatorDirections.up,
      );
    } else {
      outsideReq(
        requestFloor: destinationFloor,
        requestDirection: ElevatorDirections.down,
      );
    }
  }

  // ================= DOOR =================
  Future<void> openDoor() async {
    currentState = ElevatorState.open;
    log("🚪 OPENING at floor=$currentFloor");
    await Future.delayed(Duration(milliseconds: 400));
    log("🚪 OPENED at floor=$currentFloor");
  }

  Future<void> closeDoor() async {
    currentState = ElevatorState.closed;
    log("🚪 CLOSING");
    await Future.delayed(Duration(milliseconds: 400));
    log("🚪 CLOSED");
  }

  Future<void> setIdle() async {
    currentState = ElevatorState.idle;
    isWorking = false;
    log("🟢 IDLE at floor=$currentFloor");
  }

  // ================= CORE LOOP =================
  Future<void> work() async {
    isWorking = true;
    log("🚀 WORK STARTED | direction=$currentDirection | floor=$currentFloor");

    while (upQueue.isNotEmpty || downQueue.isNotEmpty) {
      // Direction switching
      if (currentDirection == ElevatorDirections.up && upQueue.isEmpty) {
        currentDirection = ElevatorDirections.down;
        log("🔄 SWITCHING DIRECTION → DOWN");
      } else if (currentDirection == ElevatorDirections.down &&
          downQueue.isEmpty) {
        currentDirection = ElevatorDirections.up;
        log("🔄 SWITCHING DIRECTION → UP");
      }

      final targetFloor = currentDirection == ElevatorDirections.up
          ? upQueue.first
          : downQueue.first;

      final isArrived = await operate(
        requestFloor: targetFloor,
        direction: currentDirection,
      );

      if (isArrived) {
        if (currentDirection == ElevatorDirections.up) {
          upQueue.removeAt(0);
        } else {
          downQueue.removeAt(0);
        }

        log("👤 SERVICED → floor=$targetFloor | ↑$upQueue$downQueue");
      }
    }

    await setIdle();
  }

  // ================= MOVEMENT =================
  Future<bool> operate({
    required int requestFloor,
    required ElevatorDirections direction,
  }) async {
    if (currentFloor < minFloor || currentFloor > maxFloor) {
      return false;
    }

    if (currentFloor == requestFloor) {
      log("🛑 ARRIVED at floor=$currentFloor");

      await openDoor();
      await closeDoor();

      return true;
    }

    await Future.delayed(Duration(milliseconds: 500));

    if (currentFloor < requestFloor) {
      currentFloor++;
      log("⬆️ MOVING UP → floor=$currentFloor (target=$requestFloor)");
    } else if (currentFloor > requestFloor) {
      currentFloor--;
      log("⬇️ MOVING DOWN → floor=$currentFloor (target=$requestFloor)");
    }

    return false;
  }
}

What I Learned

System design is about asking the right questions.

  • This is still not perfect and has many flaws, but to make anything perfect, we need a something to polish.
  • What are the constraints? (One elevator. Ten floors. No starvation.)
  • What’s the failure mode? (A naive single queue starves floors on the far end.)
  • What does the real-world analogue do? (Elevators sweep. So we sweep.)

The SCAN algorithm isn’t something I invented — it’s a decades-old concept from disk I/O scheduling. But recognising that my elevator problem and a disk scheduling problem share the same shape? That’s the skill. That’s what system design actually teaches you.