#5599·vueuse

useUserMedia: concurrent/aborted start leaks a MediaStream and leaves the camera active

Author: haoku123Created Aug 20, 2026Updated Aug 21, 2026

Describe the bug

useUserMedia's internal _start() guards against concurrent calls by checking stream.value, but that check happens before the await:

async function _start() {
  if (!isSupported.value || stream.value)
    return
  stream.value = await navigator!.mediaDevices.getUserMedia({ ... })
  return stream.value
}

While getUserMedia() is in flight, stream.value is still undefined, so the guard doesn't hold. This leads to two distinct problems.

1. Concurrent starts orphan a MediaStream. Two overlapping _start() calls both pass the guard and both call getUserMedia(). The second assignment overwrites the first, and the first stream is never stopped — its camera/mic tracks stay live with no reference left to stop them.

2. stop() during an in-flight start doesn't stop anything. _stop() reads stream.value, which is still undefined mid-flight, so it stops nothing and sets undefined. The pending getUserMedia() then resolves and assigns stream.value after the stop. The result is an active camera after the user has explicitly disabled it — the hardware indicator stays on.

Both are reachable from the public API:

  • The enabled watcher calls _start() without awaiting it, so toggling enabled quickly (or mounting and unmounting fast) interleaves calls:
    watch(enabled, (v) => {
      if (v) _start()
      else _stop()
    }, { immediate: true })
    
  • start() is public and awaits _start(), but nothing prevents a second start() (or an enabled flip) from overlapping the first.

restart() is the same shape — _stop() immediately followed by start().

Reproduction

Reduced from the actual implementation, with getUserMedia stubbed to take 20ms:

let streamValue
const live = new Set()
let n = 0
const getUserMedia = () => new Promise(r =>
  setTimeout(() => { const s = { id: ++n, getTracks: () => [{ stop() { live.delete(s) } }] }; live.add(s); r(s) }, 20))

async function _start() {
  if (streamValue) return
  streamValue = await getUserMedia()
}
function _stop() {
  streamValue?.getTracks().forEach(t => t.stop())
  streamValue = undefined
}

// case 2: enabled true -> false immediately
_start()
_stop()
await new Promise(r => setTimeout(r, 60))
console.log(streamValue?.id)              // 1  -- assigned after stop()
console.log([...live].map(s => s.id))     // [1] -- never stopped, camera still on

Case 1 (_start(); _start()) leaves two streams created and stream 1 unstoppable.

Expected behavior

  • Overlapping starts should not create more than one live MediaStream.
  • A stop() issued while a start is pending should ensure the resulting stream is stopped rather than adopted, so the camera/mic is actually released.

Possible fix

Track the pending promise and/or a generation counter, then discard a resolved stream if it's stale:

let pending: Promise<MediaStream> | undefined
let generation = 0

async function _start() {
  if (!isSupported.value || stream.value || pending) return pending
  const gen = ++generation
  pending = navigator!.mediaDevices.getUserMedia({ ... })
  try {
    const s = await pending
    if (gen !== generation) {          // superseded by a stop/restart
      s.getTracks().forEach(t => t.stop())
      return
    }
    stream.value = s
    return s
  } finally {
    pending = undefined
  }
}

_stop() would bump generation so an in-flight stream is stopped on arrival instead of assigned.

I'd rather confirm the preferred shape before sending a patch — happy to open a PR with tests once you've picked a direction. Note there's currently no test file under packages/core/useUserMedia/, so I'd add one.

I checked #5392 (stopOnDispose) and it only touches dispose behaviour, so it doesn't overlap with this.

Additional context

Found while reviewing the media composables ahead of the WebRTC discussion in #4270. My day job is maintaining a WebRTC-based cloud gaming web SDK, where this exact class of bug — a getUserMedia/getDisplayMedia promise resolving after teardown and leaving hardware live — is a recurring source of "camera light stays on" reports.

useDisplayMedia has the same _start() shape and looks affected in the same way; I can confirm and fold it in if useful.