SharePointVault ~/gotchas/graph ⌕ Search 404 pages, error messages, scripts…Ctrl K ★ Star
M↓ README.mdM↓ reread-fresh-before-bulk-membership-removal.md
sharepoint-vault›gotchas›graph›reread-fresh-before-bulk-membership-removal.mdhistoryedit on GitHub
● GOTCHAgraph· 4 min read

Re-read fresh right before you bulk-remove group members

applies to: Microsoft Graph (delegated), SPFxreviewed: 2026-07-24#graph#groups#data-safety

The roster you showed the user is a preview, not a source of truth — before a removeMember loop, re-read the membership strict and fresh; a read error must abort (never delete blindly), a member who vanished meanwhile is an idempotent skip, and each delete needs its own try/catch.

Seznam členů, který jsi ukázal uživateli, je náhled, ne zdroj pravdy – před smyčkou removeMember přečti členství znovu, strict a čerstvě; chyba čtení musí akci zrušit (nikdy nemazat naslepo), člen, který mezitím zmizel, je idempotentní přeskočení a každé smazání potřebuje vlastní try/catch.

Symptom

An access-review or cleanup flow shows a roster ("12 guests"), the reviewer clicks Remove all, and one of these goes wrong:

  • The list was fetched with a defensive catch → [] wrapper. On a throttling blip it returned [], the UI showed "0 members", and the remove loop did nothing — or worse, a different code path fed a partial list into a delete-then-reconcile step.
  • The reviewer opened the panel minutes ago. Between preview and click, someone else changed the group; the loop removes people based on a stale snapshot and reports success it can't back up.
  • One removeMember call fails (last owner, synced/dynamic member) and, because the loop wasn't per-item guarded, it aborts mid-way leaving a half-done removal with no accurate count.

Cause

Two different reads are being conflated:

  1. The read for display — safe, catch → [], good for rendering an empty widget instead of crashing. [] here means "couldn't look", not "nothing there".
  2. The read that authorizes a destructive write — must be strict (throw on failure) and taken fresh, immediately before the write, not from component state captured at mount/preview time.

DELETE /groups/{id}/members/{userId}/$ref is irreversible for the membership. Driving it from mood-1 data — or from a snapshot that has since drifted — turns a resilience feature into data loss or a phantom "done".

Fix

Give the membership read both moods and re-read strict inside the action handler, right before the loop:

// WRONG — acts on the roster shown earlier, no per-item guard
async function removeAll(groupId: string, shownRoster: Member[]) {
  for (const m of shownRoster) {
    await graph.api(`/groups/${groupId}/members/${m.id}/$ref`).delete();
  }
}

// RIGHT — fresh strict re-read, idempotent skip, per-item results
async function removeAll(groupId: string) {
  const fresh = await getMembersStrict(groupId); // THROWS on failure — a read error must not delete blindly
  let ok = 0, fail = 0;
  for (const m of fresh) {
    try {
      await graph.api(`/groups/${groupId}/members/${m.id}/$ref`).delete();
      ok++;
    } catch {
      fail++; // per-item: one failure (last owner, synced member) doesn't abort the rest
    }
  }
  return { ok, fail }; // report what you saw: "removed 11, 1 failed"
}

For a single-target removal (remove this one guest), the same rule collapses to a guard:

const fresh = await getGuestsStrict(groupId);            // throws → caller's catch skips the delete
if (fresh.some(g => g.id === target.id)) {
  await graph.api(`/groups/${groupId}/members/${target.id}/$ref`).delete();
} // else: already gone — sync the UI, don't surface an error (idempotent skip)

Rules that keep it safe:

  1. Read strict and fresh the membership that authorizes the delete — never the preview array from render state.
  2. Read error ⇒ abort, don't fall back to [] and "remove nothing / remove blindly".
  3. Vanished target ⇒ idempotent skip, not a failure — the desired end state (not a member) already holds.
  4. Per-item try/catch in the loop, and surface the count (removed N, M failed) rather than a bare "done".
  5. Hide the action entirely for memberships you can't change this way — synced (on-prem) and dynamic groups — so the user never triggers a guaranteed 4xx.

Notes

  • This is the Microsoft Graph / membership face of the general data-safety rule in Silent fallbacks poison destructive writes — same principle (strict-and-fresh before a write), specialized for removeMember loops and the preview-then-act timing gap.
  • The gap is a genuine TOCTOU (time-of-check to time-of-use): the fresh re-read shrinks the window to milliseconds but can't eliminate it, which is exactly why per-item guards and an honest result count matter.
  • getMembers/getGuests should page all results ($top=999, follow @odata.nextLink) — a strict read that silently stops at 100 is its own quiet bug.
  • Don't let a Promise.all couple a critical read to a nice-to-have one. A panel that loads the roster with Promise.all([getMembers, getGuests]) — where getMembers authorizes the removal and getGuests only labels who's a guest — rejects the whole thing when getGuests blips, blocking a removal that getMembers alone could safely drive. Keep the critical read strict and make the decorative one best-effort: Promise.all([getMembers, getGuests.catch(() => null)]), then degrade gracefully (show the roster, disable the "guests only" filter) when the optional read came back null. Only the read that gates the write should be able to abort it.
Was this helpful?ask or add your experience on GitHub →
Saved you an hour? A star on GitHub helps other SharePoint people find the vault.★ Star on GitHub
last reviewed: 2026-07-24last updated: 2026-09-24historyedit on GitHubview source