skip to content

In RxJS, how would you wrap the browser's navigator.geolocation.watchPosition in an Observable so that unsubscribing stops the position watch?

level: middleimportance: must knowfreq 60%

answer

  1. the constructor takes a subscriber function
  2. callbacks forward to next and error
  3. keep the watch id
  4. return a teardown that clears it

basics

~10 s

Use new Observable(subscriber => ...): call watchPosition inside it, forward positions to subscriber.next and failures to subscriber.error, and return a teardown function that calls clearWatch with the stored watch id.

solid answer

~30 s

I create it with `new Observable<GeolocationPosition>(subscriber => { ... })`. Inside the subscriber function I call `navigator.geolocation.watchPosition`, passing a success callback that calls `subscriber.next(position)` and an error callback that calls `subscriber.error(err)`, and I keep the returned watch id. The function returns a **teardown**, `() => navigator.geolocation.clearWatch(id)`. Because the function only runs on `subscribe()`, no watch starts until someone subscribes, and each subscriber gets its own watch. RxJS runs the teardown when the consumer unsubscribes and also after the stream errors, so the browser stops tracking in both cases. I never call `complete()`: a position watch has no natural end.

code

ts · 26 lines
ts
import { Observable } from 'rxjs';

export function watchPosition$(options?: PositionOptions): Observable<GeolocationPosition> {
  return new Observable<GeolocationPosition>((subscriber) => {
    if (!('geolocation' in navigator)) {
      subscriber.error(new Error('Geolocation is not available'));
      return;
    }

    const watchId = navigator.geolocation.watchPosition(
      (position) => subscriber.next(position),
      (err) => subscriber.error(err),
      options,
    );

    return () => navigator.geolocation.clearWatch(watchId);
  });
}

const subscription = watchPosition$({ enableHighAccuracy: true }).subscribe({
  next: (p) => console.log(p.coords.latitude, p.coords.longitude),
  error: (err) => console.error('position failed', err),
});

// later, when the map screen closes
subscription.unsubscribe(); // runs clearWatch(watchId)

go deeper

for a junior

Remember the shape: new Observable with a function that forwards callbacks to next and error and returns a function that undoes the setup.

for a middle

Walk through when the subscriber function runs, what RxJS does with the returned teardown, and why the teardown also runs after the error callback ends the stream.

for a senior

Show that you think about leaks and cost: one watch per subscriber, permission prompts triggered only by real consumers, and a unit test proving clearWatch runs on every ending.

for a principal

Discuss where such wrappers should live and how a team standardises them, so every browser callback API in the codebase is exposed with the same lazy, cancellable contract.

## What the Observable constructor expects RxJS's `Observable` constructor takes one argument, the **subscriber function**: `new Observable<T>(subscriber => { ... })`. RxJS stores it and calls it each time someone subscribes, passing a `Subscriber<T>` that exposes the **Observer** methods: - `subscriber.next(value)` pushes a value to the consumer; - `subscriber.error(err)` ends the stream with a failure; - `subscriber.complete()` ends the stream successfully; - `subscriber.closed` tells the producer whether the consumer is still listening. The function may **return teardown logic** - RxJS's `TeardownLogic` type is a function, a `Subscription`, any object with an `unsubscribe()` method, or nothing. RxJS adds whatever is returned to the subscription and runs it when the subscription ends. ## Mapping the browser API onto the contract The browser's `navigator.geolocation.watchPosition(success, error, options)` is a classic callback API: it starts tracking, calls `success` on every position fix, calls `error` on failure, and returns a numeric **watch id**. Tracking continues until `navigator.geolocation.clearWatch(id)` is called. Each piece has a direct counterpart: | Browser API | RxJS contract | |---|---| | `watchPosition(...)` call | the body of the subscriber function | | success callback | `subscriber.next(position)` | | error callback | `subscriber.error(err)` | | `clearWatch(id)` | the returned teardown function | | no natural end | no `complete()` call | ## Walking through the lifecycle 1. **Construction.** Calling the factory returns an Observable; no watch exists yet because the subscriber function has not run. 2. **Subscribe.** A consumer calls `subscribe()`. RxJS invokes the function, which starts the watch and returns the teardown. RxJS attaches that teardown to the returned `Subscription`. 3. **Values.** Each position fix calls `subscriber.next`, which reaches the consumer's `next` callback. 4. **Unsubscribe.** The consumer calls `unsubscribe()`. RxJS marks the subscription closed and runs the teardown, which calls `clearWatch`, so the device stops tracking. 5. **Error path.** If the browser reports a failure, `subscriber.error(err)` delivers it to the consumer's `error` callback, and the `Subscriber` then unsubscribes itself - so the teardown runs on this path too, without the consumer doing anything. ## Design points interviewers probe - **Laziness is a feature here.** A permission prompt and battery-hungry tracking should start only when a screen actually needs positions. Because the work sits inside the subscriber function, merely creating the Observable asks for nothing. - **One watch per subscriber.** Two independent subscriptions start two browser watches. If several parts of an app need the same positions, share one execution with RxJS's sharing operators rather than changing the wrapper. - **Do not call `complete()` from the error callback.** An error already ends the stream; after it the `Subscriber` ignores further notifications. - **Guard for a missing API.** If `navigator.geolocation` is unavailable, call `subscriber.error(...)` and return nothing - there is no watch to clear. - **Return the teardown, do not only store it.** Forgetting the `return` is the classic leak: the stream looks correct in a demo, but after the component that used it is gone the watch keeps firing into a closed subscriber. ## Parameters and typing Making the wrapper a **factory function** such as `watchPosition$(options)` rather than a single constant lets each caller choose `enableHighAccuracy`, `timeout` or `maximumAge`, and those options are read when the subscriber function runs, per subscription. Typing it as `Observable<GeolocationPosition>` gives consumers the browser's own position type in their `next` callback, so nothing downstream needs casts, and the error callback receives whatever the browser reported. ## Why not the other shortcuts A **Promise** wrapper (`new Promise(resolve => watchPosition(resolve))`) would deliver the first fix and then keep a watch running that nothing can stop, because a Promise has no teardown and settles only once. **`getCurrentPosition`** is the one-shot browser API and is a better fit when a single fix is enough. Hand-rolled callback lists with an `on`/`off` API re-implement what the Observable contract already gives: typed values, a terminal error, and a cancellation hook in one object. ## Testing the wrapper Because the only side effects are `watchPosition` and `clearWatch`, a unit test can replace `navigator.geolocation` with a stub, subscribe, push a fake position through the captured success callback, assert that the consumer received it, then unsubscribe and assert that `clearWatch` was called with the stored id. Driving the captured error callback should prove that the consumer's `error` callback ran and that `clearWatch` was called without an explicit `unsubscribe()`.

  • Two RxJS subscribers subscribe to the same watchPosition$() Observable - how many browser watches are running?
    Two. A constructor-built Observable runs its subscriber function once per `subscribe()` call, so each subscriber starts its own `watchPosition` and has its own teardown. That is correct isolation, but if several consumers need the same fixes you add a sharing operator on top instead of changing the wrapper.
  • In an RxJS Observable, could the subscriber function register the teardown with subscriber.add() instead of returning it?
    Yes. `Subscriber` extends `Subscription`, so `subscriber.add(() => navigator.geolocation.clearWatch(id))` registers the same finalizer and RxJS runs it on unsubscribe, error or complete. Returning the function is the conventional form; `add()` is handy when a producer acquires several resources at different points and wants to register each as it goes.

saying these in an interview costs you the question

  • The watch should start when the Observable is created, not when it is subscribed.
  • The teardown only runs if the consumer calls unsubscribe(), never after an error.
  • Calling subscriber.complete() after subscriber.error() is needed to close the stream.
  • Storing the watch id in a field is enough; returning a teardown is optional.
  • All subscribers of a constructor-built Observable share one browser watch automatically.