<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Microsoft Graph in C# on Jeppe Spanggaard - Software Developer | .NET, Azure &amp; Microsoft 365</title><link>https://jeppe-spanggaard.dk/tags/microsoft-graph/</link><description>Recent content in Microsoft Graph in C# on Jeppe Spanggaard - Software Developer | .NET, Azure &amp; Microsoft 365</description><generator>Hugo</generator><language>en-US</language><lastBuildDate>Sun, 16 Aug 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jeppe-spanggaard.dk/tags/microsoft-graph/index.xml" rel="self" type="application/rss+xml"/><item><title>List View Threshold: What Still Works at 10,000 Items</title><link>https://jeppe-spanggaard.dk/blogs/list-view-threshold-what-still-works/</link><pubDate>Sun, 16 Aug 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/list-view-threshold-what-still-works/</guid><description>Learn which SharePoint queries survive the 5,000 item list view threshold, with CAML joins, CSOM, REST, Graph and $batch measured on a 10,000 item list.</description><content:encoded><![CDATA[<p>I&rsquo;ve lost count of how many times I&rsquo;ve hit this. A list quietly grows past 200,000 items, somebody asks for a filtered view of it, and the code that has worked for two years answers with this instead:</p>
<pre tabindex="0"><code>Microsoft.SharePoint.Client.ServerException: The attempted operation is
prohibited because it exceeds the list view threshold.
</code></pre><p>The code didn&rsquo;t change. It never does.</p>
<p>CSOM has an escape hatch for this, <code>AllowIncrementalResults</code>, and it gets <a href="https://jeppe-spanggaard.dk/blogs/caml-allowincrementalresults-list-view-threshold/">a post of its own</a>. What I could never answer was the obvious follow-up. Is CSOM the only place with an answer? Every time I&rsquo;d needed to filter past the threshold I&rsquo;d ended up back in CSOM, but I&rsquo;d never actually gone and checked what REST and Graph do. Saying &ldquo;I don&rsquo;t know&rdquo; for two years is a long time.</p>
<p>So I took the harness from <a href="https://jeppe-spanggaard.dk/blogs/caml-joins-across-lists/">the CAML joins benchmark</a>, seeded a second four-list chain at 10,000 items per list, and ran everything again. Ten thousand is not two hundred thousand, but it&rsquo;s twice the threshold, which is where the behaviour changes.</p>
<h2 id="what-actually-breaks-at-the-sharepoint-list-view-threshold">What actually breaks at the SharePoint list view threshold?</h2>
<p>Not reading. I can still pull all 10,000 rows out of any of these APIs, and it costs two requests. What breaks is filtering: the moment a <code>&lt;Where&gt;</code> or a <code>$filter</code> touches a column SharePoint hasn&rsquo;t indexed, the query is refused, and it&rsquo;s refused even when the answer would be ten rows. The threshold is about the scan, not the result set. That single sentence explains almost everything below.</p>
<h2 id="reading-was-never-the-problem">Reading Was Never the Problem</h2>
<p>The first thing I measured is the thing nobody worries about, and it turns out to be fine:</p>
<table>
  <thead>
      <tr>
          <th>Reading all 10,000 rows</th>
          <th style="text-align: right">Requests</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CAML join over CSOM, <code>RowLimit</code> paging</td>
          <td style="text-align: right">2</td>
      </tr>
      <tr>
          <td>SharePoint REST, <code>$top=5000</code> plus <code>odata.nextLink</code></td>
          <td style="text-align: right">2</td>
      </tr>
      <tr>
          <td>Graph, <code>$top=5000</code> plus <code>@odata.nextLink</code></td>
          <td style="text-align: right">2</td>
      </tr>
  </tbody>
</table>
<p>Two requests each, because 10,000 rows is two pages of 5,000. An unfiltered read past the threshold is allowed on all three. If your job is &ldquo;give me the whole list&rdquo;, the threshold barely exists.</p>
<p>That&rsquo;s worth saying plainly because the error message sends people hunting for the wrong fix. You don&rsquo;t need to restructure your list to read it.</p>
<h2 id="the-flag-that-shouldnt-work-and-does">The Flag That Shouldn&rsquo;t Work, and Does</h2>
<p>Here&rsquo;s the one I got wrong, and I was confident about it.</p>
<p>The most useful thing a CAML join does is let you filter on a column that lives several lists away, by projecting it and putting the <code>&lt;Where&gt;</code> on the projection. But a projected field is not a column on the list. It doesn&rsquo;t exist there, so it cannot be indexed. And the documented rule for <code>AllowIncrementalResults</code> is that the fields you filter and sort on still have to be indexed.</p>
<p>I was sure that made the technique impossible past 5,000 items. I wrote the probe expecting to have to publish a correction.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> query = <span style="color:#66d9ef">new</span> CamlQuery
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    ViewXml = viewXml,               <span style="color:#75715e">// &lt;Joins&gt;, &lt;ProjectedFields&gt;, &lt;Where&gt; on customerNo</span>
</span></span><span style="display:flex;"><span>    AllowIncrementalResults = <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> all = <span style="color:#66d9ef">new</span> List&lt;ListItem&gt;();
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">do</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> items = list.GetItems(query);
</span></span><span style="display:flex;"><span>    context.Load(items);
</span></span><span style="display:flex;"><span>    context.Load(items, i =&gt; i.ListItemCollectionPosition);
</span></span><span style="display:flex;"><span>    context.ExecuteQuery();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    all.AddRange(items);
</span></span><span style="display:flex;"><span>    query.ListItemCollectionPosition = items.ListItemCollectionPosition;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">while</span> (query.ListItemCollectionPosition <span style="color:#66d9ef">is</span> not <span style="color:#66d9ef">null</span>);
</span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li>Without <code>AllowIncrementalResults</code>, this exact query is refused: <code>SPQueryThrottledException</code>, straight away, no rows.</li>
<li>With it, the same query returns all 5,000 matching rows in two requests. The filter is on <code>customerNo</code>, which is projected from a list two hops away and cannot be indexed anywhere.</li>
<li><code>ListItemCollectionPosition</code> is the cursor and it is not optional. A <code>&lt;RowLimit&gt;</code> on its own doesn&rsquo;t page, it truncates, and truncation is the failure mode you don&rsquo;t notice.</li>
<li>You need both properties loaded. <code>context.Load(items)</code> alone won&rsquo;t populate the position on every code path, and a null cursor looks exactly like a finished result set.</li>
</ol>
<p>So the join survives the threshold. My best guess at why is that the projection is resolved after the join rather than as a scan predicate, but that&rsquo;s inference, not something I can prove from the outside. What I can say is that it returned the right 5,000 rows, ten times in a row.</p>
<p>Then I checked whether the depth matters, because it easily could have. Everything above filters on a value two lists away, which is the shape I&rsquo;ve written about before. So I moved the query one list further back and made the projection travel three joins instead of two:</p>
<table>
  <thead>
      <tr>
          <th>Projection travels</th>
          <th>Without the flag</th>
          <th>With the flag</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Two joins</td>
          <td><code>SPQueryThrottledException</code></td>
          <td>5,000 rows, 2 requests</td>
      </tr>
      <tr>
          <td>Three joins</td>
          <td><code>SPQueryThrottledException</code></td>
          <td>5,000 rows, 2 requests</td>
      </tr>
  </tbody>
</table>
<p>Identical, right down to the request count. Whatever the flag is doing, it isn&rsquo;t running out of road at the second hop, and a filter three lists from the one you&rsquo;re querying is as viable past the threshold as a filter one list away.</p>
<h2 id="three-apis-three-different-refusals">Three APIs, Three Different Refusals</h2>
<p>Filter on a column that genuinely isn&rsquo;t indexed, and all three refuse. How they refuse is where they differ.</p>
<table>
  <thead>
      <tr>
          <th>API</th>
          <th>Status</th>
          <th>What you get</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CSOM</td>
          <td>exception</td>
          <td><code>SPQueryThrottledException</code></td>
      </tr>
      <tr>
          <td>SharePoint REST</td>
          <td><strong><code>500</code></strong></td>
          <td>the same exception, wrapped in an OData error body</td>
      </tr>
      <tr>
          <td>Graph</td>
          <td><strong><code>400</code></strong></td>
          <td>a message that names the fix</td>
      </tr>
  </tbody>
</table>
<p>Graph wins this one, and it isn&rsquo;t close:</p>
<pre tabindex="0"><code>Field &#39;Title&#39; cannot be referenced in filter or orderby as it is not
indexed. Provide the &#39;Prefer: HonorNonIndexedQueriesWarningMayFailRandomly&#39;
header to allow this, but be warned that it may fail randomly.
</code></pre><p>Add the header and the same request returns <code>200</code>. Read the header&rsquo;s name again before you reach for it, though. Somebody at Microsoft went to the trouble of putting &ldquo;MayFailRandomly&rdquo; in an API contract, and that is not the kind of thing you build a nightly job on.</p>
<p>REST answering <code>500</code> for this is the one that annoys me. Filtering an unindexed column on a large list is a completely predictable, documented, business-as-usual refusal. It is not an internal server error.</p>
<h2 id="the-rest-trick-that-only-works-under-5000">The REST Trick That Only Works Under 5,000</h2>
<p>In the previous post I found something I didn&rsquo;t expect: SharePoint REST will let you filter on an expanded lookup&rsquo;s column, even one that isn&rsquo;t the lookup&rsquo;s <code>ShowField</code>.</p>
<pre tabindex="0"><code>$filter=CustomerLookUp/customerNo eq &#39;C-NARROW&#39;&amp;$expand=CustomerLookUp
</code></pre><p>At 1,000 items that returns <code>200</code> and it&rsquo;s the single thing that got the OData route down to two requests. At 10,000 items it returns <code>500</code>, in every scenario I threw at it, along with the batched version of the same call.</p>
<p>So it&rsquo;s a sub-threshold trick. It works beautifully right up until the list is big enough for it to matter, which is a fair description of a lot of SharePoint behaviour. If you have built anything on that shape, it has an expiry date measured in rows.</p>
<h2 id="the-one-that-lies-to-you">The One That Lies to You</h2>
<p><code>RenderListDataAsStream</code> is how you run a CAML join over REST, and in the last post it came through with the projected columns intact. Past the threshold it does something worse than failing.</p>
<p>Asked for 100 matching rows out of 10,000, it returned <strong>47</strong>. Asked for 5,000, it returned 2,504. One request, <code>200 OK</code>, no error, no <code>NextHref</code> to follow, no indication that anything is missing. It stops when the scan window closes and hands you what it found so far as though that were the answer.</p>
<p>A truncated result with a <code>200</code> on it is the worst outcome in this whole post. Everything else either works or tells you it didn&rsquo;t.</p>
<h2 id="what-it-costs-in-memory">What It Costs in Memory</h2>
<p>This is the part I hadn&rsquo;t measured before, and it&rsquo;s the strongest argument in the post. Below, allocated managed bytes while answering the same question: <strong>100 matching rows out of 10,000</strong>.</p>
<table>
  <thead>
      <tr>
          <th>Approach</th>
          <th>Filter</th>
          <th style="text-align: right">Allocated</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CAML join, CSOM</td>
          <td>server</td>
          <td style="text-align: right"><strong>1.4 MB</strong></td>
      </tr>
      <tr>
          <td>CSOM per list, reverse</td>
          <td>server</td>
          <td style="text-align: right">2.3 MB</td>
      </tr>
      <tr>
          <td>Graph, reverse</td>
          <td>server</td>
          <td style="text-align: right">2.6 MB</td>
      </tr>
      <tr>
          <td>Graph <code>$batch</code>, reverse</td>
          <td>server</td>
          <td style="text-align: right">3.1 MB</td>
      </tr>
      <tr>
          <td>SharePoint REST, forward</td>
          <td>client</td>
          <td style="text-align: right">11.5 MB</td>
      </tr>
      <tr>
          <td>SharePoint REST <code>$batch</code>, forward</td>
          <td>client</td>
          <td style="text-align: right">16.9 MB</td>
      </tr>
      <tr>
          <td>Graph, forward</td>
          <td>client</td>
          <td style="text-align: right">50.9 MB</td>
      </tr>
      <tr>
          <td>Graph <code>$batch</code>, forward</td>
          <td>client</td>
          <td style="text-align: right">55.6 MB</td>
      </tr>
      <tr>
          <td>CSOM batched</td>
          <td>client</td>
          <td style="text-align: right">188.0 MB</td>
      </tr>
      <tr>
          <td>CSOM per list, forward</td>
          <td>client</td>
          <td style="text-align: right"><strong>200.0 MB</strong></td>
      </tr>
  </tbody>
</table>
<p>One hundred rows. Two hundred megabytes.</p>
<p>The split is exactly the <code>Filter</code> column. Every approach that filters on the server materialises about a hundred rows. Every approach that filters in C# has to pull the whole list into memory first, then throw away 99% of it, and at 10,000 items that is 200 MB of allocation to produce 100 objects you keep. Your production lists are twenty times bigger than my test list.</p>
<p>Two things surprised me here. Allocation does not rank the same as bytes on the wire: CSOM allocates roughly eleven times its payload because a <code>ListItem</code> is a heavy object with a field dictionary attached, so 17 MB of response becomes 200 MB of garbage. And the join is not the memory winner when you ask for <em>everything</em>, only when you ask for <em>some</em>. Unfiltered, lean OData that fetches nothing but lookup ids allocates 17 MB against the join&rsquo;s 105 MB, because the join is honestly returning full rows and OData is returning integers.</p>
<h2 id="the-whole-field-at-10000-items">The Whole Field at 10,000 Items</h2>
<p>Filtering to 5,000 rows of 10,000, entering at the order tasks list, two hops to the customer:</p>
<table>
  <thead>
      <tr>
          <th>Approach</th>
          <th>Filter</th>
          <th style="text-align: right">Requests</th>
          <th style="text-align: right">Queries</th>
          <th style="text-align: right">Median</th>
          <th style="text-align: right">Allocated</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CAML join, CSOM</td>
          <td>server</td>
          <td style="text-align: right"><strong>2</strong></td>
          <td style="text-align: right"><strong>2</strong></td>
          <td style="text-align: right"><strong>1,096 ms</strong></td>
          <td style="text-align: right">52.7 MB</td>
      </tr>
      <tr>
          <td>SharePoint REST <code>$batch</code></td>
          <td>client</td>
          <td style="text-align: right">2</td>
          <td style="text-align: right">5</td>
          <td style="text-align: right">1,752 ms</td>
          <td style="text-align: right">17.0 MB</td>
      </tr>
      <tr>
          <td>SharePoint REST, forward</td>
          <td>client</td>
          <td style="text-align: right">5</td>
          <td style="text-align: right">5</td>
          <td style="text-align: right">2,021 ms</td>
          <td style="text-align: right">12.2 MB</td>
      </tr>
      <tr>
          <td>CSOM per list, reverse</td>
          <td>server</td>
          <td style="text-align: right">12</td>
          <td style="text-align: right">12</td>
          <td style="text-align: right">2,870 ms</td>
          <td style="text-align: right">97.7 MB</td>
      </tr>
      <tr>
          <td>CSOM batched</td>
          <td>client</td>
          <td style="text-align: right">2</td>
          <td style="text-align: right">5</td>
          <td style="text-align: right">3,110 ms</td>
          <td style="text-align: right">188.5 MB</td>
      </tr>
      <tr>
          <td>Graph <code>$batch</code>, reverse</td>
          <td>server</td>
          <td style="text-align: right">5</td>
          <td style="text-align: right">62</td>
          <td style="text-align: right">3,311 ms</td>
          <td style="text-align: right">162.9 MB</td>
      </tr>
      <tr>
          <td>CSOM per list, forward</td>
          <td>client</td>
          <td style="text-align: right">23</td>
          <td style="text-align: right">23</td>
          <td style="text-align: right">5,157 ms</td>
          <td style="text-align: right">200.5 MB</td>
      </tr>
      <tr>
          <td>Graph <code>$batch</code>, forward</td>
          <td>client</td>
          <td style="text-align: right">3</td>
          <td style="text-align: right">3</td>
          <td style="text-align: right">6,369 ms</td>
          <td style="text-align: right">56.0 MB</td>
      </tr>
      <tr>
          <td>Graph, forward</td>
          <td>client</td>
          <td style="text-align: right">5</td>
          <td style="text-align: right">5</td>
          <td style="text-align: right">9,052 ms</td>
          <td style="text-align: right">52.9 MB</td>
      </tr>
      <tr>
          <td>Graph, reverse</td>
          <td>server</td>
          <td style="text-align: right">62</td>
          <td style="text-align: right">62</td>
          <td style="text-align: right">13,970 ms</td>
          <td style="text-align: right">140.0 MB</td>
      </tr>
      <tr>
          <td>CAML join over REST</td>
          <td>-</td>
          <td style="text-align: right">1</td>
          <td style="text-align: right">1</td>
          <td style="text-align: right">wrong answer</td>
          <td style="text-align: right">-</td>
      </tr>
      <tr>
          <td>SharePoint REST, reverse</td>
          <td>-</td>
          <td style="text-align: right">-</td>
          <td style="text-align: right">-</td>
          <td style="text-align: right"><code>HTTP 500</code></td>
          <td style="text-align: right">-</td>
      </tr>
      <tr>
          <td>SharePoint REST <code>$batch</code>, reverse</td>
          <td>-</td>
          <td style="text-align: right">-</td>
          <td style="text-align: right">-</td>
          <td style="text-align: right"><code>HTTP 500</code></td>
          <td style="text-align: right">-</td>
      </tr>
  </tbody>
</table>
<p>The join is still first, and at 10,000 items it is now first by a wider margin than at 1,000, because everything else has to page and it only has to page twice.</p>
<p>Note what happened to Graph&rsquo;s reverse walk: 62 requests. The or-chains that fitted in a handful of calls at 1,000 items become sixty-two at 10,000, and batching turns 62 round trips into 5 while leaving all 62 queries exactly where they were.</p>
<h2 id="gotchas">Gotchas</h2>
<ul>
<li><strong>The exception message is localised.</strong> My tenant answers in Danish: <code>Den forsøgte handling er ikke tilladt, fordi den overskrider grænsen for listevisning</code>. My first probe matched on the string &ldquo;list view threshold&rdquo; and therefore never detected a single hit. Match on <code>ex.ServerErrorTypeName == &quot;Microsoft.SharePoint.SPQueryThrottledException&quot;</code> instead. Any retry logic or alerting built on the message text is broken in every tenant that isn&rsquo;t English.</li>
<li><strong><code>&lt;RowLimit&gt;</code> without a cursor is a truncation, not a page.</strong> This is how you get a wrong answer with no error. One of my own arms returned 47 rows out of 100 because it read the first 5,000 items of a 10,000 item list and then filtered what it happened to have.</li>
<li><strong>Batching collapses queries, never pages.</strong> A cursor only exists once the previous response has arrived, so a batch can carry every list&rsquo;s page one and then has to go again for page two. That&rsquo;s true of CSOM&rsquo;s <code>ExecuteQuery</code>, of SharePoint&rsquo;s <code>$batch</code>, and of Graph&rsquo;s. Graph&rsquo;s docs suggest batching as a way around URL length limits; I measured the same 91 clause ceiling inside a batch as outside it, so that particular workaround doesn&rsquo;t apply here.</li>
<li><strong><code>&lt;In&gt;</code> past 60 values is fine, and I&rsquo;m one of the people who told you otherwise.</strong> I repeated the 60-value claim in the joins post and cited the archived 2013 article it comes from. On 10,000 items with an indexed lookup column I ran 50, 60, 61, 100 and 500 values, and every one of them returned. The 500-value hard cap is real: 501 still throws <code>Value does not fall within the expected range</code>. It&rsquo;s the 60 that I can&rsquo;t reproduce.</li>
<li><strong>The ceiling moves.</strong> One reverse-traversal arm hit the threshold in one scenario and sailed through the same shape of query in another. Large list throttling is a service-side feature with a time-of-day component, so &ldquo;it worked this morning&rdquo; is not a test result.</li>
<li><strong>Your own tooling will hit this too.</strong> The setup step of my harness crashed while verifying the data it had just seeded, because it ran an <code>&lt;IsNull&gt;</code> check with <code>&lt;RowLimit&gt;10&lt;/RowLimit&gt;</code>. Ten rows requested, still refused. I had written that check myself and still didn&rsquo;t see it coming.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>Reading a big list is easy on every API. Filtering it is where they separate, and they separate hard: CSOM throws something you can catch, REST throws a <code>500</code>, Graph throws a <code>400</code> with instructions, and <code>RenderListDataAsStream</code> hands you a partial answer with a <code>200</code> on it.</p>
<p>The advice from the previous post survives the threshold, with one line added. Make SharePoint do the join, set <code>AllowIncrementalResults = true</code>, and page with the cursor. It stays the fastest correct answer at 10,000 items, and it&rsquo;s the only approach that filters server-side without resolving the ids yourself first, which past the threshold has stopped being a performance question and become a memory one.</p>
<p>Everything else is holding your whole list in RAM to find a hundred rows in it, and it is doing that whether or not anyone measured it.</p>
]]></content:encoded></item><item><title>CAML Joins Across Lists: One Query Instead of Four</title><link>https://jeppe-spanggaard.dk/blogs/caml-joins-across-lists/</link><pubDate>Sat, 15 Aug 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/caml-joins-across-lists/</guid><description>Learn how a CAML join across SharePoint lists linked by lookup columns compares with separate queries, OData expands and Graph, measured on the same data.</description><content:encoded><![CDATA[<p>I&rsquo;ve been telling people to use CAML joins since 2025. I wrote <a href="https://jeppe-spanggaard.dk/blogs/joining-multiple-lists-csom-caml/">a whole post about it</a>. I put it in <a href="https://jeppe-spanggaard.dk/blogs/csom-vs-sharepoint-rest-vs-graph/">my pick-one playbook</a> as a reason CSOM still owns list items.</p>
<p>And I had never measured it.</p>
<p>Not properly. Never all the reasonable ways of fetching the same data, against the same lists, on the same day. It&rsquo;s always been case by case: this feels like the better solution here, that one felt slow last time. In my head, one call instead of four <em>has</em> to be faster, and that was enough.</p>
<p>That&rsquo;s not a benchmark. That&rsquo;s a hunch I&rsquo;d been repeating with confidence.</p>
<p>So I built the comparison I should have built a year ago. It moved my numbers, and it also corrected something I&rsquo;d been saying wrong for a year.</p>
<h2 id="is-a-caml-join-actually-faster-than-four-separate-queries">Is a CAML join actually faster than four separate queries?</h2>
<p>Yes, and by more than I expected. Fetching 1,000 rows across a three-hop lookup chain, the CAML join needed <strong>1 HTTP request and about 254 ms</strong>. The same rows through four separate CSOM queries took <strong>6 requests and 1,192 ms</strong>, OData expands over SharePoint REST took <strong>4 requests and 548 ms</strong>, and Microsoft Graph took <strong>4 requests and 1,655 ms</strong>. The join wasn&rsquo;t just fewer calls, it was 4.7x faster than the four-query CSOM version and 6.5x faster than Graph. The closest anything got was SharePoint REST with <code>$batch</code>, at 355 ms, and that one deserves its own paragraph rather than a footnote.</p>
<h2 id="same-rows-eight-ways">Same Rows, Eight Ways</h2>
<p>Four lists, each pointing at the next through a lookup column:</p>
<pre tabindex="0"><code>Main -&gt; OrderTasks -&gt; Orders -&gt; Customers
</code></pre><p>Every approach has to return the same thing for each of the 1,000 items in the main list: its id, its title, and the <code>customerNo</code> and <code>customerName</code> from a customer sitting three hops away.</p>
<p>The join version, built with <a href="https://github.com/sadomovalex/camlex">CAMLEX</a>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#66d9ef">string</span> OrderTasks = <span style="color:#e6db74">&#34;ordertasks&#34;</span>, Orders = <span style="color:#e6db74">&#34;orders&#34;</span>, Customers = <span style="color:#e6db74">&#34;customers&#34;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>CamlexNET.Interfaces.IQuery query = Camlex.Query()
</span></span><span style="display:flex;"><span>    .LeftJoin(x =&gt; x[<span style="color:#e6db74">&#34;OrderTaskLookUp&#34;</span>].ForeignList(OrderTasks))
</span></span><span style="display:flex;"><span>    .LeftJoin(x =&gt; x[<span style="color:#e6db74">&#34;OrderDetailLookUp&#34;</span>].PrimaryList(OrderTasks).ForeignList(Orders))
</span></span><span style="display:flex;"><span>    .LeftJoin(x =&gt; x[<span style="color:#e6db74">&#34;CustomerLookUp&#34;</span>].PrimaryList(Orders).ForeignList(Customers))
</span></span><span style="display:flex;"><span>    .ProjectedField(x =&gt; x[<span style="color:#e6db74">&#34;customerNo&#34;</span>].List(Customers).ShowField(<span style="color:#e6db74">&#34;customerNo&#34;</span>))
</span></span><span style="display:flex;"><span>    .ProjectedField(x =&gt; x[<span style="color:#e6db74">&#34;customerName&#34;</span>].List(Customers).ShowField(<span style="color:#e6db74">&#34;customerName&#34;</span>))
</span></span><span style="display:flex;"><span>    .ViewFields([<span style="color:#e6db74">&#34;ID&#34;</span>, <span style="color:#e6db74">&#34;Title&#34;</span>, <span style="color:#e6db74">&#34;customerNo&#34;</span>, <span style="color:#e6db74">&#34;customerName&#34;</span>]);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> items = mainList.GetItems(query.ToCamlQuery());
</span></span><span style="display:flex;"><span>context.Load(items);
</span></span><span style="display:flex;"><span>context.ExecuteQuery();
</span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li>Each <code>LeftJoin</code> walks one lookup hop. <code>PrimaryList</code> is the list you&rsquo;re coming <em>from</em>, <code>ForeignList</code> the one you&rsquo;re going <em>to</em>, which is what lets hops two and three start somewhere other than the main list. Those arguments are <strong>aliases you invent</strong>, not list ids: they&rsquo;re <code>string</code>, and the compiler will tell you so (<code>cannot convert from 'System.Guid' to 'string'</code>) if you try otherwise. SharePoint resolves the actual list from the lookup column&rsquo;s own configuration, so <code>&quot;orders&quot;</code> works exactly as well as a GUID. I checked, because I&rsquo;d been passing GUIDs for a year without knowing they were only ever labels.</li>
<li><code>ProjectedField</code> pulls a column out of a joined list and gives it a name you can use as if it lived on the main list.</li>
<li><code>ViewFields</code> is not optional, and this is the part I&rsquo;d been getting away with rather than getting right. A projected field that isn&rsquo;t listed there comes back absent, with no error. Same query, same joins, one <code>&lt;FieldRef&gt;</code> removed: the column is simply not on the item.</li>
<li><code>ToCamlQuery()</code>, not <code>ToString()</code>. Bare <code>ToString()</code> gives you <code>&lt;Joins&gt;</code> and <code>&lt;ProjectedFields&gt;</code> as two sibling roots with no <code>&lt;View&gt;</code> around them, which isn&rsquo;t a well-formed document at all. SharePoint accepts it anyway and hands back all 1,000 rows, minus the projected columns, because there&rsquo;s no <code>&lt;ViewFields&gt;</code> in a fragment. A silent wrong answer is worse than an exception, and I&rsquo;d shipped that line.</li>
<li>The values come back as <code>FieldLookupValue</code>, not <code>string</code>, even when the source column is plain text. The text is in <code>.LookupValue</code>, which trips people up the first time.</li>
</ol>
<p>Every approach below returned an identical result set once normalised, checked by hashing the rows and comparing against a hash computed from the seed data. The wire shapes differ wildly, as you&rsquo;ll see. The data doesn&rsquo;t, and that part matters more than the timings: a fast query that quietly returns 998 rows instead of 1,000 isn&rsquo;t fast, it&rsquo;s broken.</p>
<h2 id="one-fat-request-beats-four-lean-ones">One Fat Request Beats Four Lean Ones</h2>
<p>1,000 items in the main list, 2 warmup runs discarded, 10 timed runs per approach, interleaved so no approach got a systematically better slot. Every arm asks for the minimum it needs and no more, and every arm is chunked or paged at the ceiling I measured for that specific API rather than at a number I liked the look of. That last part cost me a rerun, and I&rsquo;ll come back to it.</p>
<table>
  <thead>
      <tr>
          <th>Approach</th>
          <th style="text-align: right">HTTP requests</th>
          <th style="text-align: right">Queries</th>
          <th style="text-align: right">Median</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CAML join, CSOM</td>
          <td style="text-align: right"><strong>1</strong></td>
          <td style="text-align: right"><strong>1</strong></td>
          <td style="text-align: right"><strong>254 ms</strong></td>
      </tr>
      <tr>
          <td>SharePoint REST <code>$batch</code></td>
          <td style="text-align: right">1</td>
          <td style="text-align: right">4</td>
          <td style="text-align: right">355 ms</td>
      </tr>
      <tr>
          <td>CAML join, SharePoint REST *</td>
          <td style="text-align: right"><strong>1</strong></td>
          <td style="text-align: right"><strong>1</strong></td>
          <td style="text-align: right">427 ms</td>
      </tr>
      <tr>
          <td>CSOM batched, no join</td>
          <td style="text-align: right">1</td>
          <td style="text-align: right">4</td>
          <td style="text-align: right">518 ms</td>
      </tr>
      <tr>
          <td>SharePoint REST, OData expands</td>
          <td style="text-align: right">4</td>
          <td style="text-align: right">4</td>
          <td style="text-align: right">548 ms</td>
      </tr>
      <tr>
          <td>Microsoft Graph <code>$batch</code></td>
          <td style="text-align: right">1</td>
          <td style="text-align: right">4</td>
          <td style="text-align: right">613 ms</td>
      </tr>
      <tr>
          <td>CSOM, four separate queries</td>
          <td style="text-align: right">6</td>
          <td style="text-align: right">6</td>
          <td style="text-align: right">1,192 ms</td>
      </tr>
      <tr>
          <td>Microsoft Graph</td>
          <td style="text-align: right">4</td>
          <td style="text-align: right">4</td>
          <td style="text-align: right">1,655 ms</td>
      </tr>
  </tbody>
</table>
<p>* <code>RenderListDataAsStream</code>, not <code>GetItems</code>. REST exposes CAML two ways and only one of them returns the projected columns, which is the next section and the single most useful thing I learned building this. The row also carries that endpoint&rsquo;s view chrome, a couple of dozen fields per row that nothing can trim, so it is paying for bytes the CSOM join never sends.</p>
<p>Graph needs a request per list because it can&rsquo;t traverse a lookup. It isn&rsquo;t quite empty-handed: select the lookup&rsquo;s internal name and you get its display value alongside the id, so <code>OrderDetailLookUp</code> comes back as <code>&quot;Order 00000&quot;</code> next to <code>OrderDetailLookUpLookupId: &quot;1&quot;</code>. That&rsquo;s one column from the related item, for free, and it&rsquo;s the one the lookup was configured to show. Any other column of that item, or another hop beyond it, needs its own request. My chain wants <code>customerNo</code> and <code>customerName</code> three lists away, and a lookup has exactly one <code>ShowField</code>, so Graph fetches all four lists and joins them in C#.</p>
<p>Four requests, one per list. That number used to be seven, and the three extra ones were mine, not Graph&rsquo;s: I had paged at <code>$top=999</code> out of habit, because 999 is the ceiling stuck in my head from <code>/users</code> and directory objects. It isn&rsquo;t a SharePoint number. Graph&rsquo;s default here is 200 a page and it honours <code>$top</code> literally, which I only established by seeding a 1,200-item list and counting the first page: <code>$top=999</code> gives exactly 999, <code>$top=1001</code> gives exactly 1,001.</p>
<p>Don&rsquo;t read a <code>200 OK</code> as a yes, either. <code>$top=100000</code> is accepted too, and just returns the list. Graph never complains about an over-large <code>$top</code>, so a non-error tells you nothing and the only honest test is to hold more rows than you think the ceiling is. Mine ran out at 1,200 without finding one.</p>
<p>The OData row is the interesting loser. It&rsquo;s the leanest of the lot on the wire, because <code>odata=nometadata</code> is compact and I only selected the lookup id columns. It still loses on time, because four sequential round trips cost more than one fat one. Being economical doesn&rsquo;t help when you&rsquo;re economical four times in a row.</p>
<h2 id="caml-isnt-a-csom-feature-and-i-had-that-wrong">CAML Isn&rsquo;t a CSOM Feature, and I Had That Wrong</h2>
<p>Here&rsquo;s the correction. I&rsquo;ve been writing as though CAML joins were a CSOM capability, and framing the choice as CSOM versus REST. That&rsquo;s wrong twice over.</p>
<p>CAML is SharePoint&rsquo;s query language, not a CSOM feature, and SharePoint REST will run it. There are two routes, and they don&rsquo;t behave the same:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-http" data-lang="http"><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">POST /_api/web/lists(guid&#39;&lt;list-id&gt;&#39;)/GetItems
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">{ &#34;query&#34;: { &#34;ViewXml&#34;: &#34;&lt;View&gt;...&lt;/View&gt;&#34; } }
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">POST /_api/web/lists(guid&#39;&lt;list-id&gt;&#39;)/RenderListDataAsStream
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">{ &#34;parameters&#34;: { &#34;ViewXml&#34;: &#34;&lt;View&gt;...&lt;/View&gt;&#34; } }
</span></span></span></code></pre></div><p>They don&rsquo;t share a body shape, which is the first small tax. <code>GetItems</code> takes an <code>SP.CamlQuery</code> under <code>query</code>; <code>RenderListDataAsStream</code> takes a parameter bag under <code>parameters</code>. Send <code>odata=nometadata</code> and drop the <code>__metadata</code> annotation the old docs show, or you get <code>The property '__metadata' does not exist on type 'SP.CamlQuery'</code>.</p>
<p><code>RenderListDataAsStream</code> runs the join and gives you the projected columns:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{ <span style="color:#f92672">&#34;Row&#34;</span>: [{
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;ID&#34;</span>: <span style="color:#e6db74">&#34;1&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;Title&#34;</span>: <span style="color:#e6db74">&#34;Main 00000&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;customerNo&#34;</span>: <span style="color:#e6db74">&#34;C-F0026&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;customerName&#34;</span>: <span style="color:#e6db74">&#34;Filler Company 0026&#34;</span>
</span></span><span style="display:flex;"><span>}]}
</span></span></code></pre></div><p>That&rsquo;s the full three-hop join, over REST, one request. It&rsquo;s the <code>CAML join, SharePoint REST</code> row in the table above, and it beats every approach that doesn&rsquo;t join except one, which is the next section.</p>
<p>That snippet is trimmed, though, and the trimming matters. <code>RenderListDataAsStream</code> doesn&rsquo;t answer in the shape OData or CSOM do. The rows live under <code>Row</code> instead of <code>value</code>, every value is a string (<code>&quot;ID&quot;: &quot;1&quot;</code>, not <code>&quot;Id&quot;: 1</code>), and each row arrives with a couple of dozen fields you never asked for: <code>PermMask</code>, <code>FSObjType</code>, <code>UniqueId</code>, <code>ContentTypeId</code>, <code>SMTotalSize</code>, <code>ScopeId</code>, <code>owshiddenversion</code>, and <code>FileRef</code> in four separate encodings. <code>&lt;ViewFields&gt;</code> doesn&rsquo;t trim any of it, and neither does <code>RenderOptions: 2</code>. I&rsquo;d assumed that one was the &ldquo;just the list data&rdquo; flag; the docs define <code>ListData</code> as &ldquo;Return list data (same as <code>None</code>)&rdquo;, so it&rsquo;s the default output under a more promising name. It&rsquo;s a view-rendering endpoint, not an API, and you deserialize it accordingly.</p>
<p><code>GetItems</code> is where it gets nasty. Send the same join and you get <code>200 OK</code>, the right number of rows, and no <code>customerNo</code> or <code>customerName</code> anywhere in the response. The columns are simply gone.</p>
<p>Before you write to tell me I forgot <code>&lt;ViewFields&gt;</code>: I didn&rsquo;t, and I checked that specifically, because it&rsquo;s the obvious answer and Microsoft is explicit that a projected field has to be named there too. It was in the request. The same <code>&lt;ViewFields&gt;</code>, the same joins, the same projected fields come back fine over <code>RenderListDataAsStream</code> and fine over CSOM. Take the <code>&lt;FieldRef&gt;</code> out over CSOM and the value disappears exactly as documented, so the mechanism works and this isn&rsquo;t it. <code>GetItems</code> drops the projection with the request fully formed.</p>
<p>I assumed that meant the join had been ignored. It hasn&rsquo;t, and I made myself prove it rather than infer it from a row count. I put a <code>&lt;Where&gt;</code> on the projected <code>customerNo</code> and compared the returned item ids against the exact set my test data says should match: 10 of 1,000 at one value, 500 of 1,000 at another, and zero for a customer that doesn&rsquo;t exist. All three came back as exact set matches, not just matching counts. The clincher is that <code>customerNo</code> isn&rsquo;t a column on that list at all, so there is nothing local for the filter to resolve against; the projection is the only path.</p>
<p>So the join executes server-side, the filter across three lists is correct, and only the projected columns are lost on the way out. <code>GetItems</code> is usable for <em>finding</em> items by a value two lists away. You just can&rsquo;t read that value back, which makes it a fine filter and a useless projection.</p>
<p>Graph has neither route. Be careful how you state that, though, because &ldquo;Graph can&rsquo;t do lookups&rdquo; is too strong and I had it too strong myself until <a href="https://robwindsor.hashnode.dev/accessing-sharepoint-lookup-field-values-with-microsoft-graph">Rob Windsor&rsquo;s post</a> made me go and check. Graph reads a lookup&rsquo;s <em>value</em> perfectly well. What it cannot do is treat that lookup as a path to the rest of the related item.</p>
<p>I checked properly rather than taking anyone&rsquo;s word for it. Ask Graph to expand a lookup and it tells you exactly what&rsquo;s wrong:</p>
<pre tabindex="0"><code>Parsing OData Select and Expand failed: Could not find a property
named &#39;OrderDetailLookUp&#39; on type &#39;microsoft.graph.listItem&#39;.
</code></pre><p>The metadata says the same thing more permanently. Pull <code>https://graph.microsoft.com/v1.0/$metadata</code> and <code>listItem</code> declares six navigation properties: <code>analytics</code>, <code>documentSetVersions</code>, <code>driveItem</code>, <code>fields</code>, <code>permissions</code> and <code>versions</code>. A lookup column is not among them, and could not be. Your columns live inside <code>fields</code>, declared as <code>&lt;EntityType Name=&quot;fieldValueSet&quot; BaseType=&quot;graph.entity&quot; OpenType=&quot;true&quot; /&gt;</code>: an open type, whose properties are whatever your columns happen to be called. <code>fields</code> itself is a navigation property, which is why <code>$expand=fields</code> works at all, but nothing <em>inside</em> it is one, because a column invented at runtime can&rsquo;t be declared in a schema. There is nothing for <code>$expand</code> to follow. That&rsquo;s a design decision, not a gap in the documentation.</p>
<p>Which is why you get the display value and nothing more. The lookup&rsquo;s <code>ShowField</code> is copied into the field bag as a value, so it travels with the item. Everything else stays behind in the other list. One thing I did not test: SharePoint lets you tick extra columns when you create a lookup, under &ldquo;Add a column to show each of these additional fields&rdquo;. Those are <em>secondary</em> lookup columns on the list holding the lookup, and Graph models them as ordinary columns with <code>lookup.primaryLookupColumnId</code> pointing back at the primary, so they&rsquo;d presumably ride along the same way. That only ever spans one hop, so it wouldn&rsquo;t have rescued a three-list chain, but if your relationship is a single hop it&rsquo;s worth knowing about before you write a second request.</p>
<h2 id="batching-gets-you-the-round-trip-not-the-query-count">Batching Gets You the Round Trip, Not the Query Count</h2>
<p>This is the nuance I&rsquo;d have missed if I&rsquo;d only counted round trips, and it is where the join&rsquo;s lead is narrowest.</p>
<p>All three stacks can put several queries in one HTTP request. CSOM queues <code>GetItems</code> calls onto one context and sends them in a single <code>ExecuteQuery</code>. SharePoint REST has <code>POST /_api/$batch</code>, multipart and awkward but real. Graph has <code>POST /v1.0/$batch</code>, JSON and pleasant, capped at 20 sub-requests: the 21st comes back <code>Number of requests inside batch exceed the limit</code>.</p>
<p>And batching works. Here is the row I did not want to find:</p>
<table>
  <thead>
      <tr>
          <th></th>
          <th style="text-align: right">HTTP requests</th>
          <th style="text-align: right">Queries</th>
          <th style="text-align: right">Response bytes</th>
          <th style="text-align: right">Median</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CAML join, CSOM</td>
          <td style="text-align: right">1</td>
          <td style="text-align: right">1</td>
          <td style="text-align: right">988,195</td>
          <td style="text-align: right">254 ms</td>
      </tr>
      <tr>
          <td>SharePoint REST <code>$batch</code></td>
          <td style="text-align: right">1</td>
          <td style="text-align: right">4</td>
          <td style="text-align: right">156,502</td>
          <td style="text-align: right">355 ms</td>
      </tr>
      <tr>
          <td>CAML join, SharePoint REST</td>
          <td style="text-align: right">1</td>
          <td style="text-align: right">1</td>
          <td style="text-align: right">1,020,047</td>
          <td style="text-align: right">427 ms</td>
      </tr>
  </tbody>
</table>
<p>The REST one is uglier than it should be, because SharePoint&rsquo;s <code>$batch</code> is multipart rather than JSON. Each part is a whole HTTP request with its own headers, and the blank lines are load-bearing:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-http" data-lang="http"><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">POST /_api/$batch
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">Content-Type: multipart/mixed; boundary=batch_a1b2c3
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">--batch_a1b2c3
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">Content-Type: application/http
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">Content-Transfer-Encoding: binary
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">GET</span> https://the-tenant.example/sites/x/_api/web/lists(guid&#39;&lt;id&gt;&#39;)/items?$select=Id,Title&amp;$top=5000 <span style="color:#66d9ef">HTTP</span><span style="color:#f92672">/</span><span style="color:#ae81ff">1.1</span>
</span></span><span style="display:flex;"><span>Accept<span style="color:#f92672">:</span> <span style="color:#ae81ff">application/json;odata=nometadata</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>--batch_a1b2c3--
</span></span></code></pre></div><p>Repeat the part per query. The response comes back as <code>multipart/mixed</code> too, one <code>HTTP/1.1 200</code> block per sub-request in the order you sent them, which you then have to pull the JSON out of yourself. Graph&rsquo;s version is a JSON array of <code>{id, method, url}</code> and is far nicer to write, but it may reorder its responses, so key them by <code>id</code> rather than by position.</p>
<p><strong>On REST, batching four plain queries beats the join.</strong> Not on round trips, which tie at one, but on time and on an order of magnitude of bytes. The join is still the fastest thing overall, but &ldquo;always join&rdquo; is the wrong lesson: it&rsquo;s &ldquo;always join <em>on CSOM</em>&rdquo;. The REST join loses because <code>RenderListDataAsStream</code> ships a megabyte of view chrome nobody asked for, and four lean OData queries in one envelope simply carry less.</p>
<p>So batching genuinely buys the round trip. What it does not buy is the query count, and that column is the one to read. The join is one query. The batched arms are three or four, riding together. Whether SharePoint charges you per request or per operation is not something my data settles, and the throttling docs say resource units are counted per operation inside a batch, so I would not assume the envelope is free.</p>
<p>Batching also can&rsquo;t filter. It drags all four lists back whole, 3,051 rows to answer a question about 1,000 of them, because you don&rsquo;t know which ids you need until the first response comes back. Which brings me to the claim I&rsquo;ve been repeating for a year, and which this exercise did not so much confirm as dismantle.</p>
<p>I&rsquo;ve been saying &ldquo;6 resource units versus 2&rdquo;, on the basis that a multi-item query costs 2 resource units. Go back to <a href="https://learn.microsoft.com/en-us/sharepoint/dev/general-development/how-to-avoid-getting-throttled-or-blocked-in-sharepoint-online">the throttling page</a> and read which sentence that table sits under: &ldquo;<strong>Microsoft Graph APIs</strong> have a predetermined resource unit cost per request.&rdquo; Much further down, past four more tables and into the &ldquo;How to handle throttling?&rdquo; section, the same page says &ldquo;CSOM and REST don&rsquo;t have a predetermined resource unit cost, and they usually consume more resource units than Microsoft Graph APIs to achieve the same functionality.&rdquo;</p>
<p>So the 2-units-per-query figure was never a <em>price</em> for the CSOM queries I was applying it to. My arithmetic borrowed Graph&rsquo;s price list for a different shop. In fairness the same page does bless the number as an estimate, &ldquo;you can estimate the request rate using an average of 2 resource units per request&rdquo;, which is a reasonable way to size a request rate and not a per-call cost you get to multiply by four and quote as a fact. The direction of the argument survives, because fewer queries is still fewer queries. The certainty doesn&rsquo;t.</p>
<p>And I should say where I said it, because it&rsquo;s still sitting there in my own writing. The 2025 joins post I linked at the top counts the units out query by query in its code comments and closes on &ldquo;66% fewer resource units&rdquo;. The CSOM performance playbook runs the same arithmetic. Both of them are wrong in the same way, this paragraph is the correction, and I&rsquo;d rather point at them than quietly hope you don&rsquo;t click through.</p>
<p>I also can&rsquo;t replace it with a measurement. The documented per-app limits are real and my measurements match them: 1,250 resource units per minute and 1,200,000 per 24 hours, which is the row for tenants up to 1,000 licences. What you can&rsquo;t easily do is watch the meter. Microsoft&rsquo;s <a href="https://devblogs.microsoft.com/microsoft365dev/prevent-throttling-in-your-application-by-using-ratelimit-headers-in-sharepoint-online/">developer blog</a> says &ldquo;when the application has consumed 80% of its resource unit quota SharePoint will start to send RateLimit headers&rdquo;, and it never says which window that 80% is measured against. I found out by pushing: 1,416 requests in 14 seconds and the headers appeared, carrying <code>RateLimit-Limit: 1250</code>. That&rsquo;s the per-minute budget, not the daily one. It&rsquo;s also a state that evaporates in about ten seconds, which makes it useless as a measuring instrument.</p>
<p>And here&rsquo;s the part that made me stop trying. The same throttling page, in a section titled RateLimit headers, currently says: &ldquo;SharePoint Online does not return or support IETF RateLimit headers.&rdquo; I have them in a response body from this afternoon. The commit history explains it better than the page does: that section was headed &ldquo;RateLimit headers - preview&rdquo; until a docs change on 7 August 2026 deleted it, with the note that there had been a preview and it was no longer there. So what I caught was a preview being switched off underneath me, eight days before I went looking. Two sections higher the page still lists &ldquo;Use the <code>Retry-After</code> and <code>RateLimit</code> HTTP headers&rdquo; as a best practice, which it hasn&rsquo;t got round to retracting.</p>
<p>Honour <code>Retry-After</code>, treat a <code>RateLimit</code> header as a bonus that has already been withdrawn once, and don&rsquo;t build a measuring instrument on either. It does mean nobody should be quoting per-call resource unit costs at you, including me.</p>
<h2 id="then-i-added-a-where">Then I Added a WHERE</h2>
<p>The part of the original post I like most is filtering on a field several lists away. So the second scenario queries the OrderTasks list and filters on the customer&rsquo;s number, two hops out:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span>CamlexNET.Interfaces.IQuery query = Camlex.Query()
</span></span><span style="display:flex;"><span>    .Where(x =&gt; (<span style="color:#66d9ef">string</span>)x[<span style="color:#e6db74">&#34;customerNo&#34;</span>] == customerNo)
</span></span><span style="display:flex;"><span>    .LeftJoin(x =&gt; x[<span style="color:#e6db74">&#34;OrderDetailLookUp&#34;</span>].ForeignList(Orders))
</span></span><span style="display:flex;"><span>    .LeftJoin(x =&gt; x[<span style="color:#e6db74">&#34;CustomerLookUp&#34;</span>].PrimaryList(Orders).ForeignList(Customers))
</span></span><span style="display:flex;"><span>    .ProjectedField(x =&gt; x[<span style="color:#e6db74">&#34;customerNo&#34;</span>].List(Customers).ShowField(<span style="color:#e6db74">&#34;customerNo&#34;</span>))
</span></span><span style="display:flex;"><span>    .ProjectedField(x =&gt; x[<span style="color:#e6db74">&#34;customerName&#34;</span>].List(Customers).ShowField(<span style="color:#e6db74">&#34;customerName&#34;</span>))
</span></span><span style="display:flex;"><span>    .ViewFields([<span style="color:#e6db74">&#34;ID&#34;</span>, <span style="color:#e6db74">&#34;Title&#34;</span>, <span style="color:#e6db74">&#34;customerNo&#34;</span>, <span style="color:#e6db74">&#34;customerName&#34;</span>]);
</span></span></code></pre></div><p>The generated <code>Where</code> targets the projected field directly, and the value type is <code>Text</code>, not <code>Lookup</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-xml" data-lang="xml"><span style="display:flex;"><span><span style="color:#f92672">&lt;Where&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;Eq&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;FieldRef</span> <span style="color:#a6e22e">Name=</span><span style="color:#e6db74">&#34;customerNo&#34;</span> <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;Value</span> <span style="color:#a6e22e">Type=</span><span style="color:#e6db74">&#34;Text&#34;</span><span style="color:#f92672">&gt;</span>C-BROAD<span style="color:#f92672">&lt;/Value&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;/Eq&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/Where&gt;</span>
</span></span></code></pre></div><p>Before running it I assumed the join would lose here. Filtering first and walking <em>backwards</em> is the obvious smart move: find the customer, find their orders, find those orders&rsquo; tasks. Three tiny, highly selective queries instead of one join across the whole list.</p>
<p>It doesn&rsquo;t win. Filtering to 500 of 1,000 rows, the join answers in one request and 168 ms; the best reverse walk needs three requests and 365 ms, and the worst needs eight and 1,658 ms. The full table is below, at the shape you&rsquo;re more likely to ship.</p>
<p>Reverse traversal doesn&rsquo;t collapse because the idea is bad. It collapses because neither REST nor Graph has an <code>in</code> operator for a set of ids, so 500 ids become a chain of <code>or</code> clauses chopped into pieces that fit inside a query string. Thirteen requests for REST, eight for Graph, which gets to send far longer chains, and I&rsquo;ll come back to why.</p>
<p>Batching rescues some of that, but less than you&rsquo;d hope, and it&rsquo;s worth being precise about why. The stages are sequential: you can&rsquo;t ask for the orders until the customers have answered. So you can only batch <em>within</em> a stage, which is why REST reverse goes from thirteen requests to two and not to one. The thirteen queries are all still there.</p>
<p>At the other end, filtering down to the 10 rows that match <code>C-NARROW</code>, reverse traversal finally gets somewhere: REST does it in 2 requests and 187 ms, batched or not. Still slower than the same filter as a CAML join over CSOM, which answers in 1 request and 108 ms. And look at what those ten rows cost on the wire: REST reverse moves <strong>1,607 bytes</strong>, the CAML join moves 10,229, and CSOM walking forward moves <strong>1,825,534</strong>. Same ten rows. That is what client-side filtering means.</p>
<h2 id="more-than-one-customer-which-is-what-you-actually-write">More Than One Customer, Which Is What You Actually Write</h2>
<p>A single <code>Eq</code> is the demo. Real code usually asks for a set: give me the tasks for these three customers. That&rsquo;s an <code>&lt;In&gt;</code> over the projected field, and it&rsquo;s fair to ask whether the operator surface on a projected column is as complete as on a real one.</p>
<p>It is. I checked <code>&lt;In&gt;</code>, <code>&lt;BeginsWith&gt;</code> and <code>&lt;Neq&gt;</code> against the ids my test data says should match, over both CSOM and REST, and all six runs came back as exact set matches:</p>
<table>
  <thead>
      <tr>
          <th>Operator on the projected <code>customerNo</code></th>
          <th style="text-align: right">Expected rows</th>
          <th>CSOM</th>
          <th>REST</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>&lt;In&gt;</code> with three customers</td>
          <td style="text-align: right">520</td>
          <td>exact</td>
          <td>exact</td>
      </tr>
      <tr>
          <td><code>&lt;BeginsWith&gt;</code></td>
          <td style="text-align: right">490</td>
          <td>exact</td>
          <td>exact</td>
      </tr>
      <tr>
          <td><code>&lt;Neq&gt;</code></td>
          <td style="text-align: right">500</td>
          <td>exact</td>
          <td>exact</td>
      </tr>
  </tbody>
</table>
<p>So here&rsquo;s the whole field, at the shape you&rsquo;d actually ship: 520 rows belonging to three customers, and what it costs to get them without a join. The <strong>Filter</strong> column is the one that repays reading. Only the join and the reverse walks filter on the server. Every <code>client</code> row fetches the entire entry list and discards what doesn&rsquo;t match with a <code>Contains</code> in C#, because <code>customerNo</code> lives two hops from the list being queried and nothing but the join can express that predicate to SharePoint. A forward arm that filtered server-side would have to resolve the customer ids first, at which point it has become a reverse arm. That isn&rsquo;t a straw man I built, it&rsquo;s the shape of the problem.</p>
<table>
  <thead>
      <tr>
          <th>Approach</th>
          <th>Filter</th>
          <th style="text-align: right">HTTP requests</th>
          <th style="text-align: right">Queries</th>
          <th style="text-align: right">Median</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CAML join, CSOM</td>
          <td>server</td>
          <td style="text-align: right"><strong>1</strong></td>
          <td style="text-align: right"><strong>1</strong></td>
          <td style="text-align: right"><strong>169 ms</strong></td>
      </tr>
      <tr>
          <td>SharePoint REST <code>$batch</code></td>
          <td>client</td>
          <td style="text-align: right">1</td>
          <td style="text-align: right">3</td>
          <td style="text-align: right">255 ms</td>
      </tr>
      <tr>
          <td>CAML join, SharePoint REST *</td>
          <td>server</td>
          <td style="text-align: right">1</td>
          <td style="text-align: right">1</td>
          <td style="text-align: right">300 ms</td>
      </tr>
      <tr>
          <td>CSOM batched, no join</td>
          <td>client</td>
          <td style="text-align: right">1</td>
          <td style="text-align: right">3</td>
          <td style="text-align: right">351 ms</td>
      </tr>
      <tr>
          <td>SharePoint REST, OData forward</td>
          <td>client</td>
          <td style="text-align: right">3</td>
          <td style="text-align: right">3</td>
          <td style="text-align: right">367 ms</td>
      </tr>
      <tr>
          <td>SharePoint REST <code>$batch</code>, reverse</td>
          <td>server</td>
          <td style="text-align: right">2</td>
          <td style="text-align: right">13</td>
          <td style="text-align: right">377 ms</td>
      </tr>
      <tr>
          <td>CSOM per list, reverse</td>
          <td>server</td>
          <td style="text-align: right">4</td>
          <td style="text-align: right">4</td>
          <td style="text-align: right">455 ms</td>
      </tr>
      <tr>
          <td>Microsoft Graph <code>$batch</code></td>
          <td>client</td>
          <td style="text-align: right">1</td>
          <td style="text-align: right">3</td>
          <td style="text-align: right">509 ms</td>
      </tr>
      <tr>
          <td>CSOM per list, forward</td>
          <td>client</td>
          <td style="text-align: right">4</td>
          <td style="text-align: right">4</td>
          <td style="text-align: right">690 ms</td>
      </tr>
      <tr>
          <td>Microsoft Graph <code>$batch</code>, reverse</td>
          <td>server</td>
          <td style="text-align: right">3</td>
          <td style="text-align: right">9</td>
          <td style="text-align: right">732 ms</td>
      </tr>
      <tr>
          <td>Microsoft Graph, forward</td>
          <td>client</td>
          <td style="text-align: right">3</td>
          <td style="text-align: right">3</td>
          <td style="text-align: right">1,149 ms</td>
      </tr>
      <tr>
          <td>SharePoint REST, OData reverse</td>
          <td>server</td>
          <td style="text-align: right">13</td>
          <td style="text-align: right">13</td>
          <td style="text-align: right">1,163 ms</td>
      </tr>
      <tr>
          <td>Microsoft Graph, reverse</td>
          <td>server</td>
          <td style="text-align: right">9</td>
          <td style="text-align: right">9</td>
          <td style="text-align: right">1,717 ms</td>
      </tr>
  </tbody>
</table>
<p>* <code>RenderListDataAsStream</code> again.</p>
<p>The join answers in 169 ms and one request. The cheapest thing that isn&rsquo;t a join costs 1.5x that and only by giving up the filter, the honest reverse walk costs 2.2x, and the worst case is 10x and nine round trips.</p>
<p>Worth being precise about why the reverse arms blow up, because &ldquo;no <code>in</code> operator&rdquo; is only half of it. They do use <code>or</code>, and it works fine: <code>$filter=(OrderDetailLookUpId eq 2) or (OrderDetailLookUpId eq 3) or ...</code>. What kills them is that the chain has to fit in a <strong>query string</strong>, not a URL, and each stack has its own idea of how long that may be. I bisected both rather than assuming they matched:</p>
<ul>
<li><strong>SharePoint REST</strong> refuses at 2,048 characters of query string, which is ASP.NET&rsquo;s <code>maxQueryStringLength</code> default showing through. 46 clauses go through at 2,016 characters, 47 come back <code>401</code> at 2,059.</li>
<li><strong>Graph</strong> goes more than twice as far, to somewhere just past 4,625 characters. 91 clauses pass, 92 come back <code>404</code> with an empty <code>UnknownError</code> and no explanation at all.</li>
</ul>
<p>Each escaped clause costs about 43 characters either way, so REST fits about 44 ids per request and Graph about 86. An <code>in</code> operator would cost about 6 characters per id, which is why its absence hurts: same query, seven times the requests.</p>
<p>This is the rerun I owed you. I had originally chunked both stacks against a single 1,900 character budget measured on the whole URL, which is wrong twice: the cap is on the query string, and Graph&rsquo;s path carries a 90 character site id that was eating into a budget SharePoint never had to pay. Fixing it took Graph&rsquo;s reverse walk from nineteen requests to nine, which is the sort of correction that makes your own argument weaker and your numbers worth reading.</p>
<p>Three customers become 520 order tasks. REST needs twelve chunked calls to read them, on top of the one that found the orders. Thirteen round trips to answer one question. The more you ask for, the worse not joining gets.</p>
<h2 id="what-rest-and-graph-actually-refuse">What REST and Graph Actually Refuse</h2>
<p>I checked the walls rather than assuming them, and the errors are worth having:</p>
<ul>
<li><strong>Two-level <code>$expand</code> in REST</strong>: <code>400</code>. The docs never spell out a depth limit, but they do warn you off the shape, in one sentence you could read past: &ldquo;Bulk expansion and selection of related items isn&rsquo;t supported.&rdquo; In practice you name one field of one lookup and that&rsquo;s your lot.</li>
<li><strong>Reaching a lookup id <em>through</em> an expand</strong> (<code>$select=OrderTaskLookUp/OrderDetailLookUpId</code>): also <code>400</code>. If this worked, OData would do the whole chain in two calls.</li>
<li><strong><code>$filter=Id in (1,2,3)</code> in REST</strong>: <code>400</code>. No <code>in</code> operator, hence the <code>or</code> chains.</li>
<li><strong>Graph filtering on a column that lives on the lookup target</strong>: <code>400</code>. You can only filter <code>fields/CustomerLookUpLookupId</code>, the integer.</li>
<li><strong>Graph <code>in (...)</code> on a lookup id</strong>: <code>400</code>. But <code>or</code> chaining works, which is the only reason Graph&rsquo;s reverse arm is nine requests and not 500.</li>
<li><strong>Graph expanding a lookup at all</strong>: <code>400</code>, &ldquo;Could not find a property named &lsquo;OrderDetailLookUp&rsquo; on type &lsquo;microsoft.graph.listItem&rsquo;&rdquo;. Try it inside <code>fields</code> instead and the type name changes but the answer doesn&rsquo;t: <code>$expand=fields($expand=OrderDetailLookUp)</code> is <code>400</code>, &ldquo;Could not find a property named &lsquo;OrderDetailLookUp&rsquo; on type &lsquo;microsoft.graph.fieldValueSet&rsquo;&rdquo;.</li>
<li><strong>Asking for a path through a lookup</strong>, <code>$expand=fields($select=OrderDetailLookUp/Title)</code>: <code>200</code>, which looks like a win until you read it. <code>fields</code> contains exactly one thing, <code>&quot;OrderDetailLookUp&quot;: &quot;Order 00000&quot;</code>, the lookup&rsquo;s own display value. The <code>/Title</code> was quietly ignored rather than honoured or rejected.</li>
</ul>
<p>REST does have one trick I didn&rsquo;t expect: you <em>can</em> filter on an expanded lookup&rsquo;s column, even one that isn&rsquo;t the lookup&rsquo;s <code>ShowField</code>. <code>$filter=CustomerLookUp/customerNo eq 'C-NARROW'</code> with <code>$expand=CustomerLookUp</code> returns 200. That&rsquo;s what gets the OData route down to 2 requests on the narrow filter.</p>
<h2 id="gotchas">Gotchas</h2>
<ul>
<li><strong><code>GetItems</code> returns your join without the projected columns.</strong> <code>200 OK</code>, correct row count, columns missing, <code>&lt;ViewFields&gt;</code> present and correct. The filter still works, so if you only need the ids, it&rsquo;s fine. If you need the values, use <code>RenderListDataAsStream</code>.</li>
<li><strong>A projected field must also be in <code>&lt;ViewFields&gt;</code>.</strong> Leave it out and the column isn&rsquo;t on the item. No error, no warning, no empty string, just absent. CAMLEX won&rsquo;t add it for you either.</li>
<li><strong><code>ProjectedFields</code> is a whitelist, and it&rsquo;s shorter than the docs let on.</strong> I provisioned one column of each disputed type and tried to project it. Text, Number, DateTime and Currency came through. Choice, person, yes/no, hyperlink and <em>every</em> flavour of multi-line text failed, including the plain one-line <code>Note</code> that Microsoft&rsquo;s own list says is allowed. The error is <code>Value does not fall within the expected range</code>, which tells you nothing about which column it means. My workaround is to store the value in a plain text column, because I know that projects, and then put <a href="https://learn.microsoft.com/en-us/sharepoint/dev/declarative-customization/column-formatting">column formatting</a> on it so the user still gets the coloured pill or the icon they&rsquo;d have got from a choice column. Same experience in the list, and the column survives a join.</li>
<li><strong><code>LookupId=&quot;TRUE&quot;</code> or you&rsquo;re filtering on display text.</strong> Filter a lookup column without it and CAML compares against the shown value, not the id. You get zero rows and no error, which is the worst combination.</li>
<li><strong>The <code>In</code> clause has two limits, not one.</strong> 500 values really is a hard cap: 500 returns, 501 throws <code>Value does not fall within the expected range</code>. The other one, repeated everywhere and traced to an archived 2013 article, is 60 values, past which SharePoint is said to stop treating the column as indexed so a big list throws the list view threshold error instead. My lists are 1,000 items, so I never met that one and I would not take it on faith. At 1,000 items my no-join arm went from 4 requests to 6 purely because of that chunking.</li>
<li><strong>SharePoint REST pages at 100 by default, Graph at 200.</strong> Forget <code>$top</code> and a 1,000 item list is ten round trips before you&rsquo;ve done anything interesting.</li>
<li><strong>999 is not a SharePoint number.</strong> It&rsquo;s the directory-object ceiling from <code>/users</code>, and I paged a whole benchmark at it out of habit. Graph honours <code>$top</code> literally on list items: 999 gives 999, 1,001 gives 1,001. It also returns <code>200 OK</code> for <code>$top=100000</code> without honouring anything in particular, so a non-error tells you nothing. If you want to know your real page size, put more rows in the list than you think the cap is and count the first page.</li>
<li><strong>The query string cap is 2,048 characters on SharePoint, and mine answered <code>401</code>.</strong> Not <code>414</code>, and not the <code>400</code> that ASP.NET&rsquo;s own docs promise for this. You get &ldquo;The length of the query string for this request exceeds the configured maxQueryStringLength value&rdquo; under an Unauthorized status code, which sends you off debugging your token. Push much further past it and the status changes again, to <code>404</code>. An escaped <code>or</code> clause costs about 43 characters, so plan on roughly 45 ids per request.</li>
<li><strong>Graph&rsquo;s ceiling is its own, and roughly twice SharePoint&rsquo;s.</strong> 4,625 characters, so about 86 ids per request, and it announces the limit with a bare <code>404</code> and an empty <code>UnknownError</code>. If you chunk both stacks against one number you will silently hand Graph half the requests it needed, which is exactly what I did for the first draft of this post.</li>
<li><strong><code>RenderListDataAsStream</code> is not a drop-in swap for the OData shape.</strong> Rows under <code>Row</code>, not <code>value</code>. Every value is a string, ids included, so parse rather than cast. And you get the view&rsquo;s own fields whether you want them or not: <code>RenderOptions: 2</code> removed nothing for me, and the docs say why, since <code>ListData</code> is defined as &ldquo;same as <code>None</code>&rdquo;.</li>
<li><strong>CAMLEX still works, and <code>ToString()</code> is not the method you want.</strong> <code>Camlex.Client.dll</code> 5.4.3 built and ran fine against CSOM on .NET 10, which I mention because the last NuGet release is from July 2024 and people ask. Use <code>ToCamlQuery()</code>; <code>ToString()</code> returns fragments, and SharePoint will run them and quietly leave your projections out.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>The advice survives contact with a stopwatch, but not in the shape I brought to it. Two things didn&rsquo;t survive. I&rsquo;d been selling the join as a CSOM feature and it isn&rsquo;t: CAML runs over REST too, and comes through intact on <code>RenderListDataAsStream</code>. And &ldquo;the join always wins&rdquo; is now false, because on REST a <code>$batch</code> of four ordinary queries beats the REST join on time and on bytes. What&rsquo;s left is narrower and I think truer: <strong>the CAML join over CSOM is the fastest way to do this, and the only one that keeps the filter on the server without walking the chain backwards first.</strong></p>
<p>The honest caveats: one tenant, one geography, warm caches, ten timed runs per approach, so anything inside about 20% is noise. Absolute numbers won&rsquo;t be yours. The ratios probably will be.</p>
<p>And the gap grows with the ask. One customer, one row set, and the alternatives are merely slower. Three customers and 520 rows, and the reverse walks turn into thirteen and nine round trips, not because <code>or</code> doesn&rsquo;t work but because 520 ids don&rsquo;t fit in one query string on either stack.</p>
<p>Those are the numbers after I tuned every arm to the ceiling I measured for it: <code>&lt;In&gt;</code> at its 500-value cap, SharePoint&rsquo;s or-chains at 2,048 characters, Graph&rsquo;s at 4,500 just under its measured 4,625, Graph&rsquo;s pages at a <code>$top</code> that actually returns the list. The first draft of this post had all three set to something I&rsquo;d guessed, and every one of the guesses happened to favour the join. Fixing them cost the join some of its margin and cost me an afternoon, and it&rsquo;s the only version of the table I&rsquo;d defend.</p>
<p>My rule of thumb, now with receipts behind it and one honest amendment: <strong>if your lists are connected by lookup columns, make SharePoint do the join.</strong> If you can&rsquo;t, batch. The round trips come back either way, but the join is the only thing that filters server-side without making you resolve the ids yourself first, and that&rsquo;s the part you&rsquo;d otherwise have written by hand in C# while holding an entire list in memory to do it.</p>
<p>Every number above is from lists of 1,000 items. Past 5,000 the rules change, one of these approaches starts returning partial answers with a <code>200 OK</code>, and the REST filter trick stops working entirely. I ran the whole thing again at 10,000: <a href="https://jeppe-spanggaard.dk/blogs/list-view-threshold-what-still-works/">what still works at the list view threshold</a>.</p>
]]></content:encoded></item><item><title>Graph Multi-Value Lookups: The LookupId Shape That Works</title><link>https://jeppe-spanggaard.dk/blogs/graph-multi-value-lookup-person-fields/</link><pubDate>Mon, 10 Aug 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/graph-multi-value-lookup-person-fields/</guid><description>Learn how to write multi-value lookup and person columns on SharePoint list items with Microsoft Graph, using the LookupId array syntax the docs never show you.</description><content:encoded><![CDATA[<p>I had multi-value lookup columns filed under &ldquo;Graph can&rsquo;t do that&rdquo; for embarrassingly long. I was wrong. It can.</p>
<p>The syntax just looks nothing like what you&rsquo;d write in CSOM, and the request that gets it wrong doesn&rsquo;t tell you.</p>
<h2 id="the-shape">The Shape</h2>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-http" data-lang="http"><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">PATCH /sites/{site-id}/lists/{list-id}/items/{item-id}/fields
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">{
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">  &#34;ProductsLookupId@odata.type&#34;: &#34;Collection(Edm.Int32)&#34;,
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">  &#34;ProductsLookupId&#34;: [6, 7, 8]
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">}
</span></span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li>The writable property is the column&rsquo;s internal name plus <code>LookupId</code>, not the column name. Graph <a href="https://learn.microsoft.com/en-us/graph/api/resources/fieldvalueset">documents this for reads</a> and is silent about writes, but it&rsquo;s the same convention.</li>
<li>The value is a plain array of integer ids from the target list. No objects, no <code>LookupValue</code>, no wrapper.</li>
<li>The <code>@odata.type</code> annotation sits in a sibling property with the same name. Some report it isn&rsquo;t strictly required. Include it anyway - it costs nothing and it&rsquo;s the difference between a write and a shrug.</li>
</ol>
<p>Creating an item works the same way, with the body nested under <code>fields</code>. Clearing the column is <code>&quot;ProductsLookupId&quot;: []</code>.</p>
<p>The failure mode worth knowing: send the CSOM-flavoured <code>Collection(SP.FieldLookupValue)</code> with a <code>results</code> wrapper and you get <code>204 No Content</code> and a column that stays empty. Nothing in the response suggests you did anything wrong.</p>
<h2 id="person-columns-are-just-lookups">Person Columns Are Just Lookups</h2>
<p>A Person or Group column is a lookup into the hidden User Information List, so the write is identical:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;ReviewersLookupId@odata.type&#34;</span>: <span style="color:#e6db74">&#34;Collection(Edm.Int32)&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;ReviewersLookupId&#34;</span>: [<span style="color:#ae81ff">12</span>, <span style="color:#ae81ff">13</span>, <span style="color:#ae81ff">27</span>]
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Single-value version is <code>{ &quot;ReviewerLookupId&quot;: &quot;12&quot; }</code>.</p>
<p>The catch is where those numbers come from. They&rsquo;re site-collection user ids, not Entra object ids and not Graph user ids. Graph has no <code>/sites/{id}/users</code> endpoint, so you read them out of the hidden list yourself:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-http" data-lang="http"><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">GET /sites/{siteId}/lists?$filter=displayName eq &#39;User Information List&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">GET /sites/{siteId}/lists/{uilId}/items?$expand=fields($select=id,EMail,Name,Title)
</span></span></span></code></pre></div><p>And then the real gap: if a user has never been referenced on that site, they have no entry in the list and therefore no id, and Graph offers no way to create one. That&rsquo;s <code>EnsureUser</code>, and it only exists in SharePoint REST and CSOM:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-http" data-lang="http"><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">POST https://{site}/_api/web/ensureuser
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">{ &#34;logonName&#34;: &#34;i:0#.f|membership|someone@the-tenant.example&#34; }
</span></span></span></code></pre></div><p>So the write path for person columns can&rsquo;t be pure Graph unless you can guarantee the users already exist on the site. In a backend I skip the dance and do the whole thing in CSOM with <code>Web.EnsureUser()</code> and a <code>FieldUserValue</code>. In a browser client I resolve or create with the REST call, then write the item with Graph.</p>
<h2 id="reading-them-back">Reading Them Back</h2>
<p>Lookup fields aren&rsquo;t returned by default, and the display value needs an explicit <code>$select</code> inside the <code>$expand</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-http" data-lang="http"><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">GET /sites/{site-id}/lists/{list-id}/items?$expand=fields($select=id,Title,ProductsLookupId,Products)
</span></span></span></code></pre></div><p>You get the id and the display value, and that&rsquo;s it. Any other column from the target list is a second query. Twelve lookup fields per query is the documented ceiling.</p>
<h2 id="gotchas">Gotchas</h2>
<ul>
<li><strong><code>Files.ReadWrite.All</code> alone makes multi-value lookups read back as <code>[]</code>.</strong> Populated column, empty array, no error. Add <code>Sites.Read.All</code> and the values appear. Single-value lookups are unaffected, which is what makes this one so slow to diagnose.</li>
<li><strong>Internal names, not display names.</strong> <code>Product_x0020_Line</code>, not <code>Product Line</code>. Get them from <code>/lists/{id}/columns</code>.</li>
<li><strong><code>400</code> on the array means the column is single-value.</strong> Check <code>lookup.allowMultipleValues</code> or <code>personOrGroup.allowMultipleSelection</code> on the column definition before blaming the syntax.</li>
<li><strong>It&rsquo;s <code>logonName</code>, not <code>loginName</code>.</strong> Get it wrong and <code>ensureuser</code> answers with <code>InvalidClientQueryException</code>, which tells you nothing about the spelling.</li>
<li><strong><code>ensureuser</code> needs a SharePoint-audience token.</strong> <code>https://{tenant}.sharepoint.com/.default</code>, not your Graph token. Two audiences, two sets of permissions to consent.</li>
<li><strong>Old Graph SDK 5.x chokes on non-string <code>AdditionalData</code>.</strong> <code>CurrentDepth (1000) is equal to or larger than the maximum allowed depth of 1000</code> on anything that isn&rsquo;t a string, ints and arrays included. Fixed in current packages, so upgrade first and only reach for raw JSON through the request adapter if you&rsquo;re pinned.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>Multi-value lookups and person columns are writable from Graph. The rule is <code>&lt;InternalName&gt;LookupId</code> plus an integer array plus <code>Collection(Edm.Int32)</code>, and the CSOM-shaped payload you&rsquo;d expect to work is exactly the one that fails quietly.</p>
<p>The one thing Graph genuinely can&rsquo;t do here is mint a user id that doesn&rsquo;t exist yet. That&rsquo;s still an <code>EnsureUser</code> call, and it&rsquo;s part of why I keep <a href="https://jeppe-spanggaard.dk/blogs/csom-vs-sharepoint-rest-vs-graph/">a playbook for which SharePoint API I reach for</a> instead of committing a whole feature to one of them.</p>
]]></content:encoded></item><item><title>Every Activity Will Run Twice: Idempotency in Durable Functions</title><link>https://jeppe-spanggaard.dk/blogs/durable-functions-idempotent-activities/</link><pubDate>Wed, 05 Aug 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/durable-functions-idempotent-activities/</guid><description>Learn how to make every Durable Functions activity idempotent so retry policies can safely rerun SharePoint and Graph provisioning steps.</description><content:encoded><![CDATA[<p>My provisioning engine creates SharePoint team sites through a Durable Functions orchestration: create the site, activate features, pull content types, apply templates, seed folders. Every activity call in that orchestrator goes through one shared retry policy:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// Outer safety net: retry any activity that throws a transient error (e.g. throttling</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// that slips past HTTP-level retry). Exponential backoff: 5s, 10s, 20s, ... up to 5min,</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// 10 attempts total. All activities are idempotent, so retrying the full activity is safe.</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">readonly</span> TaskOptions ActivityRetryOptions = <span style="color:#66d9ef">new</span>(
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">new</span> RetryPolicy(
</span></span><span style="display:flex;"><span>        maxNumberOfAttempts: <span style="color:#ae81ff">10</span>,
</span></span><span style="display:flex;"><span>        firstRetryInterval: TimeSpan.FromSeconds(<span style="color:#ae81ff">5</span>),
</span></span><span style="display:flex;"><span>        backoffCoefficient: <span style="color:#ae81ff">2.0</span>,
</span></span><span style="display:flex;"><span>        maxRetryInterval: TimeSpan.FromMinutes(<span style="color:#ae81ff">5</span>)));
</span></span></code></pre></div><p>Read that comment again: &ldquo;All activities are idempotent, so retrying the full activity is safe.&rdquo;</p>
<p>That sentence is either the best thing about the whole engine or a lie that duplicates customer sites at 2am. There&rsquo;s no middle ground. The retry policy reruns the <em>entire activity</em>, not the line that failed. If your activity created a site and then died resolving its ID, the retry creates the site again - unless the activity was written to survive being run twice.</p>
<p>So this post is a catalog of how I actually make that sentence true. Five patterns, all from real provisioning code.</p>
<h2 id="pattern-1-check-before-create">Pattern 1: Check Before Create</h2>
<p>The site creation activity is the highest-stakes one. If a previous attempt created the site but failed a moment later (say, while resolving the Graph site ID), the retry must not create it again. So it probes first:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// Idempotency: if the site already exists (e.g. previous attempt created it but</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// failed during Graph ID resolution), skip creation and go straight to Graph lookup.</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (<span style="color:#66d9ef">await</span> SiteExistsAsync(newSiteUrl))
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    logger.LogInformation(<span style="color:#e6db74">&#34;Site already exists at {SiteUrl}, skipping creation.&#34;</span>, newSiteUrl);
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">else</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> CreateSiteAsync(parsedType, site, siteAlias, newSiteUrl);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Note what makes the probe possible in the first place: the site URL is deterministic, built from a prefix and alias in the schema. No timestamps, no random suffixes. If the URL contained <code>Guid.NewGuid()</code>, attempt two would probe a different URL, find nothing, and create a second site. Deterministic naming is the quiet prerequisite for check-before-create.</p>
<h2 id="pattern-2-already-exists-is-success">Pattern 2: &ldquo;Already Exists&rdquo; Is Success</h2>
<p>Sometimes you can&rsquo;t probe cheaply, so you attempt the operation and translate the failure. Activating a site feature that&rsquo;s already active comes back from SharePoint as an error - but it means the activity already did its job on a previous run:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">catch</span> (InvalidOperationException ex) when (
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// &#34;Feature already activated&#34; comes back as HTTP 500 with odata.error.code</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// &#34;System.Data.DuplicateNameException&#34; - locale-independent, unlike the human message.</span>
</span></span><span style="display:flex;"><span>    ex.Message.Contains(<span style="color:#e6db74">&#34;System.Data.DuplicateNameException&#34;</span>, StringComparison.OrdinalIgnoreCase))
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    logger.LogInformation(<span style="color:#e6db74">&#34;{Feature} feature is already active, skipping.&#34;</span>, displayName);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The comment is a scar. My first version matched on the English error text, which works great until the code runs against a tenant in another language. Match on error <em>codes</em>, not error <em>messages</em>. The OData error code survives localization; &ldquo;Feature &hellip; is already activated&rdquo; does not.</p>
<p>Graph makes this pattern cleaner because it gives you a real status code. Copying folder structures into the new site:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">catch</span> (ODataError ex) when (ex.ResponseStatusCode == <span style="color:#ae81ff">409</span>)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Item already exists in target - idempotent on retry</span>
</span></span><span style="display:flex;"><span>    logger.LogInformation(<span style="color:#e6db74">&#34;Item &#39;{ItemName}&#39; already exists in target, skipping copy.&#34;</span>, itemName);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>409 Conflict on a create is not a failure. It&rsquo;s a receipt from your previous attempt.</p>
<h2 id="pattern-3-upsert-by-nature">Pattern 3: Upsert by Nature</h2>
<p>The best idempotency is the kind you get for free by choosing the right API. The registration activity writes the new site&rsquo;s URL and ID back to an inventory list, and it does so with SharePoint&rsquo;s <code>ValidateUpdateListItem</code> against a known item ID. Setting the same fields on the same item to the same values twice is indistinguishable from doing it once. There&rsquo;s nothing to guard because the operation has no failure mode for repetition.</p>
<p>When you&rsquo;re designing an activity and you get to pick between &ldquo;add a row&rdquo; and &ldquo;set fields on item X&rdquo;, pick the second. Every <code>Add</code> needs a guard; a keyed <code>Set</code> guards itself.</p>
<h2 id="pattern-4-verify-after-write">Pattern 4: Verify After Write</h2>
<p>The dark twin of idempotency: some SharePoint writes report success and then don&rsquo;t stick. Property bag values on a freshly created site are notorious for this. The activity&rsquo;s answer is to re-read everything it just wrote and throw if reality disagrees:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (mismatches.Count &gt; <span style="color:#ae81ff">0</span>)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> InvalidOperationException(
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">$&#34;Property bag verification failed on {siteUrl}: {string.Join(&#34;</span>; <span style="color:#e6db74">&#34;, mismatches)}. &#34;</span> +
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;Values did not persist to the server; retrying the activity.&#34;</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li>After writing the property bag values, the activity loads <code>Web.AllProperties</code> fresh from the server.</li>
<li>Each expected key/value is compared against what actually persisted.</li>
<li>Any mismatch throws a plain transient exception, which hands the problem to the orchestrator&rsquo;s retry policy.</li>
<li>The rerun is safe precisely because setting a property bag key is an upsert - pattern 3 again. Verification and idempotency are two halves of the same loop: writes are repeatable, so failed verification can simply demand a repeat.</li>
</ol>
<p>This inverts the usual relationship with retries. Instead of retries being something inflicted on the activity, the activity uses a throw to <em>request</em> one.</p>
<h2 id="pattern-5-idempotent-deletes-too">Pattern 5: Idempotent Deletes Too</h2>
<p>Removal flows have the same problem in the other direction. Removing a user from a site&rsquo;s members group when they&rsquo;re already gone throws a <code>ServerException</code>, and treating that as failure would wedge every cleanup retry:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">catch</span> (ServerException ex)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    logger.LogWarning(ex, <span style="color:#e6db74">&#34;Could not remove {LoginName} from {SiteUrl} ({ErrorType}); treating as already removed.&#34;</span>,
</span></span><span style="display:flex;"><span>        loginName, siteUrl, ex.ServerErrorTypeName);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Desired state: user is not in the group. User is not in the group. Done. Idempotency is about converging on a state, not performing an action.</p>
<h2 id="gotchas">Gotchas</h2>
<ul>
<li><strong>One non-idempotent activity poisons the whole pipeline.</strong> I had a security activity that created SharePoint groups without checking for existing ones. Every retry of that step risked duplicate groups, which meant retries weren&rsquo;t safe, which meant the retry policy comment was a lie for the whole orchestration. It&rsquo;s disabled until it earns its way back in. Idempotency is all-or-nothing per pipeline.</li>
<li><strong>The failure window is between success and checkpoint.</strong> The orchestrator records completed steps, but a crash can land after the activity finished and before the record was written. The completed-steps list narrows how often reruns happen; idempotency is what makes the remaining reruns harmless. You need both.</li>
<li><strong><code>catch</code> the narrowest thing you can.</strong> The 409 handler above catches <code>ODataError</code> with status 409, nothing else. A broad <code>catch { return; }</code> also &ldquo;makes retries pass&rdquo;, by swallowing real failures. Idempotency guards should be precise enough that a genuinely broken call still throws.</li>
<li><strong>Test by running it twice, literally.</strong> My smoke test for a new activity is: run the provisioning, then immediately queue the exact same provisioning again. Zero errors and zero duplicates or it doesn&rsquo;t ship.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>A Durable Functions retry policy is a contract: it promises to rerun your activities, and your activities promise not to care. Check before create, read &ldquo;already exists&rdquo; as success, prefer upserts, verify writes that lie. Write every activity as if it will run twice, because on a long enough timeline, it will.</p>
<p>If retry policies and replay are new to you, <a href="https://jeppe-spanggaard.dk/blogs/what-are-durable-functions/">Durable Functions: A Function That Sleeps for a Week</a> covers what an orchestration actually is, and why &ldquo;it will run twice&rdquo; is a feature rather than a bug.</p>
]]></content:encoded></item><item><title>CSOM vs SharePoint REST vs Graph: My Pick-One Playbook</title><link>https://jeppe-spanggaard.dk/blogs/csom-vs-sharepoint-rest-vs-graph/</link><pubDate>Thu, 30 Jul 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/csom-vs-sharepoint-rest-vs-graph/</guid><description>Learn when to use CSOM, SharePoint REST or Microsoft Graph for SharePoint work, and which operations each one silently refuses to do.</description><content:encoded><![CDATA[<p>You start a SharePoint feature the sensible way. Graph first, because it&rsquo;s the modern API and because that&rsquo;s what PnP Core does by default. You write the upload. You write the metadata. You write the listing code. It works.</p>
<p>Then you hit the one field, the one property, the one checkbox Graph doesn&rsquo;t do. And you hit it on day three, with the feature already shaped around Graph.</p>
<p>Sometimes rewiring it is cheap. Sometimes it&rsquo;s a rewrite. Either way you paid.</p>
<p>I&rsquo;ve walked into that wall enough times now that I stopped calling it bad luck and started writing down which tool I reach for when. This post is that list.</p>
<h2 id="the-200-that-meant-nothing">The 200 That Meant Nothing</h2>
<p>The worst one was metadata on uploaded documents. My add-in uploads a file with Graph, then applies the library&rsquo;s columns to the resulting list item with a <code>PATCH</code> to <code>/listItem/fields</code>. Text columns, choice columns, dates, all good.</p>
<p>Then a managed metadata column.</p>
<p><code>200 OK</code>. Empty field.</p>
<p>Not a 400. Not &ldquo;column type not supported&rdquo;. Graph accepted the payload, answered like everything was fine, and wrote nothing. The <a href="https://learn.microsoft.com/en-us/graph/api/listitem-update">Learn page for updating a listItem</a> shows you <code>Color</code> and <code>Quantity</code> and never mentions which column types don&rsquo;t make it through.</p>
<p>A thrown error costs you an hour. A silent success costs you until someone notices the taxonomy column is blank on documents that were archived weeks ago, and then it costs you a data migration.</p>
<p>That&rsquo;s the real argument for having a playbook. Not elegance. The cases where the wrong choice fails quietly.</p>
<p>Be careful about which columns you write off, though. I had multi-value lookups and person columns on my &ldquo;Graph can&rsquo;t&rdquo; list for a long time, and both were wrong. They work fine, they just don&rsquo;t take the shape you&rsquo;d guess coming from CSOM, and that&rsquo;s its own post. Managed metadata is the one that genuinely has no Graph write path.</p>
<h2 id="graph-first-is-a-default-not-a-plan">Graph First Is a Default, Not a Plan</h2>
<p>To be clear, &ldquo;Graph first&rdquo; is a good instinct. PnP Core builds it in: the SDK favours Graph when reading SharePoint data and falls back to SharePoint REST when the requested properties aren&rsquo;t available there, and you can flip <code>GraphFirst</code> off if you disagree. I&rsquo;ve <a href="https://jeppe-spanggaard.dk/blogs/pnp-core-vs-pnp-framework-migration-blockers/">written before about why I&rsquo;m still on PnP.Framework</a>, and that per-operation routing is the thing I most want from PnP Core.</p>
<p>Until I have it, I&rsquo;m the router. So here&rsquo;s how I route.</p>
<h2 id="csom-owns-list-items">CSOM Owns List Items</h2>
<p>Roughly 95% of my list item CRUD is CSOM, and it&rsquo;s not nostalgia. Three capabilities keep it there.</p>
<p><strong>Batching without a hard ceiling.</strong> CSOM queues operations until you call <code>ExecuteQueryAsync()</code>, and around <a href="https://jeppe-spanggaard.dk/blogs/csom-performance-optimization-chunking/">100 operations per batch</a> is the reliable sweet spot. Graph&rsquo;s <code>$batch</code> caps at 20 requests, and <a href="https://jeppe-spanggaard.dk/blogs/graph-batch-smart-retry/">a failed batch needs picking apart</a> before you retry it. When I&rsquo;m updating 500 items, that difference is 5 round trips versus 25.</p>
<p><strong>CAML joins.</strong> One query across lists connected by lookup columns, filtered on a field two lists away, merged server-side. There&rsquo;s no Graph equivalent, and I&rsquo;ve never found a way to fake it that didn&rsquo;t end in merging rows in C#.</p>
<p><strong>A way past the list view threshold.</strong> Query a big list and CSOM throws <code>The attempted operation is prohibited because it exceeds the list view threshold</code>. There&rsquo;s a flag on <code>CamlQuery</code> that gets you through it, combined with paging and indexed columns. That one deserves its own post and it&rsquo;s on my list.</p>
<p>The rest of my CSOM reasoning is in <a href="https://jeppe-spanggaard.dk/blogs/sharepoint-csom-performance-playbook/">the CSOM performance playbook</a>, and the join pattern has <a href="https://jeppe-spanggaard.dk/blogs/joining-multiple-lists-csom-caml/">its own post</a>.</p>
<h2 id="graph-owns-files">Graph Owns Files</h2>
<p>For files it flips completely. Uploading and downloading through Graph is fewer requests and noticeably faster in every project I&rsquo;ve measured it in, and large files are the clearest case: <code>createUploadSession</code> gives you a resumable, chunked upload with a pre-authenticated URL, and the chunks don&rsquo;t carry an <code>Authorization</code> header at all.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#75715e">// Small files: straight PUT to the content endpoint
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">file</span>.<span style="color:#a6e22e">size</span> <span style="color:#f92672">&lt;=</span> <span style="color:#a6e22e">SIMPLE_UPLOAD_LIMIT</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">folder</span>.<span style="color:#a6e22e">concat</span>(<span style="color:#e6db74">`:/</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">filename</span><span style="color:#e6db74">}</span><span style="color:#e6db74">:/content`</span>).<span style="color:#a6e22e">put</span>(<span style="color:#a6e22e">file</span>);
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Large files: a session, then sequential chunks
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">session</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">folder</span>.<span style="color:#a6e22e">createUploadSession</span>({ <span style="color:#a6e22e">name</span>: <span style="color:#66d9ef">filename</span> });
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> (<span style="color:#66d9ef">const</span> <span style="color:#a6e22e">chunk</span> <span style="color:#66d9ef">of</span> <span style="color:#a6e22e">chunks</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">session</span>.<span style="color:#a6e22e">resumableUpload</span>.<span style="color:#a6e22e">upload</span>(<span style="color:#a6e22e">chunk</span>.<span style="color:#a6e22e">length</span>, <span style="color:#a6e22e">chunk</span>, <span style="color:#a6e22e">contentRange</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The rules that matter: chunk sizes must be a multiple of 320 KiB, they go up sequentially, and each <code>PUT</code> extends the session expiry. Get the multiple wrong and the upload fails at the <em>last</em> chunk, which is a fun way to spend an afternoon.</p>
<p>Downloads get the same treatment - <a href="https://jeppe-spanggaard.dk/blogs/download-multiple-files-from-sharepoint/">zipping a whole folder out of SharePoint</a> is a Graph job for me, not a CSOM one. Graph&rsquo;s JSON batching maps cleanly onto &ldquo;fetch these 20 files&rsquo; content&rdquo;, though matching responses back to requests has its own quirks that I covered in <a href="https://jeppe-spanggaard.dk/blogs/graph-batching-file-content-mapping/">Graph batching for file content</a>.</p>
<h2 id="sharepoint-rest-is-the-escape-hatch">SharePoint REST Is the Escape Hatch</h2>
<p>I almost never <em>choose</em> SharePoint REST. I end up there when it&rsquo;s the only thing that works, which turns out to be more often than the modern-API story suggests. From my current projects:</p>
<ul>
<li>Setting a navigation link to open in a new tab. It&rsquo;s a checkbox in the UI, it&rsquo;s not in the PnP provisioning schema, and it&rsquo;s not on CSOM&rsquo;s <code>NavigationNode</code>. It&rsquo;s <code>MenuState</code>/<code>SaveMenuState</code>. I have a whole post coming about that one.</li>
<li>Reading the sites a user follows: <code>/_api/social.following/my/followed(types=4)</code>.</li>
<li>Joining a hub site, activating site features, applying a site design, adding an available content type to a library. All <code>_api</code> calls in my provisioning engine.</li>
<li><code>ValidateUpdateListItem</code>, which is what rescues the metadata story from the top of this post.</li>
<li>And <code>/_api/web/ensureuser</code>, because Graph has no <code>EnsureUser</code> and no <code>/sites/{id}/users</code> endpoint at all.</li>
</ul>
<p>The pattern is consistent: the older and more SharePoint-specific the concept, the more likely REST is the only place it lives.</p>
<p><code>ValidateUpdateListItem</code> is worth knowing by name. It takes form values in SharePoint&rsquo;s own wire format - taxonomy as <code>Label|GUID</code>, people as a JSON array of claim keys - and it&rsquo;s the same payload the classic edit form posts, which is exactly why it accepts the column types Graph won&rsquo;t touch. In a C# backend it&rsquo;s a method on <code>ListItem</code> in the client library; in a browser add-in with no CSOM available, it&rsquo;s the REST endpoint. Either way, read the response: rejected fields come back with <code>HasException: true</code> inside an otherwise successful call, so you can recreate the silent-200 problem from the other direction if you don&rsquo;t look.</p>
<p>The <code>EnsureUser</code> gap is the more interesting one, because it&rsquo;s what stops person columns from being a pure Graph story. The ids in <code>ReviewersLookupId</code> are site-collection user ids from the User Information List. Not Entra object ids, not Graph user ids. If a user has never been referenced on that site, they simply have no id, and Graph gives you no way to create one. So you <code>POST /_api/web/ensureuser</code> with a <code>logonName</code> first, then write the item with Graph. Or you stay in CSOM and let <code>Web.EnsureUser()</code> plus a <code>FieldUserValue</code> do both in one place, which is what I usually do in a backend.</p>
<p>One nice asymmetry on the taxonomy side: <em>reading</em> the term store works fine over Graph (<code>/sites/{id}/termStore/sets/{id}</code>, with <code>TermStore.Read.All</code>). It&rsquo;s only writing a term onto a list item that Graph won&rsquo;t do. So my term picker is Graph and my term save is not.</p>
<h2 id="gotchas">Gotchas</h2>
<ul>
<li><strong>A <code>200</code> is not proof.</strong> This is the whole post in one bullet. Graph accepts unsupported column types and writes nothing. Assert on the value you wrote, at least once per column type, in a real library. Otherwise you find out at migration time.</li>
<li><strong>Mixing APIs means mixing tokens.</strong> A Graph token does not work against <code>/_api</code>. SharePoint REST wants an audience of <code>https://&lt;tenant&gt;.sharepoint.com/.default</code>, so a flow that uses both acquires two tokens and your app registration needs both sets of permissions. Plan the consent, not just the code.</li>
<li><strong>Lookups are <code>FieldNameLookupId</code>, not <code>FieldName</code>.</strong> Graph names the writable property differently from the column. Writing to the column name silently does nothing. Yes, silently.</li>
<li><strong>Switching to <code>ValidateUpdateListItem</code> needs the list item id.</strong> Drive item ids don&rsquo;t work, so <code>$select=sharepointIds</code> on the Graph item first and use <code>sharepointIds.listItemId</code>.</li>
<li><strong>It&rsquo;s <code>logonName</code>, not <code>loginName</code>.</strong> The <code>ensureuser</code> parameter is spelled the unintuitive way, some docs get it wrong, and the wrong spelling gets you an <code>InvalidClientQueryException</code> that says nothing useful.</li>
<li><strong>Chunk sizes are a multiple of 320 KiB or nothing.</strong> Graph upload sessions fail on the final commit, not on the offending chunk, so the error points at the wrong place.</li>
<li><strong>&ldquo;Unsupported&rdquo; is sometimes just undocumented.</strong> Multi-value lookups and person columns sat on my can&rsquo;t-do list for far too long. Before you route an operation to the older API, check whether the modern one only lacks a doc page.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>There&rsquo;s no winner here. CSOM, SharePoint REST and Graph are three drawers in the same toolbox, and picking per operation beats picking per project.</p>
<p>My rule of thumb: <strong>Graph for files, CSOM for list items, SharePoint REST when nothing else can do it - and verify the write whenever you cross a boundary.</strong> The failure mode that actually hurt me wasn&rsquo;t choosing the slower API. It was choosing the API that said yes and did nothing.</p>
]]></content:encoded></item><item><title>Your Add-in Gets a Free Folder in Everyone's OneDrive (Use It)</title><link>https://jeppe-spanggaard.dk/blogs/outlook-addin-onedrive-approot-preferences/</link><pubDate>Mon, 15 Jun 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/outlook-addin-onedrive-approot-preferences/</guid><description>Learn how to store Outlook add-in user preferences in the OneDrive App Folder via the Graph approot endpoint, so settings roam across every device.</description><content:encoded><![CDATA[<p>My Outlook add-in has a handful of user preferences: which tab to open by default, language, a &ldquo;don&rsquo;t show this tip again&rdquo; flag. Small stuff. A single JSON blob.</p>
<p>So where do you put it? <code>localStorage</code> is per-device, per-browser, and mobile WebViews evict it whenever they feel like it. A backend with a database is a lot of infrastructure for one JSON file per user. And then it hit me: every single one of my users already <em>has</em> cloud storage my add-in can reach. Their OneDrive.</p>
<h2 id="what-the-app-folder-actually-is">What the App Folder Actually Is</h2>
<p>First time your app touches <code>approot</code>, OneDrive creates a folder under <code>Apps/&lt;your app's name&gt;</code> (the name comes from your Entra app registration). It&rsquo;s the user&rsquo;s storage, visible to them in OneDrive, but sandboxed for you: request <code>Files.ReadWrite.AppFolder</code> and that folder is <em>all</em> your app can see. No scary &ldquo;this app can read all your files&rdquo; consent screen.</p>
<p>One endpoint to remember:</p>
<pre tabindex="0"><code>/me/drive/special/approot
</code></pre><h2 id="saving-settings">Saving Settings</h2>
<p>I use PnPjs (<code>@pnp/graph</code>), so a save is three lines:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">import</span> { <span style="color:#a6e22e">SpecialFolder</span> } <span style="color:#66d9ef">from</span> <span style="color:#e6db74">&#34;@pnp/graph/files&#34;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">async</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">saveUserSettings</span>(<span style="color:#a6e22e">settings</span>: <span style="color:#66d9ef">UserDefinedSettings</span>)<span style="color:#f92672">:</span> <span style="color:#a6e22e">Promise</span>&lt;<span style="color:#f92672">void</span>&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">appRoot</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">graphFI</span>.<span style="color:#a6e22e">me</span>.<span style="color:#a6e22e">drive</span>.<span style="color:#a6e22e">special</span>(<span style="color:#a6e22e">SpecialFolder</span>.<span style="color:#a6e22e">AppRoot</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">appRoot</span>.<span style="color:#a6e22e">upload</span>({
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">content</span>: <span style="color:#66d9ef">JSON.stringify</span>(<span style="color:#a6e22e">settings</span>),
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">filePathName</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;user-settings.json&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">contentType</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;application/json&#34;</span>,
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>No PnPjs? The raw Graph equivalent is a single PUT: <code>PUT /me/drive/special/approot:/user-settings.json:/content</code>.</p>
<h2 id="reading-settings-and-surviving-the-first-run">Reading Settings (and Surviving the First Run)</h2>
<p>Reading has one twist: the very first time a user opens your add-in, the file doesn&rsquo;t exist yet. That&rsquo;s not an error, that&rsquo;s a new user. Plan for it:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">defaultSettings</span>: <span style="color:#66d9ef">UserDefinedSettings</span> <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">openPreviewInNewTab</span>: <span style="color:#66d9ef">false</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">returnToHomeAfterArchive</span>: <span style="color:#66d9ef">false</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">hasSeenPinTip</span>: <span style="color:#66d9ef">false</span>,
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">async</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getUserSettings</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">Promise</span>&lt;<span style="color:#f92672">UserDefinedSettings</span>&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">appRoot</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">graphFI</span>.<span style="color:#a6e22e">me</span>.<span style="color:#a6e22e">drive</span>.<span style="color:#a6e22e">special</span>(<span style="color:#a6e22e">SpecialFolder</span>.<span style="color:#a6e22e">AppRoot</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">children</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">appRoot</span>.<span style="color:#a6e22e">children</span>();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">fileItem</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">children</span>.<span style="color:#a6e22e">find</span>((<span style="color:#a6e22e">item</span>) <span style="color:#f92672">=&gt;</span> <span style="color:#a6e22e">item</span>.<span style="color:#a6e22e">name</span> <span style="color:#f92672">===</span> <span style="color:#e6db74">&#34;user-settings.json&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">fileItem</span>) {
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">defaultSettings</span>; <span style="color:#75715e">// first run - no file yet
</span></span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">blob</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">graphFI</span>.<span style="color:#a6e22e">me</span>.<span style="color:#a6e22e">drive</span>.<span style="color:#a6e22e">getItemById</span>(<span style="color:#a6e22e">fileItem</span>.<span style="color:#a6e22e">id</span><span style="color:#f92672">!</span>).<span style="color:#a6e22e">getContent</span>();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">json</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">JSON</span>.<span style="color:#a6e22e">parse</span>(<span style="color:#66d9ef">await</span> <span style="color:#a6e22e">blob</span>.<span style="color:#a6e22e">text</span>());
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> {
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">openPreviewInNewTab</span>: <span style="color:#66d9ef">json.openPreviewInNewTab</span> <span style="color:#f92672">??</span> <span style="color:#a6e22e">defaultSettings</span>.<span style="color:#a6e22e">openPreviewInNewTab</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">returnToHomeAfterArchive</span>: <span style="color:#66d9ef">json.returnToHomeAfterArchive</span> <span style="color:#f92672">??</span> <span style="color:#a6e22e">defaultSettings</span>.<span style="color:#a6e22e">returnToHomeAfterArchive</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">hasSeenPinTip</span>: <span style="color:#66d9ef">json.hasSeenPinTip</span> <span style="color:#f92672">??</span> <span style="color:#a6e22e">defaultSettings</span>.<span style="color:#a6e22e">hasSeenPinTip</span>,
</span></span><span style="display:flex;"><span>    };
</span></span><span style="display:flex;"><span>  } <span style="color:#66d9ef">catch</span> (<span style="color:#a6e22e">error</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">console</span>.<span style="color:#a6e22e">error</span>(<span style="color:#e6db74">&#34;Error getting user settings:&#34;</span>, <span style="color:#a6e22e">error</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">defaultSettings</span>; <span style="color:#75715e">// any failure - the add-in still works
</span></span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li>Missing file → defaults, not an exception. New users get a working add-in, not an error toast.</li>
<li>Every field is parsed with a <code>??</code> fallback. When I ship a new setting next month, users with an old <code>user-settings.json</code> don&rsquo;t break, the new field just gets its default.</li>
<li>The whole thing is wrapped in try/catch that returns defaults. Settings are a nice-to-have; they should never take the add-in down with them.</li>
</ol>
<p>Two practical habits: load the settings once at boot and cache them (don&rsquo;t re-read OneDrive on every render), and save optimistically - update your local state immediately, fire the upload without awaiting it. And the best part comes for free: change a setting on desktop, open the phone, it&rsquo;s there. 🎁</p>
<h2 id="not-just-outlook-add-ins-spfx-too">Not Just Outlook Add-ins: SPFx Too</h2>
<p>This pattern isn&rsquo;t tied to Outlook at all, it just needs a user context and a Graph token. That makes it a great fit for <strong>SPFx web parts</strong> as well.</p>
<p>Web part properties in SPFx are per-instance and per-page, and usually something an editor configures, not the end user. If you want <em>user</em>-level preferences - a collapsed/expanded state, a preferred view, a dismissed banner - that follow the user across every page and site where your web part lives, the App Folder solves it with zero extra infrastructure. Grab <code>MSGraphClientV3</code> from the SPFx context and hit the same <code>/me/drive/special/approot:/user-settings.json:/content</code> endpoint. Same folder, same JSON file, same permission model.</p>
<h2 id="gotchas">Gotchas</h2>
<ul>
<li><strong>Ask for <code>Files.ReadWrite.AppFolder</code>, not <code>Files.ReadWrite</code>.</strong> If the app folder is all you need, the scoped permission gets you a much friendlier consent prompt and a much smaller blast radius.</li>
<li><strong>The folder is named after your app registration&rsquo;s display name.</strong> Rename the registration and users get a <em>new</em> empty folder, your settings file stays behind in the old one.</li>
<li><strong>Users can see (and delete) the folder.</strong> It&rsquo;s their OneDrive. Treat a missing file as a normal state, always, not just on first run.</li>
<li><strong>Coming from PnPjs v2, <code>.get()</code> is not a function.</strong> The imports above are v3 selective imports: <code>@pnp/graph/files</code> brings the drive methods onto the fluent chain, and the v2 <code>.get()</code> terminator is gone in favour of <code>getContent()</code>, <code>getItemById()</code> and friends. If a v2-era snippet dies on <code>.get is not a function</code>, you&rsquo;re missing the selective import, not the package.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>User preferences don&rsquo;t need a database, and they deserve better than <code>localStorage</code>. The OneDrive App Folder is the middle ground that&rsquo;s easy to miss: zero infrastructure on your side, real cloud persistence on theirs, and a permission model that only exposes what your app actually needs. Outlook add-in, SPFx web part, anything with a Graph token - same trick everywhere.</p>
]]></content:encoded></item><item><title>Outlook Mobile Has No getAsFileAsync: Fall Back to Graph</title><link>https://jeppe-spanggaard.dk/blogs/outlook-addin-graph-fallback-mobile/</link><pubDate>Fri, 05 Jun 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/outlook-addin-graph-fallback-mobile/</guid><description>Learn how to fall back to the Microsoft Graph MIME endpoint when getAsFileAsync isn't available in Outlook on mobile, so your add-in keeps working everywhere.</description><content:encoded><![CDATA[<p>In <a href="https://jeppe-spanggaard.dk/blogs/outlook-addin-manifest-requirement-sets-mobile/">my last post</a> I showed how declaring a low Mailbox requirement set in the manifest got my Outlook add-in to <em>show up</em> on mobile. Great. Button&rsquo;s there, task pane opens, everything looks alive.</p>
<p>Then the user taps &ldquo;Archive to SharePoint&rdquo; and the whole feature stands on one API: <code>getAsFileAsync()</code>, which hands you the entire email as a file. That API lives in Mailbox 1.14. Outlook mobile doesn&rsquo;t have it.</p>
<p>So now I had the opposite problem from last time: instead of an invisible add-in, I had a visible add-in with a dead button. Honestly, that&rsquo;s worse.</p>
<p>Visible ≠ functional. This post is about the second half of the mobile story: getting the same email bytes through a different door.</p>
<h2 id="the-desktop-path-just-ask-outlook">The Desktop Path: Just Ask Outlook</h2>
<p>On desktop and web, Office.js does all the work. You ask the host for the current message as a file, and it hands you the EML as base64. No network call, no token, no permissions dance, Outlook already <em>has</em> the email.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getMessageAsBlob</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">Promise</span><span style="color:#f92672">&lt;</span>{ <span style="color:#a6e22e">bytes</span>: <span style="color:#66d9ef">Uint8Array</span>; <span style="color:#a6e22e">blob</span>: <span style="color:#66d9ef">Blob</span> }<span style="color:#f92672">&gt;</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Promise</span>((<span style="color:#a6e22e">resolve</span>, <span style="color:#a6e22e">reject</span>) <span style="color:#f92672">=&gt;</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">requirements</span>.<span style="color:#a6e22e">isSetSupported</span>(<span style="color:#e6db74">&#34;Mailbox&#34;</span>, <span style="color:#e6db74">&#34;1.14&#34;</span>)) {
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">reject</span>(<span style="color:#66d9ef">new</span> Error(<span style="color:#e6db74">&#34;This client does not support Mailbox 1.14 (getAsFileAsync).&#34;</span>));
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">item</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">mailbox</span>.<span style="color:#a6e22e">item</span> <span style="color:#66d9ef">as</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">MessageRead</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">item</span>.<span style="color:#a6e22e">getAsFileAsync</span>((<span style="color:#a6e22e">asyncResult</span>) <span style="color:#f92672">=&gt;</span> {
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">asyncResult</span>.<span style="color:#a6e22e">status</span> <span style="color:#f92672">===</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">AsyncResultStatus</span>.<span style="color:#a6e22e">Failed</span>) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">reject</span>(<span style="color:#a6e22e">asyncResult</span>.<span style="color:#a6e22e">error</span>);
</span></span><span style="display:flex;"><span>      }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      <span style="color:#75715e">// getAsFileAsync returns the EML as base64
</span></span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">bytes</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">Uint8Array</span>.<span style="color:#66d9ef">from</span>(<span style="color:#a6e22e">atob</span>(<span style="color:#a6e22e">asyncResult</span>.<span style="color:#a6e22e">value</span>), (<span style="color:#a6e22e">c</span>) <span style="color:#f92672">=&gt;</span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">charCodeAt</span>(<span style="color:#ae81ff">0</span>));
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">blob</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Blob</span>([<span style="color:#a6e22e">bytes</span>], { <span style="color:#66d9ef">type</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;message/rfc822&#34;</span> });
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">resolve</span>({ <span style="color:#a6e22e">bytes</span>, <span style="color:#a6e22e">blob</span> });
</span></span><span style="display:flex;"><span>    });
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Notice the first thing this function does: check <code>isSetSupported(&quot;Mailbox&quot;, &quot;1.14&quot;)</code> and reject if it&rsquo;s not there. That reject is not an error case, it&rsquo;s a <em>signal</em>. Remember it, it becomes important in a minute.</p>
<h2 id="the-graph-path-ask-exchange-instead">The Graph Path: Ask Exchange Instead</h2>
<p>Here&rsquo;s the realization that saved the mobile experience: <strong>Outlook doesn&rsquo;t own your email, Exchange does.</strong> The host app is just one way to get at it. Microsoft Graph is another, and Graph has an endpoint that returns the raw MIME content of any message:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">async</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getMessageAsBlobViaGraph</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">Promise</span><span style="color:#f92672">&lt;</span>{ <span style="color:#a6e22e">bytes</span>: <span style="color:#66d9ef">Uint8Array</span>; <span style="color:#a6e22e">blob</span>: <span style="color:#66d9ef">Blob</span> }<span style="color:#f92672">&gt;</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">item</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">mailbox</span>.<span style="color:#a6e22e">item</span> <span style="color:#66d9ef">as</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">MessageRead</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">item</span><span style="color:#f92672">?</span>.<span style="color:#a6e22e">itemId</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> Error(<span style="color:#e6db74">&#34;No item ID available for Graph-based archive.&#34;</span>);
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">token</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">getToken</span>([<span style="color:#e6db74">&#34;Mail.ReadWrite.Shared&#34;</span>]);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">mimeUrl</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`https://graph.microsoft.com/v1.0/me/messages/</span><span style="color:#e6db74">${</span>encodeURIComponent(<span style="color:#a6e22e">item</span>.<span style="color:#a6e22e">itemId</span>)<span style="color:#e6db74">}</span><span style="color:#e6db74">/$value`</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">resp</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">fetch</span>(<span style="color:#a6e22e">mimeUrl</span>, {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">method</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;GET&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">headers</span><span style="color:#f92672">:</span> { <span style="color:#a6e22e">Authorization</span><span style="color:#f92672">:</span> <span style="color:#e6db74">`Bearer </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">token</span><span style="color:#e6db74">}</span><span style="color:#e6db74">`</span> },
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">resp</span>.<span style="color:#a6e22e">ok</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> Error(<span style="color:#e6db74">`Error </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">resp</span>.<span style="color:#a6e22e">status</span><span style="color:#e6db74">}</span><span style="color:#e6db74">: </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">resp</span>.<span style="color:#a6e22e">statusText</span><span style="color:#e6db74">}</span><span style="color:#e6db74">`</span>);
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">buffer</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">resp</span>.<span style="color:#a6e22e">arrayBuffer</span>();
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">bytes</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Uint8Array</span>(<span style="color:#a6e22e">buffer</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">blob</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Blob</span>([<span style="color:#a6e22e">buffer</span>], { <span style="color:#66d9ef">type</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;message/rfc822&#34;</span> });
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> { <span style="color:#a6e22e">bytes</span>, <span style="color:#a6e22e">blob</span> };
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li><code>item.itemId</code> is all we need from Office.js, and reading it only requires <strong>Mailbox 1.1</strong>. That&rsquo;s the beautiful part: the manifest floor from the last post promised almost nothing, and this fallback only <em>needs</em> almost nothing.</li>
<li>The <code>$value</code> segment on <code>/me/messages/{id}</code> tells Graph to skip the JSON representation and return the raw RFC 822 message, the same EML you&rsquo;d get from <code>getAsFileAsync</code>.</li>
<li>We wrap it in a <code>Blob</code> with <code>message/rfc822</code>, the exact same shape the desktop path produces. Same email, different source.</li>
<li>The token comes from MSAL with the <code>Mail.ReadWrite.Shared</code> scope. And yes, getting a token <em>inside Outlook mobile</em> is its own adventure, the magic words are Nested App Auth (NAA), where sign-in goes through the Microsoft Authenticator app instead of a browser popup. That one deserves its own post someday.</li>
</ol>
<p>One honest caveat: I pass <code>item.itemId</code> straight to Graph without converting it. That works because modern hosts (including mobile) hand out REST-format IDs. If your add-in also runs in older Outlook clients that still produce EWS-format IDs, run the ID through <code>Office.context.mailbox.convertToRestId()</code> first, or Graph will give you a very confusing 404.</p>
<h2 id="dont-branch-fall-back">Don&rsquo;t Branch, Fall Back</h2>
<p>So we have two functions. The tempting way to pick between them is <code>if (isMobile) { ... } else { ... }</code>. I did it differently, and I&rsquo;m glad I did:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">let</span> <span style="color:#a6e22e">messageAsBlob</span><span style="color:#f92672">:</span> { <span style="color:#a6e22e">bytes</span>: <span style="color:#66d9ef">Uint8Array</span>; <span style="color:#a6e22e">blob</span>: <span style="color:#66d9ef">Blob</span> };
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">messageAsBlob</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">getMessageAsBlob</span>();
</span></span><span style="display:flex;"><span>} <span style="color:#66d9ef">catch</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#75715e">// Office.js getAsFileAsync unavailable (e.g. mobile) - fall back to Graph
</span></span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">getMessageAsBlobViaGraph</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">messageAsBlob</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">getMessageAsBlobViaGraph</span>();
</span></span><span style="display:flex;"><span>  } <span style="color:#66d9ef">else</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> Error(<span style="color:#e6db74">&#34;Email export is not available on this platform.&#34;</span>);
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>Always try Office.js first, and let the failure route you to Graph.</strong> Remember that reject on the 1.14 check? On mobile it fires every time, and the catch block quietly takes the Graph road instead.</p>
<p>Why is this better than platform-branching? Because it&rsquo;s <em>capability</em>-based, not <em>platform</em>-based. If some desktop client out there is running an older Outlook without 1.14, it gets the fallback for free. I never had to predict which platforms are broken, the code just asks &ldquo;did the good path work?&rdquo; and moves on.</p>
<p>I do add one belt-and-suspenders detail: the Graph function is only injected into this flow when platform detection says we&rsquo;re on mobile. On desktop it&rsquo;s <code>undefined</code>, so a genuinely broken desktop fails loudly with a clear message instead of silently making Graph calls I didn&rsquo;t expect.</p>
<h2 id="same-blob-same-pipeline">Same Blob, Same Pipeline</h2>
<p>This is the part I want you to steal. Both functions return the same thing:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span>{ <span style="color:#a6e22e">bytes</span>: <span style="color:#66d9ef">Uint8Array</span>; <span style="color:#a6e22e">blob</span>: <span style="color:#66d9ef">Blob</span> }  <span style="color:#75715e">// type: &#34;message/rfc822&#34;
</span></span></span></code></pre></div><p>Everything after this point - naming the file, uploading it to SharePoint, progress reporting, conflict handling - is <strong>one code path</strong>. The upload logic has no idea whether the bytes came from Office.js or from a Graph call. There&rsquo;s no <code>if (isMobile)</code> sprinkled through the upload code, no duplicated pipeline, nothing.</p>
<p>That&rsquo;s the whole trick, really. A good fallback isn&rsquo;t a second feature, it&rsquo;s a second <em>source</em> feeding the same feature. The moment your fallback needs its own downstream handling, you&rsquo;ve built two features and doubled your bugs.</p>
<h2 id="gotchas-i-hit-along-the-way">Gotchas I Hit Along the Way</h2>
<ul>
<li><strong>ID formats will bite you.</strong> REST IDs and EWS IDs look similar enough (long base64-ish strings) that you won&rsquo;t spot the difference by eye. If Graph returns 404 for a message you&rsquo;re literally looking at, check the ID format before questioning your sanity. <code>convertToRestId()</code> is the fix.</li>
<li><strong>Graph only knows what the server knows.</strong> <code>getAsFileAsync</code> reads from the host, Graph reads from Exchange. For a message that <em>just</em> arrived, the server side can lag a beat behind what Outlook is already showing you. Rare, but real.</li>
<li><strong>No retry for free.</strong> Office.js calls fail locally and instantly. The Graph call is a network request that can hit throttling or transient errors, and a bare <code>fetch</code> won&rsquo;t retry anything. Mine throws on non-OK responses and lets the surrounding archive flow surface the error; depending on your feature, a retry with backoff might be worth it.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>The <a href="https://jeppe-spanggaard.dk/blogs/outlook-addin-manifest-requirement-sets-mobile/">manifest post</a> got the add-in through the door on mobile. This post made it actually earn its place there: use Office.js when the host can deliver, fall back to Graph when it can&rsquo;t, and make both paths hand over identical bytes so the rest of your app never has to care.</p>
<p>Outlook is just one door to the mailbox. When it&rsquo;s locked, Graph is around the back. 🚪</p>
]]></content:encoded></item><item><title>SharePoint News Links via Graph SDK: Filling in the Gaps the Docs Left Behind</title><link>https://jeppe-spanggaard.dk/blogs/graph-beta-sdk-news-link-with-banner-image/</link><pubDate>Tue, 03 Mar 2026 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/graph-beta-sdk-news-link-with-banner-image/</guid><description>Learn how to create SharePoint News Link pages with a banner image using the Microsoft Graph Beta SDK in C# — including the multipart upload the official documentation leaves completely undocumented.</description><content:encoded><![CDATA[<h2 id="the-problem-with-the-documentation">The Problem With the Documentation</h2>
<p>News Link pages in SharePoint — those cards that link to external news articles — are only available through the Graph API&rsquo;s beta endpoint. That&rsquo;s fine, beta APIs are part of life.</p>
<p>What&rsquo;s <em>not</em> fine is that Microsoft&rsquo;s <a href="https://learn.microsoft.com/en-us/graph/api/newslinkpage-create?view=graph-rest-beta">official documentation</a> covers the simple case well, but the moment you want to add a banner image, the C# code snippet disappears and is replaced with:</p>
<p><strong>&ldquo;Snippet not available.&rdquo;</strong></p>
<p>Great. Thanks.</p>
<p>The banner image upload requires a multipart request. Figuring out how to do that with the Graph Beta SDK means piecing together documentation about <code>MultipartBody</code>, some Kiota internals, and a few quirks you&rsquo;ll only discover by actually trying it.</p>
<p>This post covers the full working solution.</p>
<h2 id="prerequisites">Prerequisites</h2>
<p>Install these two NuGet packages:</p>
<pre tabindex="0"><code>Microsoft.Graph.Beta
Microsoft.Kiota.Serialization.Multipart
</code></pre><p><code>Microsoft.Graph.Beta</code> is the Beta SDK — it contains <code>NewsLinkPage</code> and all the beta models. <code>Microsoft.Kiota.Serialization.Multipart</code> provides the <code>MultipartBody</code> class needed to construct multipart requests. Without it, you don&rsquo;t have the types to send binary data alongside the JSON payload.</p>
<h2 id="the-simple-case-no-banner-image">The Simple Case: No Banner Image</h2>
<p>If you just want a News Link without a banner image, the Beta SDK fluent API handles it cleanly:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> page = <span style="color:#66d9ef">new</span> NewsLinkPage
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    OdataType = <span style="color:#e6db74">&#34;#microsoft.graph.newsLinkPage&#34;</span>,
</span></span><span style="display:flex;"><span>    Title = <span style="color:#e6db74">&#34;Contoso Unveils First Self-Driving Car&#34;</span>,
</span></span><span style="display:flex;"><span>    NewsWebUrl = <span style="color:#e6db74">&#34;https://someexternalnewssite.com/article&#34;</span>,
</span></span><span style="display:flex;"><span>    Description = <span style="color:#e6db74">&#34;A brief description of the article.&#34;</span>
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> result = <span style="color:#66d9ef">await</span> GraphClient.Sites[siteId].Pages.PostAsync(page, requestConfiguration =&gt;
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    requestConfiguration.Headers.Add(<span style="color:#e6db74">&#34;prefer&#34;</span>, <span style="color:#e6db74">&#34;include-unknown-enum-members&#34;</span>);
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><p>Two things to note:</p>
<p>The <code>Prefer: include-unknown-enum-members</code> header is required. Without it, the API won&rsquo;t return <code>newsLink</code> as a valid <code>pageLayoutType</code> value — it&rsquo;s an evolvable enum that hasn&rsquo;t been promoted to v1.0 yet, so Graph treats it as unknown by default.</p>
<p>The page is also created as a draft. You still need to publish it separately before it shows up in the news feed.</p>
<h2 id="the-full-solution-with-banner-image">The Full Solution: With Banner Image</h2>
<p>Here&rsquo;s the complete implementation including banner image upload and publishing:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">internal</span> <span style="color:#66d9ef">async</span> Task CreateNewsLink(
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> siteId,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string?</span> title,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string?</span> url,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string?</span> description,
</span></span><span style="display:flex;"><span>    Stream? bannerImageContent)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> page = <span style="color:#66d9ef">new</span> NewsLinkPage
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        OdataType = <span style="color:#e6db74">&#34;#microsoft.graph.newsLinkPage&#34;</span>,
</span></span><span style="display:flex;"><span>        Title = title,
</span></span><span style="display:flex;"><span>        NewsWebUrl = url,
</span></span><span style="display:flex;"><span>        Description = description,
</span></span><span style="display:flex;"><span>        AdditionalData = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">object</span>&gt;
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">            [&#34;@microsoft.graph.bannerImageWebUrlContent&#34;]</span> = <span style="color:#e6db74">&#34;name:content&#34;</span>
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> multipartBody = <span style="color:#66d9ef">new</span> MultipartBody();
</span></span><span style="display:flex;"><span>    multipartBody.AddOrReplacePart(<span style="color:#e6db74">&#34;request&#34;</span>, <span style="color:#e6db74">&#34;application/json&#34;</span>, page);
</span></span><span style="display:flex;"><span>    multipartBody.AddOrReplacePart(<span style="color:#e6db74">&#34;content&#34;</span>, <span style="color:#e6db74">&#34;image/jpeg&#34;</span>, bannerImageContent, fileName: <span style="color:#e6db74">&#34;banner.jpg&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> requestInfo = <span style="color:#66d9ef">new</span> RequestInformation
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        HttpMethod = Method.POST,
</span></span><span style="display:flex;"><span>        UrlTemplate = <span style="color:#e6db74">$&#34;https://graph.microsoft.com/beta/sites/{siteId}/pages&#34;</span>
</span></span><span style="display:flex;"><span>    };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    requestInfo.Headers.Add(<span style="color:#e6db74">&#34;prefer&#34;</span>, <span style="color:#e6db74">&#34;include-unknown-enum-members&#34;</span>);
</span></span><span style="display:flex;"><span>    requestInfo.SetContentFromParsable(GraphClient.RequestAdapter, <span style="color:#e6db74">&#34;multipart/form-data&#34;</span>, multipartBody);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> response = <span style="color:#66d9ef">await</span> GraphClient.RequestAdapter.SendAsync&lt;NewsLinkPage&gt;(
</span></span><span style="display:flex;"><span>        requestInfo,
</span></span><span style="display:flex;"><span>        NewsLinkPage.CreateFromDiscriminatorValue
</span></span><span style="display:flex;"><span>    );
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> createdNewslink = <span style="color:#66d9ef">await</span> GraphClient.Sites[siteId].Pages[response.Id].GetAsync(
</span></span><span style="display:flex;"><span>        requestConfiguration =&gt;
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            requestConfiguration.Headers.Add(<span style="color:#e6db74">&#34;prefer&#34;</span>, <span style="color:#e6db74">&#34;include-unknown-enum-members&#34;</span>);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    );
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> publishRequestInfo = <span style="color:#66d9ef">new</span> RequestInformation
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        HttpMethod = Method.POST,
</span></span><span style="display:flex;"><span>        UrlTemplate = <span style="color:#e6db74">$&#34;https://graph.microsoft.com/beta/sites/{siteId}/pages/{createdNewslink.Id}/microsoft.graph.newsLinkPage/publish&#34;</span>
</span></span><span style="display:flex;"><span>    };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> GraphClient.RequestAdapter.SendNoContentAsync(publishRequestInfo);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>It looks like a lot, but each part has a specific reason for being there. Let me explain the non-obvious bits.</p>
<h2 id="breaking-down-the-code">Breaking Down the Code</h2>
<h3 id="the-microsoftgraphbannerimageweburlcontent-annotation">The <code>@microsoft.graph.bannerImageWebUrlContent</code> Annotation</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span>AdditionalData = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">object</span>&gt;
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">    [&#34;@microsoft.graph.bannerImageWebUrlContent&#34;]</span> = <span style="color:#e6db74">&#34;name:content&#34;</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>This is the glue between the JSON part and the image bytes. The value <code>&quot;name:content&quot;</code> tells the Graph API: <em>&ldquo;find the image bytes in the multipart part named &lsquo;content&rsquo;&rdquo;</em>.</p>
<p>When the API processes the request, it reads this annotation, locates the <code>content</code> part in the multipart body, saves the image to the site&rsquo;s assets library, and sets the <code>bannerImageWebUrl</code> property on the created page. The naming has to match — the part you add with <code>AddOrReplacePart(&quot;content&quot;, ...)</code> is what the annotation references.</p>
<h3 id="why-multipartbody-instead-of-the-fluent-api">Why <code>MultipartBody</code> Instead of the Fluent API</h3>
<p>The normal fluent API — <code>GraphClient.Sites[siteId].Pages.PostAsync(...)</code> — only supports JSON payloads. There&rsquo;s no overload that accepts binary data or constructs a multipart request.</p>
<p><code>MultipartBody</code> fills that gap. You compose the request from named parts, each with their own content type:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> multipartBody = <span style="color:#66d9ef">new</span> MultipartBody();
</span></span><span style="display:flex;"><span>multipartBody.AddOrReplacePart(<span style="color:#e6db74">&#34;request&#34;</span>, <span style="color:#e6db74">&#34;application/json&#34;</span>, page);
</span></span><span style="display:flex;"><span>multipartBody.AddOrReplacePart(<span style="color:#e6db74">&#34;content&#34;</span>, <span style="color:#e6db74">&#34;image/jpeg&#34;</span>, bannerImageContent, fileName: <span style="color:#e6db74">&#34;banner.jpg&#34;</span>);
</span></span></code></pre></div><p>The <code>&quot;request&quot;</code> part carries the JSON, the <code>&quot;content&quot;</code> part carries the image bytes. The names are what you reference in <code>@microsoft.graph.bannerImageWebUrlContent</code>.</p>
<h3 id="why-requestinformation-directly">Why <code>RequestInformation</code> Directly</h3>
<p>Since the fluent API can&rsquo;t send multipart requests, we drop down one level to <code>RequestInformation</code> — the underlying request abstraction that all Kiota-generated clients use internally:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> requestInfo = <span style="color:#66d9ef">new</span> RequestInformation
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    HttpMethod = Method.POST,
</span></span><span style="display:flex;"><span>    UrlTemplate = <span style="color:#e6db74">$&#34;https://graph.microsoft.com/beta/sites/{siteId}/pages&#34;</span>
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>requestInfo.SetContentFromParsable(GraphClient.RequestAdapter, <span style="color:#e6db74">&#34;multipart/form-data&#34;</span>, multipartBody);
</span></span></code></pre></div><p><code>SetContentFromParsable</code> serializes the <code>MultipartBody</code> and sets the correct <code>Content-Type</code> header — including the boundary parameter that multipart requests require. This is one of those things you have to discover by reading the Kiota source code rather than the docs.</p>
<h3 id="the-extra-get-after-creation">The Extra GET After Creation</h3>
<p>You might notice the code fetches the page again right after creating it:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> createdNewslink = <span style="color:#66d9ef">await</span> GraphClient.Sites[siteId].Pages[response.Id].GetAsync(...);
</span></span></code></pre></div><p>This is a quirk of the multipart POST. The response from a raw <code>RequestInformation</code>-based call doesn&rsquo;t go through the same deserialization pipeline as the fluent API, which means the returned <code>NewsLinkPage</code> object isn&rsquo;t fully populated. Rather than fighting with it, a quick GET on the newly created page — with the proper <code>prefer</code> header — gives you a cleanly deserialized object with the correct <code>Id</code> to use for publishing.</p>
<h2 id="publishing-the-news-link">Publishing the News Link</h2>
<p>Pages are created as drafts. To make the News Link appear in the SharePoint news feed, you have to explicitly publish it:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> publishRequestInfo = <span style="color:#66d9ef">new</span> RequestInformation
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    HttpMethod = Method.POST,
</span></span><span style="display:flex;"><span>    UrlTemplate = <span style="color:#e6db74">$&#34;https://graph.microsoft.com/beta/sites/{siteId}/pages/{createdNewslink.Id}/microsoft.graph.newsLinkPage/publish&#34;</span>
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">await</span> GraphClient.RequestAdapter.SendNoContentAsync(publishRequestInfo);
</span></span></code></pre></div><p>Again, <code>RequestInformation</code> directly — the Graph Beta SDK&rsquo;s fluent API doesn&rsquo;t expose a typed publish method for <code>newsLinkPage</code>.</p>
<h2 id="what-about-the-v10-sdk">What About the v1.0 SDK?</h2>
<p>Not supported yet. The <code>NewsLinkPage</code> type, the <code>pageLayout: newsLink</code> enum value, and the publish endpoint are all beta-only. When the API graduates to v1.0, the approach should be almost identical — just swap <code>Microsoft.Graph.Beta</code> for <code>Microsoft.Graph</code>.</p>
<p>Until then, you&rsquo;re on beta. Microsoft officially cautions against using beta APIs in production, but in practice this particular API has been stable for a while. Use your own judgment.</p>
]]></content:encoded></item><item><title>Create Microsoft 365 Groups Without the Welcome Email Flood</title><link>https://jeppe-spanggaard.dk/blogs/graph-sdk-create-group-without-welcome-email/</link><pubDate>Sat, 14 Feb 2026 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/graph-sdk-create-group-without-welcome-email/</guid><description>Learn how to create Microsoft 365 groups programmatically with the Graph SDK in C# without sending a welcome email flood to every member.</description><content:encoded><![CDATA[<h2 id="the-problem">The Problem</h2>
<p>When building automated site provisioning, every Microsoft 365 group creation triggers a welcome email to each member by default. In an automated scenario where multiple groups are created at once, that quickly turns into a flood of emails your users didn&rsquo;t ask for — and they&rsquo;re going to call it spam.</p>
<p>Microsoft <a href="https://learn.microsoft.com/en-us/graph/group-set-options">documents</a> <code>WelcomeEmailDisabled</code> as a supported option, but doesn&rsquo;t show you how to actually set it from the Graph SDK in C#. That&rsquo;s what this post is about.</p>
<h2 id="the-weird-part-additionaldata">The Weird Part: AdditionalData</h2>
<p>If you look at the typed <code>Group</code> object in the SDK, you won&rsquo;t find a <code>ResourceBehaviorOptions</code> property anywhere. It has to be set through <code>AdditionalData</code> — a catch-all dictionary for properties that exist in the Graph API but aren&rsquo;t modeled as first-class typed properties in the SDK.</p>
<p>It works fine, but it does mean you lose IntelliSense and compile-time safety. The same pattern applies to <code>owners@odata.bind</code> and <code>members@odata.bind</code>, which let you assign owners and members at creation time without separate follow-up calls.</p>
<h2 id="the-solution">The Solution</h2>
<p>Here&rsquo;s the complete group creation request with welcome emails disabled, owners set, and members added — all in a single API call:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> ownerId = <span style="color:#e6db74">$&#34;https://graph.microsoft.com/v1.0/users/{ownerObjectId}&#34;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> groupMembers = <span style="color:#66d9ef">new</span> List&lt;<span style="color:#66d9ef">string</span>&gt;
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">$&#34;https://graph.microsoft.com/v1.0/users/{memberObjectId1}&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">$&#34;https://graph.microsoft.com/v1.0/users/{memberObjectId2}&#34;</span>
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Group requestBody = <span style="color:#66d9ef">new</span>()
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    Description = description,
</span></span><span style="display:flex;"><span>    DisplayName = displayName,
</span></span><span style="display:flex;"><span>    GroupTypes = [<span style="color:#e6db74">&#34;Unified&#34;</span>],
</span></span><span style="display:flex;"><span>    MailEnabled = <span style="color:#66d9ef">false</span>,
</span></span><span style="display:flex;"><span>    MailNickname = siteName,
</span></span><span style="display:flex;"><span>    SecurityEnabled = <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>    AdditionalData = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">object</span>&gt;
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        { <span style="color:#e6db74">&#34;resourceBehaviorOptions&#34;</span>, <span style="color:#66d9ef">new</span> List&lt;<span style="color:#66d9ef">string</span>&gt; { <span style="color:#e6db74">&#34;WelcomeEmailDisabled&#34;</span> } },
</span></span><span style="display:flex;"><span>        { <span style="color:#e6db74">&#34;owners@odata.bind&#34;</span>, <span style="color:#66d9ef">new</span> List&lt;<span style="color:#66d9ef">string</span>&gt; { ownerId } },
</span></span><span style="display:flex;"><span>        { <span style="color:#e6db74">&#34;members@odata.bind&#34;</span>, groupMembers }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Group? <span style="color:#66d9ef">group</span> = <span style="color:#66d9ef">await</span> _graphClient.Groups.PostAsync(requestBody);
</span></span></code></pre></div><h3 id="resourcebehavioroptions">resourceBehaviorOptions</h3>
<p><code>resourceBehaviorOptions</code> controls specific group behaviors at creation. <code>WelcomeEmailDisabled</code> simply stops Microsoft 365 from sending the welcome email to members.</p>
<p>You can also combine multiple options in the same list if needed:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span>{ <span style="color:#e6db74">&#34;resourceBehaviorOptions&#34;</span>, <span style="color:#66d9ef">new</span> List&lt;<span style="color:#66d9ef">string</span>&gt; { <span style="color:#e6db74">&#34;WelcomeEmailDisabled&#34;</span>, <span style="color:#e6db74">&#34;HideGroupInOutlook&#34;</span> } }
</span></span></code></pre></div><p>Other supported values are documented <a href="https://learn.microsoft.com/en-us/graph/group-set-options">here</a>.</p>
<h3 id="and"><a href="mailto:owners@odata.bind">owners@odata.bind</a> and <a href="mailto:members@odata.bind">members@odata.bind</a></h3>
<p>The <code>@odata.bind</code> syntax binds users to the group by their full resource URL at creation time, so you don&rsquo;t need separate <code>POST /groups/{id}/members</code> calls afterward. The URL format must be the full Graph v1.0 path:</p>
<pre tabindex="0"><code>https://graph.microsoft.com/v1.0/users/{objectId}
</code></pre>]]></content:encoded></item><item><title>Stop Retrying Everything: Smart Graph Batch Retry Logic</title><link>https://jeppe-spanggaard.dk/blogs/graph-batch-smart-retry/</link><pubDate>Mon, 22 Sep 2025 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/graph-batch-smart-retry/</guid><description>Learn smart retry logic for Microsoft Graph batching to optimize API calls, reduce throttling, and enhance user experience.</description><content:encoded><![CDATA[<h2 id="the-day-my-batch-requests-started-fighting-back">The Day My Batch Requests Started Fighting Back</h2>
<p>Picture this: It&rsquo;s 2 AM, you&rsquo;re on your third cup of coffee, and you&rsquo;re watching your perfectly crafted Microsoft Graph batch request fail spectacularly. Again.</p>
<p>You&rsquo;ve got 25 files to download from SharePoint. Your batch processes 24 of them perfectly, then one lonely file decides to throw a throttling tantrum. What does your retry logic do? It throws away all 24 successful downloads and starts over. From scratch. Like a digital Groundhog Day, but less amusing and more soul-crushing.</p>
<p>Sound familiar? Welcome to the &ldquo;retry everything&rdquo; club – where perfectly good API calls go to die unnecessarily. 😅</p>
<h2 id="the-grocery-cart-problem-or-why-were-doing-this-wrong">The Grocery Cart Problem (Or: Why We&rsquo;re Doing This Wrong)</h2>
<p>Let me paint you a picture. You&rsquo;re at the grocery store with a cart full of 20 items. You get to checkout, and the cashier says, &ldquo;Sorry, we&rsquo;re out of milk.&rdquo;</p>
<p>What would you do?</p>
<ul>
<li><strong>Option A:</strong> Put back everything, go home, and come back later to shop for all 20 items again</li>
<li><strong>Option B:</strong> Buy the 19 items you can get, then come back just for the milk</li>
</ul>
<p>If you picked Option A, congratulations – you think like most API retry logic! If you picked Option B, you&rsquo;re ready to learn about smart retries.</p>
<p><strong>The &ldquo;retry everything&rdquo; approach is like Option A, and here&rsquo;s why it&rsquo;s bonkers:</strong></p>
<ul>
<li>🔄 <strong>Wasted effort</strong>: You&rsquo;re re-requesting stuff that already worked perfectly</li>
<li>🐌 <strong>Slower performance</strong>: Users wait longer while you redo successful work</li>
<li>📈 <strong>Throttling amplification</strong>: You&rsquo;re actually making the problem worse by hitting successful endpoints again</li>
<li>🔍 <strong>Poor debugging</strong>: Can&rsquo;t easily identify which specific requests are the real troublemakers</li>
</ul>
<p>I learned this the hard way when I watched a simple file sync turn into an API call avalanche. 20 requests became 40, then 80, then&hellip; well, let&rsquo;s just say Microsoft&rsquo;s throttling system got very acquainted with my application.</p>
<h2 id="the-aha-moment-its-simpler-than-you-think">The &ldquo;Aha!&rdquo; Moment (It&rsquo;s Simpler Than You Think)</h2>
<p>The solution hit me during one of those 2 AM debugging sessions: <strong>What if we only retry the stuff that actually failed?</strong></p>
<p>Revolutionary, right? 😏</p>
<p>Here&rsquo;s the beautiful thing – this isn&rsquo;t some PhD-level computer science. It&rsquo;s just common sense applied to code. Keep the winners, retry the losers. Simple.</p>
<p>But (there&rsquo;s always a &ldquo;but&rdquo;), there&rsquo;s one sneaky technical challenge that makes this trickier than it sounds. Microsoft&rsquo;s Graph SDK has a helpful method called <code>NewBatchWithFailedRequests()</code>, but it has a quirk: it generates brand new request IDs. This breaks your ability to map responses back to your original data.</p>
<p>Think of it like this: You order pizza for table 5, but when they bring the replacement slice, they call it table 23. Good luck figuring out who ordered what!</p>
<p>If you&rsquo;re new to Graph batching or request mapping, I&rsquo;d recommend checking out my post on <a href="https://jeppe-spanggaard.dk/blogs/graph-batching-file-content-mapping/">Graph Batching for File Content: Mapping Requests to Responses</a> first. It&rsquo;s like the prequel to this story – explains how to keep track of what&rsquo;s what when dealing with batch responses.</p>
<h2 id="quick-win-summary-for-the-impatient-developers">Quick Win Summary (For the Impatient Developers)</h2>
<p><strong>The Problem:</strong> Your retry logic is like that friend who starts the entire conversation over when they missed one word. Inefficient and annoying.</p>
<p><strong>The Solution:</strong> A drop-in extension method that only retries the actual failures while keeping successful responses safe and sound.</p>
<p><strong>The Payoff:</strong></p>
<ul>
<li>⚡ Faster operations (no more re-downloading working files)</li>
<li>📉 Fewer API calls (your rate limits will thank you)</li>
<li>🎯 Less throttling (stop beating dead endpoints)</li>
<li>😌 Happier users (and happier you at 2 AM)</li>
</ul>
<p><strong>The Catch:</strong> You need to understand request-to-response mapping. Don&rsquo;t worry, it&rsquo;s not rocket science, and I&rsquo;ve got a whole post about it.</p>
<p><strong>Time Investment:</strong> About 5 minutes to implement, countless hours of frustration saved.</p>
<p>Ready for the nitty-gritty? Let&rsquo;s dive in! 👇</p>
<h2 id="the-hero-of-our-story-the-smart-retry-extension">The Hero of Our Story: The Smart Retry Extension</h2>
<p>Okay, here&rsquo;s where we get our hands dirty. The main challenge isn&rsquo;t just filtering out successful requests – it&rsquo;s that pesky <code>NewBatchWithFailedRequests</code> method that scrambles your request IDs like eggs at Sunday brunch.</p>
<p>Here&rsquo;s the extension method that saves the day:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#66d9ef">internal</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">GraphServiceClientExtensions</span> 
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">internal</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">async</span> Task&lt;(IReadOnlyDictionary&lt;<span style="color:#66d9ef">string</span>, HttpStatusCode&gt; Statuses, Dictionary&lt;<span style="color:#66d9ef">string</span>, HttpResponseMessage&gt; BatchResponse)&gt; 
</span></span><span style="display:flex;"><span>        PostBatchWithFailedDependencyRetriesAsync(<span style="color:#66d9ef">this</span> GraphServiceClient graphClient, BatchRequestContentCollection originalBatch) 
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">const</span> <span style="color:#66d9ef">int</span> maxRetries = <span style="color:#ae81ff">5</span>;
</span></span><span style="display:flex;"><span>        TimeSpan delay = TimeSpan.FromSeconds(<span style="color:#ae81ff">1</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        Dictionary&lt;<span style="color:#66d9ef">string</span>, HttpResponseMessage&gt; allResponses = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, HttpResponseMessage&gt;();
</span></span><span style="display:flex;"><span>        Dictionary&lt;<span style="color:#66d9ef">string</span>, HttpStatusCode&gt; allStatuses = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, HttpStatusCode&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        BatchRequestContentCollection batchToSend = originalBatch;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> (<span style="color:#66d9ef">int</span> attempt = <span style="color:#ae81ff">1</span>; attempt &lt;= maxRetries; attempt++) 
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            BatchResponseContentCollection batchResponse = <span style="color:#66d9ef">await</span> graphClient.Batch.PostAsync(batchToSend);
</span></span><span style="display:flex;"><span>            Dictionary&lt;<span style="color:#66d9ef">string</span>, HttpStatusCode&gt; responses = <span style="color:#66d9ef">await</span> batchResponse.GetResponsesStatusCodesAsync();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Filter out failures (excluding redirects which are normal for file content)</span>
</span></span><span style="display:flex;"><span>            Dictionary&lt;<span style="color:#66d9ef">string</span>, HttpStatusCode&gt; failedRequests = responses
</span></span><span style="display:flex;"><span>                .Where(kvp =&gt; !BatchResponseContent.IsSuccessStatusCode(kvp.Value) &amp;&amp; kvp.Value != HttpStatusCode.Found)
</span></span><span style="display:flex;"><span>                .ToDictionary(kvp =&gt; kvp.Key, kvp =&gt; kvp.Value);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Collect all responses from this attempt</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> kvp <span style="color:#66d9ef">in</span> responses) 
</span></span><span style="display:flex;"><span>            {
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">var</span> response = <span style="color:#66d9ef">await</span> batchResponse.GetResponseByIdAsync(kvp.Key);
</span></span><span style="display:flex;"><span>                allResponses[kvp.Key] = response;
</span></span><span style="display:flex;"><span>                allStatuses[kvp.Key] = kvp.Value;
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> (failedRequests.Count == <span style="color:#ae81ff">0</span> || attempt == maxRetries) 
</span></span><span style="display:flex;"><span>            {
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">await</span> Task.Delay(delay);
</span></span><span style="display:flex;"><span>            delay = TimeSpan.FromSeconds(delay.TotalSeconds * <span style="color:#ae81ff">2</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// The key problem: NewBatchWithFailedRequests creates new request IDs!</span>
</span></span><span style="display:flex;"><span>            batchToSend = batchToSend.NewBatchWithFailedRequests(responses);
</span></span><span style="display:flex;"><span>            
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// This is why we need this method - restore the original request IDs</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">await</span> RestoreOriginalRequestIdsAsync(batchToSend, originalBatch);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> (allStatuses, allResponses);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">async</span> Task RestoreOriginalRequestIdsAsync(
</span></span><span style="display:flex;"><span>        BatchRequestContentCollection newBatch, 
</span></span><span style="display:flex;"><span>        BatchRequestContentCollection originalBatch) 
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> stepsSnapshot = newBatch.BatchRequestSteps.ToArray();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> kvp <span style="color:#66d9ef">in</span> stepsSnapshot) 
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> oldStepId = kvp.Key;
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> step = kvp.Value;
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> requestPath = step.Request.RequestUri!.AbsolutePath;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Find the original request ID by matching the request path</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> matchingOriginal = originalBatch.BatchRequestSteps
</span></span><span style="display:flex;"><span>                .First(x =&gt; x.Value.Request.RequestUri!.AbsolutePath == requestPath);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> originalStepId = matchingOriginal.Key;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> (oldStepId != originalStepId) 
</span></span><span style="display:flex;"><span>            {
</span></span><span style="display:flex;"><span>                newBatch.RemoveBatchRequestStepWithId(oldStepId);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">var</span> newStep = <span style="color:#66d9ef">new</span> BatchRequestStep(
</span></span><span style="display:flex;"><span>                    requestId: originalStepId,
</span></span><span style="display:flex;"><span>                    httpRequestMessage: step.Request,
</span></span><span style="display:flex;"><span>                    dependsOn: step.DependsOn);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>                newBatch.AddBatchRequestStep(newStep);
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong> Think of it as a diplomatic negotiator for your API calls:</p>
<ol>
<li><strong>The ID Shuffle Problem</strong>: <code>NewBatchWithFailedRequests</code> gives failed requests shiny new IDs, like witness protection for HTTP requests</li>
<li><strong>The Detective Work</strong>: <code>RestoreOriginalRequestIdsAsync</code> plays detective, matching requests by their paths to find their original identities</li>
<li><strong>The Happy Reunion</strong>: Failed requests get their original IDs back, so your mapping dictionary doesn&rsquo;t break down in tears</li>
</ol>
<p>It&rsquo;s like having a really good wedding planner who makes sure everyone sits at the right table, even after the venue changes.</p>
<h2 id="showtime-watching-smart-retries-in-action">Showtime: Watching Smart Retries in Action</h2>
<p>Now let&rsquo;s see our smart retry logic work its magic in a real-world scenario. Imagine you&rsquo;re building a document sync tool and need to download 25 files from SharePoint. Some will work perfectly, others might throw tantrums due to throttling or network hiccups.</p>
<p>Here&rsquo;s how the new approach handles it like a champ:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#75715e">// Let&#39;s say you need to download content from 25 SharePoint files</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> batch = <span style="color:#66d9ef">new</span> BatchRequestContentCollection(graphClient);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> fileMapping = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, FileContentRequest&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Build the batch for file content downloads</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> filesToDownload = <span style="color:#66d9ef">await</span> GetFilesToProcess(); <span style="color:#75715e">// Your method to get file list</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> file <span style="color:#66d9ef">in</span> filesToDownload) 
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> requestInfo = graphClient.Sites[siteId]
</span></span><span style="display:flex;"><span>                                .Drives[driveId]
</span></span><span style="display:flex;"><span>                                .Items[file.DriveItemId]
</span></span><span style="display:flex;"><span>                                .Content
</span></span><span style="display:flex;"><span>                                .ToGetRequestInformation();
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> requestId = <span style="color:#66d9ef">await</span> batch.AddBatchRequestStepAsync(requestInfo);
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Map the request ID to your file info (same pattern as previous post)</span>
</span></span><span style="display:flex;"><span>    fileMapping[requestId] = <span style="color:#66d9ef">new</span> FileContentRequest 
</span></span><span style="display:flex;"><span>    { 
</span></span><span style="display:flex;"><span>        DriveItemId = file.DriveItemId,
</span></span><span style="display:flex;"><span>        FileName = file.Name,
</span></span><span style="display:flex;"><span>        ExpectedSize = file.Size,
</span></span><span style="display:flex;"><span>        DownloadStartTime = DateTime.Now
</span></span><span style="display:flex;"><span>    };
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// 🎯 Here&#39;s where the magic happens - just one line change!</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> (statuses, responses) = <span style="color:#66d9ef">await</span> graphClient.PostBatchWithFailedDependencyRetriesAsync(batch);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Process results - this is where the retry really shines</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> successfulDownloads = <span style="color:#66d9ef">new</span> List&lt;FileDownloadResult&gt;();
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> failedDownloads = <span style="color:#66d9ef">new</span> List&lt;<span style="color:#66d9ef">string</span>&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> kvp <span style="color:#66d9ef">in</span> fileMapping) 
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> requestId = kvp.Key;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> fileRequest = kvp.Value;
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (responses.TryGetValue(requestId, <span style="color:#66d9ef">out</span> <span style="color:#66d9ef">var</span> response)) 
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> statusCode = statuses[requestId];
</span></span><span style="display:flex;"><span>        
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (BatchResponseContent.IsSuccessStatusCode(statusCode)) 
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Success! Handle the file content</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> contentBytes = <span style="color:#66d9ef">await</span> response.Content.ReadAsByteArrayAsync();
</span></span><span style="display:flex;"><span>            
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Save to your desired location</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> localPath = Path.Combine(downloadFolder, fileRequest.FileName);
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">await</span> File.WriteAllBytesAsync(localPath, contentBytes);
</span></span><span style="display:flex;"><span>            
</span></span><span style="display:flex;"><span>            successfulDownloads.Add(<span style="color:#66d9ef">new</span> FileDownloadResult 
</span></span><span style="display:flex;"><span>            { 
</span></span><span style="display:flex;"><span>                FileName = fileRequest.FileName,
</span></span><span style="display:flex;"><span>                LocalPath = localPath,
</span></span><span style="display:flex;"><span>                ActualSize = contentBytes.Length,
</span></span><span style="display:flex;"><span>                ExpectedSize = fileRequest.ExpectedSize,
</span></span><span style="display:flex;"><span>                DownloadTime = DateTime.Now - fileRequest.DownloadStartTime
</span></span><span style="display:flex;"><span>            });
</span></span><span style="display:flex;"><span>        } 
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">else</span> 
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Even after smart retries, this file failed</span>
</span></span><span style="display:flex;"><span>            failedDownloads.Add(<span style="color:#e6db74">$&#34;{fileRequest.FileName} ({statusCode})&#34;</span>);
</span></span><span style="display:flex;"><span>            
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Log the specific failure for debugging</span>
</span></span><span style="display:flex;"><span>            Console.WriteLine(<span style="color:#e6db74">$&#34;Failed to download {fileRequest.FileName}: {statusCode}&#34;</span>);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Always clean up the response</span>
</span></span><span style="display:flex;"><span>        response.Dispose();
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Console.WriteLine(<span style="color:#e6db74">$&#34;Successfully downloaded: {successfulDownloads.Count} files&#34;</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (failedDownloads.Any())
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    Console.WriteLine(<span style="color:#e6db74">$&#34;Failed downloads: {string.Join(&#34;</span>, <span style="color:#e6db74">&#34;, failedDownloads)}&#34;</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>The beautiful part?</strong> Look at that line with the magic emoji 🎯. That&rsquo;s literally the only change you need to make to your existing batch processing code. Everything else stays exactly the same.</p>
<p><strong>Here&rsquo;s what&rsquo;s happening behind the scenes:</strong></p>
<ol>
<li><strong>First batch attempt</strong>: Say 20 files succeed, 5 fail due to throttling</li>
<li><strong>Smart filtering</strong>: Keep those 20 successful responses safe</li>
<li><strong>Targeted retry</strong>: Build a new batch with just the 5 failures</li>
<li><strong>ID preservation</strong>: Make sure those 5 retries still map to your original file info</li>
<li><strong>Rinse and repeat</strong>: Maybe 4 of the 5 succeed on retry, leaving just 1 persistent troublemaker</li>
</ol>
<p><strong>The result?</strong> Instead of making 250 API calls (25 files × 5 retry attempts for the unlucky ones), you might only make 35 total calls. Your throttling problems become manageable, and files download way faster.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#75715e">// Supporting classes for the example above</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">FileContentRequest</span> 
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string</span> DriveItemId { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; } = <span style="color:#66d9ef">string</span>.Empty;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string</span> FileName { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; } = <span style="color:#66d9ef">string</span>.Empty;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">long</span> ExpectedSize { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> DateTime DownloadStartTime { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">FileDownloadResult</span> 
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string</span> FileName { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; } = <span style="color:#66d9ef">string</span>.Empty;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string</span> LocalPath { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; } = <span style="color:#66d9ef">string</span>.Empty;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">long</span> ActualSize { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">long</span> ExpectedSize { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> TimeSpan DownloadTime { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="the-method-to-the-madness-whats-really-happening">The Method to the Madness (What&rsquo;s Really Happening)</h2>
<p>I know that extension method looks intimidating – like trying to read assembly instructions in a foreign language. But once you break it down, it&rsquo;s actually pretty logical. Let me walk you through the step-by-step dance:</p>
<p><strong>Step 1: The First Attempt</strong>
&ldquo;Let&rsquo;s try everything once and see what happens&rdquo;</p>
<p>Sends your original batch of 25 files and carefully captures every single response and status code. No throwing anything away yet.</p>
<p><strong>Step 2: The Great Sorting</strong>
&ldquo;Okay, who succeeded and who&rsquo;s being difficult?&rdquo;</p>
<p>Separates the winners from the losers, but (and this is important) ignores redirect responses. Why? Because when you&rsquo;re downloading large files, redirects are totally normal – SharePoint often redirects you to the actual storage location.</p>
<p><strong>Step 3: The Preservation Society</strong>
&ldquo;Keep the good stuff safe while we deal with the troublemakers&rdquo;</p>
<p>All successful responses get stored in a safe place while we build a new, smaller batch containing only the failed requests. It&rsquo;s like having a really good filing system for your API responses.</p>
<p><strong>Step 4: The Identity Crisis Resolution</strong>
&ldquo;Wait, who are you again? Let me check your original ID&hellip;&rdquo;</p>
<p>This is the tricky bit! The <code>NewBatchWithFailedRequests</code> method gives everyone new IDs, like a witness protection program for HTTP requests. Our <code>RestoreOriginalRequestIdsAsync</code> method plays detective, matching requests by their URL paths to restore their original identities.</p>
<p><strong>Step 5: The Polite Wait</strong>
&ldquo;Let&rsquo;s not be pushy – maybe try again in a second?&rdquo;</p>
<p>Implements <a href="https://docs.microsoft.com/en-us/azure/architecture/patterns/retry">exponential backoff</a> – starts with a 1-second wait, then 2 seconds, then 4 seconds, etc. This prevents your app from being that annoying person who keeps knocking on the door every second.</p>
<p><strong>Step 6: The Safety Net</strong>
&ldquo;Okay, we tried 5 times. Some files just aren&rsquo;t meant to be downloaded today.&rdquo;</p>
<p>Gives up after 5 attempts to prevent infinite retry loops. Because sometimes you need to know when to walk away from the poker table.</p>
<h2 id="why-this-actually-works-the-science-behind-the-magic">Why This Actually Works (The Science Behind the Magic)</h2>
<p>Here&rsquo;s what makes this approach so much better than the &ldquo;retry everything&rdquo; strategy:</p>
<p><strong>🎯 Surgical Precision</strong>
Only retry what actually failed – it&rsquo;s like having a really good therapist who focuses on the actual problems instead of rehashing everything from childhood.</p>
<p>No wasted API calls on requests that already succeeded. If 24 out of 25 files downloaded perfectly, why punish them with another round trip?</p>
<p><strong>⚡ Speed Demon</strong>
Successful requests don&rsquo;t get repeated, so everything finishes faster – sometimes dramatically faster.</p>
<p>Users see their successful downloads immediately while you quietly retry the problematic ones in the background.</p>
<p><strong>🤝 Throttling-Friendly</strong>
Fewer total requests means you&rsquo;re less likely to hit Microsoft&rsquo;s rate limits, and when you do, recovery is faster.</p>
<p>Instead of amplifying throttling issues, you&rsquo;re actually helping to resolve them by reducing load on the endpoints that are already struggling.</p>
<p><strong>🔄 Drop-in Simplicity</strong>
Change literally one line of code and you&rsquo;re done. No architectural rewrites, no complex state management – just swap out the method call.</p>
<p>Your existing error handling, logging, and business logic all stay exactly the same.</p>
<p><strong>🔍 Debug Paradise</strong>
Easy to see exactly which requests are consistently failing, making troubleshooting a breeze instead of a nightmare.</p>
<p>When file &ldquo;ImportantDocument.pdf&rdquo; fails on every retry attempt, you know there&rsquo;s something specific about that file, not your entire batch logic.</p>
<h2 id="the-before-and-after-moment">The &ldquo;Before and After&rdquo; Moment</h2>
<p>Let me paint you a picture of how this changes your life:</p>
<p><strong>Before Smart Retries:</strong></p>
<ul>
<li>25 file batch fails on 3 files due to throttling</li>
<li>Retry all 25 files → now 5 files fail due to increased throttling</li>
<li>Retry all 25 files again → now 8 files fail</li>
<li>You&rsquo;re now in the throttling spiral of doom</li>
<li>Users are staring at loading spinners</li>
<li>You&rsquo;re questioning your career choices</li>
</ul>
<p><strong>After Smart Retries:</strong></p>
<ul>
<li>25 file batch fails on 3 files due to throttling</li>
<li>Keep the 22 successful files, retry only the 3 failures</li>
<li>Maybe 2 of the 3 succeed on retry, leaving 1 stubborn file</li>
<li>Final retry gets the last file, or you log it as a persistent issue</li>
<li>Users get 24/25 files quickly, you sleep better at night</li>
</ul>
<p>It&rsquo;s the difference between being stuck in traffic because one lane is blocked (and everyone keeps switching to that lane), versus just using the open lanes and going around the problem.</p>
<h2 id="the-bottom-line-and-why-your-future-self-will-thank-you">The Bottom Line (And Why Your Future Self Will Thank You)</h2>
<p>I&rsquo;ll be real with you – when I first started working with Microsoft Graph batching, I thought the built-in retry policies were enough. &ldquo;How hard could it be?&rdquo; I thought. &ldquo;APIs fail sometimes, just retry them!&rdquo;</p>
<p>Then I built my first real-world document sync application. Suddenly, I was dealing with users uploading hundreds of files, enterprise throttling limits, and the occasional network hiccup that would bring the whole operation to a screeching halt.</p>
<p><strong>That&rsquo;s when I learned the hard way that &ldquo;retry everything&rdquo; is like using a sledgehammer to hang a picture frame.</strong> Sure, it might work, but you&rsquo;re probably going to break some stuff in the process.</p>
<p>This selective retry approach has been a game-changer. Not just for performance (though users definitely notice when their bulk operations actually complete), but for debugging too. When you can see that <code>ImportantReport_v23_FINAL_REALLY_FINAL.docx</code> is the file that keeps failing, you can actually do something about it.</p>
<p><strong>The best part?</strong> Once you have this extension method in your toolkit, it becomes muscle memory. You&rsquo;re not adding complexity to your day-to-day development – you&rsquo;re just swapping out one method call for a smarter one. It&rsquo;s like upgrading from a flip phone to a smartphone – you wonder how you ever lived without it.</p>
<p><strong>Pro tip:</strong> After implementing this, keep an eye on your application logs. You&rsquo;ll start to notice patterns in failures that you never saw before. Maybe certain file types are more prone to issues, or maybe there&rsquo;s a specific time of day when throttling gets worse. This kind of insight is pure gold for optimization.</p>
<p>The moral of the story? Sometimes the biggest performance improvements come not from doing things faster, but from doing fewer unnecessary things. And sometimes, the best debugging tool is just&hellip; not breaking the working stuff while you fix the broken stuff.</p>
<p>Your 2 AM debugging sessions will never be the same. 😌</p>
<h2 id="want-to-learn-more-the-reading-list">Want to Learn More? (The Reading List)</h2>
<ul>
<li><strong><a href="https://docs.microsoft.com/en-us/graph/json-batching">Microsoft Graph JSON batching</a></strong> - The official documentation (surprisingly readable!)</li>
<li><strong><a href="https://jeppe-spanggaard.dk/blogs/graph-batching-file-content-mapping/">Graph Batching for File Content: Mapping Requests to Responses</a></strong> - My previous post that sets up the foundation for this one</li>
<li><strong><a href="https://docs.microsoft.com/en-us/graph/throttling">Microsoft Graph throttling guidance</a></strong> - Understanding what makes Microsoft&rsquo;s APIs cranky</li>
<li><strong><a href="https://developer.microsoft.com/en-us/graph/graph-explorer">Graph Explorer</a></strong> - Test your batch requests interactively (great for experimenting)</li>
<li><strong><a href="https://docs.microsoft.com/en-us/azure/architecture/patterns/retry">Exponential Backoff Pattern</a></strong> - The polite way to retry things</li>
</ul>
<p>Now go forth and batch smarter, not harder! 🚀</p>
]]></content:encoded></item><item><title>Graph Batching for File Content: Mapping Requests to Responses</title><link>https://jeppe-spanggaard.dk/blogs/graph-batching-file-content-mapping/</link><pubDate>Wed, 10 Sep 2025 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/graph-batching-file-content-mapping/</guid><description>How to handle Graph batching when downloading file content and mapping responses back to original requests</description><content:encoded><![CDATA[<h2 id="the-problem-thatll-drive-you-crazy">The Problem That&rsquo;ll Drive You Crazy</h2>
<p>Picture this: you need to download 50 files from SharePoint using Microsoft Graph. Being a good developer, you decide to use batching instead of making 50 individual API calls (because nobody wants to wait that long, and Microsoft&rsquo;s throttling limits aren&rsquo;t going anywhere).</p>
<p>You set up your batch request, send it off, and get your responses back. Great! Except&hellip; now you&rsquo;re staring at a bunch of file content with absolutely no way to tell which file is which. 😅</p>
<p>Unlike other Graph operations that return nice JSON objects with IDs and metadata, file content responses are just raw bytes. No file name, no path, no ID - nothing to help you figure out which response belongs to which original request.</p>
<p>I learned this the hard way when I first tried Graph batching for file downloads. Spent way too much time trying to correlate responses by file size or content patterns before realizing there was a much cleaner solution.</p>
<h2 id="the-mapping-solution-its-simpler-than-you-think">The Mapping Solution (It&rsquo;s Simpler Than You Think)</h2>
<p>The trick is surprisingly straightforward: use the batch request ID as your bridge between the original file info and the response content. Every batch request gets a unique ID, and that same ID comes back with the response.</p>
<p>Here&rsquo;s the game plan:</p>
<ol>
<li>Create a dictionary mapping request IDs to your original file information</li>
<li>Build your batch requests and store the mappings</li>
<li>Process responses using the request ID to look up the original file info</li>
</ol>
<p>Let me show you exactly how this works.</p>
<h2 id="setting-up-your-file-information">Setting Up Your File Information</h2>
<p>First, let&rsquo;s create a simple model to hold our file details:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">FileInfoDTO</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> Path { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> Name { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> RelativePath { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> UniqueFileName { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Nothing fancy here - just the basics we need to identify and process each file.</p>
<h2 id="the-complete-batch-download-method">The Complete Batch Download Method</h2>
<p>Here&rsquo;s the full implementation. Don&rsquo;t worry, I&rsquo;ll break down the important parts afterward:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">async</span> Task&lt;Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">byte</span>[]&gt;&gt; DownloadFilesBatchAsync(
</span></span><span style="display:flex;"><span>    FileInfoDTO[] fileInfos, 
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> siteId, 
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> driveId) {
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">using</span> BatchRequestContent batchRequestContent = <span style="color:#66d9ef">new</span> BatchRequestContent();
</span></span><span style="display:flex;"><span>    Dictionary&lt;<span style="color:#66d9ef">string</span>, FileInfoDTO&gt; requestMapping = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, FileInfoDTO&gt;();
</span></span><span style="display:flex;"><span>    Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">byte</span>[]&gt; results = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">byte</span>[]&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Build batch requests with mapping</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> (FileInfoDTO fileInfo <span style="color:#66d9ef">in</span> fileInfos) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (<span style="color:#66d9ef">string</span>.IsNullOrEmpty(fileInfo.RelativePath) || <span style="color:#66d9ef">string</span>.IsNullOrEmpty(fileInfo.UniqueFileName))
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">continue</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">string</span> requestId = batchRequestContent.AddBatchRequestStep(
</span></span><span style="display:flex;"><span>            GraphClient.Sites[siteId]
</span></span><span style="display:flex;"><span>                      .Drives[driveId]
</span></span><span style="display:flex;"><span>                      .Root
</span></span><span style="display:flex;"><span>                      .ItemWithPath(fileInfo.RelativePath)
</span></span><span style="display:flex;"><span>                      .Content
</span></span><span style="display:flex;"><span>                      .Request());
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// This is the magic - storing the mapping!</span>
</span></span><span style="display:flex;"><span>        requestMapping[requestId] = fileInfo;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Execute batch request</span>
</span></span><span style="display:flex;"><span>    BatchResponseContent batchResponse = <span style="color:#66d9ef">await</span> GraphClient.Batch.Request().PostAsync(batchRequestContent);
</span></span><span style="display:flex;"><span>    Dictionary&lt;<span style="color:#66d9ef">string</span>, HttpResponseMessage&gt; responses = <span style="color:#66d9ef">await</span> batchResponse.GetResponsesAsync();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Process responses using our mapping</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> ((<span style="color:#66d9ef">string</span> requestId, HttpResponseMessage response) <span style="color:#66d9ef">in</span> responses) {
</span></span><span style="display:flex;"><span>        FileInfoDTO originalFile = requestMapping[requestId]; <span style="color:#75715e">// Look up the original file info</span>
</span></span><span style="display:flex;"><span>        
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">switch</span> (response.StatusCode) {
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">case</span> HttpStatusCode.OK:
</span></span><span style="display:flex;"><span>                    <span style="color:#66d9ef">byte</span>[] content = <span style="color:#66d9ef">await</span> response.Content.ReadAsByteArrayAsync();
</span></span><span style="display:flex;"><span>                    results[originalFile.UniqueFileName!] = content;
</span></span><span style="display:flex;"><span>                    <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>                    
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">case</span> HttpStatusCode.Redirect:
</span></span><span style="display:flex;"><span>                    <span style="color:#75715e">// Handle redirect for large files (more on this below)</span>
</span></span><span style="display:flex;"><span>                    <span style="color:#66d9ef">byte</span>[] redirectContent = <span style="color:#66d9ef">await</span> DownloadFromRedirectAsync(response.Headers.Location);
</span></span><span style="display:flex;"><span>                    results[originalFile.UniqueFileName!] = redirectContent;
</span></span><span style="display:flex;"><span>                    <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>                    
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">case</span> HttpStatusCode.TooManyRequests:
</span></span><span style="display:flex;"><span>                    <span style="color:#75715e">// Handle throttling</span>
</span></span><span style="display:flex;"><span>                    <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> Exception(<span style="color:#e6db74">$&#34;Throttled request for {originalFile.Name}&#34;</span>);
</span></span><span style="display:flex;"><span>                    
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">default</span>:
</span></span><span style="display:flex;"><span>                    <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> Exception(<span style="color:#e6db74">$&#34;Failed to download {originalFile.Name}: {response.ReasonPhrase}&#34;</span>);
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">finally</span> {
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Always dispose - learned this one the hard way after some memory leak hunting</span>
</span></span><span style="display:flex;"><span>            response.Content.Dispose();
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> results;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="the-key-parts-explained">The Key Parts Explained</h2>
<h3 id="the-mapping-dictionary">The Mapping Dictionary</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span>Dictionary&lt;<span style="color:#66d9ef">string</span>, FileInfoDTO&gt; requestMapping = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, FileInfoDTO&gt;();
</span></span></code></pre></div><p>This is your lifeline. For every request you add to the batch, you store the request ID and link it to your original file information. When responses come back, you can instantly look up which file each response belongs to.</p>
<h3 id="building-requests-with-mapping">Building Requests with Mapping</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">string</span> requestId = batchRequestContent.AddBatchRequestStep(...);
</span></span><span style="display:flex;"><span>requestMapping[requestId] = fileInfo;
</span></span></code></pre></div><p>The <code>AddBatchRequestStep</code> method returns a unique request ID. Store this immediately - you&rsquo;ll need it to match responses later.</p>
<h2 id="handling-the-redirect-curveball">Handling the Redirect Curveball</h2>
<p>Here&rsquo;s something that caught me off guard initially: large files don&rsquo;t return content directly. Instead, Graph gives you a redirect to an Azure Blob Storage URL where the actual file lives. Microsoft doesn&rsquo;t specify the exact file size threshold, but in practice, I&rsquo;ve observed this happening with files larger than a few MB.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">async</span> Task&lt;<span style="color:#66d9ef">byte</span>[]&gt; DownloadFromRedirectAsync(Uri? redirectUri) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (redirectUri == <span style="color:#66d9ef">null</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> ArgumentException(<span style="color:#e6db74">&#34;Redirect URI is null&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">using</span> HttpClient httpClient = <span style="color:#66d9ef">new</span> HttpClient();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">await</span> httpClient.GetByteArrayAsync(redirectUri);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>This happens because Microsoft doesn&rsquo;t want to push huge files through the Graph API unnecessarily. The redirect URL is temporary and works great - just make sure you handle it properly.</p>
<p><strong>Note:</strong> The exact file size that triggers a redirect isn&rsquo;t officially documented by Microsoft, so always handle both direct content (200 OK) and redirect (302) responses in your code.</p>
<h2 id="error-handling-that-actually-helps">Error Handling That Actually Helps</h2>
<p>When things go wrong (and they will), you want meaningful error messages. Here&rsquo;s how to handle the common scenarios:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">async</span> Task ProcessBatchResponseAsync(
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> requestId, 
</span></span><span style="display:flex;"><span>    HttpResponseMessage response, 
</span></span><span style="display:flex;"><span>    FileInfoDTO originalFile,
</span></span><span style="display:flex;"><span>    Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">byte</span>[]&gt; results) {
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">switch</span> (response.StatusCode) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">case</span> HttpStatusCode.OK:
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">byte</span>[] content = <span style="color:#66d9ef">await</span> response.Content.ReadAsByteArrayAsync();
</span></span><span style="display:flex;"><span>                results[originalFile.UniqueFileName!] = content;
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>                
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">case</span> HttpStatusCode.Redirect:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">case</span> HttpStatusCode.Found:
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">byte</span>[] redirectContent = <span style="color:#66d9ef">await</span> DownloadFromRedirectAsync(response.Headers.Location);
</span></span><span style="display:flex;"><span>                results[originalFile.UniqueFileName!] = redirectContent;
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>                
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">case</span> HttpStatusCode.NotFound:
</span></span><span style="display:flex;"><span>                Console.WriteLine(<span style="color:#e6db74">$&#34;File not found: {originalFile.Name} at {originalFile.RelativePath}&#34;</span>);
</span></span><span style="display:flex;"><span>                <span style="color:#75715e">// Maybe the file was moved or deleted</span>
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>                
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">case</span> HttpStatusCode.TooManyRequests:
</span></span><span style="display:flex;"><span>                Console.WriteLine(<span style="color:#e6db74">$&#34;Throttled request for: {originalFile.Name}&#34;</span>);
</span></span><span style="display:flex;"><span>                <span style="color:#75715e">// This is where retry logic would go (coming in the next post!)</span>
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>                
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">case</span> HttpStatusCode.Forbidden:
</span></span><span style="display:flex;"><span>                Console.WriteLine(<span style="color:#e6db74">$&#34;Access denied for: {originalFile.Name}&#34;</span>);
</span></span><span style="display:flex;"><span>                <span style="color:#75715e">// Check your permissions</span>
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>                
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">default</span>:
</span></span><span style="display:flex;"><span>                Console.WriteLine(<span style="color:#e6db74">$&#34;Unexpected error downloading {originalFile.Name}: {response.StatusCode} - {response.ReasonPhrase}&#34;</span>);
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">finally</span> {
</span></span><span style="display:flex;"><span>        response.Content?.Dispose(); <span style="color:#75715e">// Don&#39;t leak memory!</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="important-things-to-remember">Important Things to Remember</h2>
<h3 id="batch-size-limits">Batch Size Limits</h3>
<p>Graph batching has a hard limit of 20 requests per batch. If you have more files, you&rsquo;ll need to chunk them:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#66d9ef">int</span> BATCH_SIZE = <span style="color:#ae81ff">20</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> (<span style="color:#66d9ef">int</span> i = <span style="color:#ae81ff">0</span>; i &lt; fileInfos.Length; i += BATCH_SIZE) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> batch = fileInfos.Skip(i).Take(BATCH_SIZE).ToArray();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> batchResults = <span style="color:#66d9ef">await</span> DownloadFilesBatchAsync(batch, siteId, driveId);
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Merge results...</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="memory-management">Memory Management</h3>
<p>Always dispose of <code>HttpResponseMessage.Content</code>. File downloads can be large, and forgetting to dispose will cause memory leaks that are painful to debug.</p>
<h3 id="request-id-uniqueness">Request ID Uniqueness</h3>
<p>Request IDs are unique within a single batch, but not across different batches. Don&rsquo;t try to reuse mappings between different batch operations.</p>
<h2 id="why-this-pattern-works-so-well">Why This Pattern Works So Well</h2>
<p>This approach has several advantages that make it my go-to solution:</p>
<ul>
<li><strong>Dead Simple</strong>: No complex logic, just a straightforward mapping pattern</li>
<li><strong>Reliable</strong>: Works consistently regardless of file sizes or response order</li>
<li><strong>Memory Efficient</strong>: Proper cleanup prevents memory leaks</li>
<li><strong>Debuggable</strong>: Easy to trace issues when something goes wrong</li>
<li><strong>Extensible</strong>: Perfect foundation for adding retry logic later</li>
</ul>
<p>The beauty is in its simplicity. You&rsquo;re not trying to guess which response belongs to which request - you know exactly because you mapped it from the start.</p>
<h2 id="whats-next">What&rsquo;s Next?</h2>
<p>This mapping pattern solves the core problem of correlating Graph batch responses with your original requests. But what happens when some requests fail due to throttling or temporary errors?</p>
<p>In my next post, I&rsquo;ll show you how to build retry logic on top of this foundation that automatically handles failed requests without losing track of which files still need to be downloaded.</p>
<p>Have you run into this mapping challenge before? Let me know in the comments how you solved it - I&rsquo;m always curious about different approaches! 🚀</p>
]]></content:encoded></item><item><title>DevProxy: How to Test API Rate Limiting and Throttling in C# Development</title><link>https://jeppe-spanggaard.dk/blogs/devproxy-throttling-testing/</link><pubDate>Sun, 10 Aug 2025 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/devproxy-throttling-testing/</guid><description>Discover how to simulate Microsoft Graph and SharePoint throttling locally using DevProxy, and prevent production slowdowns before they happen.</description><content:encoded><![CDATA[<h2 id="the-problem-when-parallel-programming-backfires">The Problem: When Parallel Programming Backfires</h2>
<p>If you’ve ever optimized your C# API calls with parallel programming, you might know this story.</p>
<p>Your client wants faster performance. You run multiple API requests in parallel. Locally, everything flies — especially late at night when traffic is low. But the next morning, on the final pre-deployment test, disaster strikes.</p>
<p><strong>Random errors. Requests delayed for 45 seconds.</strong><br>
Microsoft Graph or SharePoint has slammed you with throttling and rate limits. Your blazing-fast local solution crumbles under production-like conditions.</p>
<hr>
<h2 id="how-crud-operations-can-secretly-burn-through-your-limits--and-how-to-catch-them-with-devproxy">How CRUD Operations Can Secretly Burn Through Your Limits — and How to Catch Them with DevProxy</h2>
<p>Here’s the thing:<br>
SharePoint Online charges “Resource Units” (RUs) for every request you make. Think of RUs as an invisible currency — every API call you send deducts from your allowance. Run out too fast, and throttling kicks in.</p>
<p>And those “simple” operations? They’re not as cheap as you think.</p>
<p><strong>From Microsoft’s RU table</strong>:</p>
<table>
  <thead>
      <tr>
          <th>Operation Type</th>
          <th>RU Cost</th>
          <th>What That Means</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Single item query</strong></td>
          <td>1 RU</td>
          <td>Reading one specific list item</td>
      </tr>
      <tr>
          <td><strong>Multi-item query</strong></td>
          <td>2 RUs</td>
          <td>Listing children, filtering, sorting</td>
      </tr>
      <tr>
          <td><strong>Create / Update / Delete / Upload</strong></td>
          <td>2 RUs</td>
          <td>Per individual operation (bulk operations may be more efficient)</td>
      </tr>
      <tr>
          <td><strong>Permission expansion</strong> (<code>$expand=permissions</code>)</td>
          <td>5 RUs</td>
          <td>Heavy permission lookups</td>
      </tr>
  </tbody>
</table>
<blockquote>
<p><strong>Source:</strong> <a href="https://learn.microsoft.com/en-us/sharepoint/dev/general-development/how-to-avoid-getting-throttled-or-blocked-in-sharepoint-online#resource-units">Microsoft&rsquo;s official SharePoint throttling documentation</a></p>
</blockquote>
<p>Permission expansions are just one type of &ldquo;expensive&rdquo; call. Another common scenario that can impact RU consumption is <strong>making multiple separate queries instead of using CAML joins</strong> — something I explored in <a href="https://jeppe-spanggaard.dk/blogs/joining-multiple-lists-csom-caml/">my CAML join post</a>.<br>
While joining multiple lists in a single query is actually more efficient than separate calls, poorly structured queries or retrieving unnecessarily large datasets can still consume RUs quickly if not optimized properly.</p>
<hr>
<h3 id="why-this-sneaks-up-on-you">Why This Sneaks Up on You</h3>
<p>When coding locally, it’s easy to run a handful of queries without noticing.<br>
But in production, with real concurrency and volume, these calls pile up in seconds.</p>
<p>Imagine:</p>
<ul>
<li>10 parallel updates (2 RUs each) = <strong>20 RUs in one burst</strong></li>
<li>Add a few joins/expansions or large list queries, and you’re suddenly <em>burning through limits 5x faster</em>.</li>
</ul>
<hr>
<h3 id="how-devproxy-can-show-you-the-pain-before-production">How DevProxy Can Show You the Pain Before Production</h3>
<p>Here’s where DevProxy shines.<br>
If you configure it to simulate throttling based on these RU-heavy calls, you’ll <em>see</em> the impact locally:</p>
<ol>
<li><strong>Enable throttling plugins</strong> in your <code>devproxy.json</code> (as shown earlier).</li>
<li>Point <code>urlsToWatch</code> at your SharePoint endpoints.</li>
<li>Run your CRUD-heavy code.</li>
</ol>
<p>DevProxy will start throwing 429s (Too Many Requests) once your “fake” RU budget is exhausted — just like SharePoint would in production.</p>
<p>The beautiful part?<br>
You can crank the limits <em>down</em> during testing to make expensive patterns obvious. Even a single large query or unbatched <code>Update</code> will light up your logs.</p>
<p>Example DevProxy log when hitting a throttling simulation:</p>
<pre tabindex="0"><code>[Warning] Throttling triggered: 2 parallel Create calls exceeded RU limit (RateLimitingPlugin)
Retry after: 30 seconds
</code></pre><p><strong>Pro tip:</strong><br>
Use DevProxy as a <strong>budget meter</strong> for your API calls. Treat every 2-RU and 5-RU operation as “spending big” — and redesign those spots <em>before</em> they become a production outage.</p>
<h2 id="what-is-devproxy">What is DevProxy?</h2>
<p><a href="https://github.com/dotnet/dev-proxy">DevProxy</a> is an open-source HTTP/HTTPS proxy server that can simulate real-world network issues, including:</p>
<ul>
<li><a href="https://learn.microsoft.com/en-us/microsoft-cloud/dev/dev-proxy/concepts/what-is-rate-limiting"><strong>Rate limiting</strong></a></li>
<li><a href="https://learn.microsoft.com/en-us/microsoft-cloud/dev/dev-proxy/concepts/what-is-throttling"><strong>Throttling</strong></a></li>
<li><a href="https://learn.microsoft.com/en-us/microsoft-cloud/dev/dev-proxy/how-to/simulate-slow-api-responses"><strong>Network delays</strong></a></li>
<li><a href="https://learn.microsoft.com/en-us/microsoft-cloud/dev/dev-proxy/how-to/test-my-app-with-random-errors"><strong>Intermittent errors</strong></a></li>
</ul>
<p>It’s free, open source, and designed for developers who want to <strong>catch performance bottlenecks before they reach production</strong>.</p>
<hr>
<h2 id="installing-devproxy">Installing DevProxy</h2>
<p>Follow the official setup guide here:<br>
<a href="https://learn.microsoft.com/en-us/microsoft-cloud/dev/dev-proxy/get-started/set-up">DevProxy Installation Documentation</a></p>
<hr>
<h2 id="basic-usage">Basic Usage</h2>
<p>Run DevProxy with the default configuration:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>devproxy
</span></span></code></pre></div><p>It will begin intercepting all HTTP/HTTPS requests.</p>
<blockquote>
<p><strong>Tip:</strong> Start DevProxy <em>before</em> running your own code — otherwise, it won’t capture the traffic.</p>
</blockquote>
<hr>
<h2 id="simulating-microsoft-graph-throttling">Simulating Microsoft Graph Throttling</h2>
<p>Here’s how to configure DevProxy to reproduce Microsoft Graph and SharePoint throttling issues locally.</p>
<h3 id="1-create-a-devproxyjson-configuration-file">1. Create a <code>devproxy.json</code> configuration file</h3>
<p>This is the exact configuration I used when testing my problematic parallel code:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;$schema&#34;</span>: <span style="color:#e6db74">&#34;https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v0.27.0/rc.schema.json&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;rate&#34;</span>: <span style="color:#ae81ff">25</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;plugins&#34;</span>: [
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;name&#34;</span>: <span style="color:#e6db74">&#34;RetryAfterPlugin&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;enabled&#34;</span>: <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;pluginPath&#34;</span>: <span style="color:#e6db74">&#34;~appFolder/plugins/dev-proxy-plugins.dll&#34;</span>
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;name&#34;</span>: <span style="color:#e6db74">&#34;RateLimitingPlugin&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;enabled&#34;</span>: <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;pluginPath&#34;</span>: <span style="color:#e6db74">&#34;~appFolder/plugins/dev-proxy-plugins.dll&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;configSection&#34;</span>: <span style="color:#e6db74">&#34;rateLimitingPlugin&#34;</span>
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;name&#34;</span>: <span style="color:#e6db74">&#34;GraphRandomErrorPlugin&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;enabled&#34;</span>: <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;pluginPath&#34;</span>: <span style="color:#e6db74">&#34;~appFolder/plugins/dev-proxy-plugins.dll&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;configSection&#34;</span>: <span style="color:#e6db74">&#34;graphRandomErrorPlugin&#34;</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>  ],
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;urlsToWatch&#34;</span>: [
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://graph.microsoft.com/v1.0/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://graph.microsoft.com/beta/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://graph.microsoft.us/v1.0/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://graph.microsoft.us/beta/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://dod-graph.microsoft.us/v1.0/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://dod-graph.microsoft.us/beta/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://microsoftgraph.chinacloudapi.cn/v1.0/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://microsoftgraph.chinacloudapi.cn/beta/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://*.sharepoint.*/*_api/web/GetClientSideComponents&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://*.sharepoint.*/*_api/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://*.sharepoint.*/*_vti_bin/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://*.sharepoint-df.*/*_api/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://*.sharepoint-df.*/*_vti_bin/*&#34;</span>
</span></span><span style="display:flex;"><span>  ],
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;graphRandomErrorPlugin&#34;</span>: {
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;$schema&#34;</span>: <span style="color:#e6db74">&#34;https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v0.27.0/graphrandomerrorplugin.schema.json&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;allowedErrors&#34;</span>: [
</span></span><span style="display:flex;"><span>      <span style="color:#ae81ff">429</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#ae81ff">503</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#ae81ff">504</span>
</span></span><span style="display:flex;"><span>    ]
</span></span><span style="display:flex;"><span>  },
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;rateLimitingPlugin&#34;</span>: {
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;$schema&#34;</span>: <span style="color:#e6db74">&#34;https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v0.27.0/ratelimitingplugin.schema.json&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;costPerRequest&#34;</span>: <span style="color:#ae81ff">2</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;rateLimit&#34;</span>: <span style="color:#ae81ff">120</span>
</span></span><span style="display:flex;"><span>  },
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;logLevel&#34;</span>: <span style="color:#e6db74">&#34;information&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;newVersionNotification&#34;</span>: <span style="color:#e6db74">&#34;stable&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;showSkipMessages&#34;</span>: <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;showTimestamps&#34;</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="2-start-devproxy-with-your-configuration">2. Start DevProxy with your configuration</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>devproxy --config-file devproxy.json
</span></span></code></pre></div><hr>
<h2 id="example-of-problematic-code">Example of Problematic Code</h2>
<p>Here’s the snippet that caused my throttling nightmare. It uses <code>Parallel.ForEachAsync</code> to query SharePoint in bulk:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// ❌ This will likely trigger throttling in production</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> customers = <span style="color:#66d9ef">new</span> ConcurrentBag&lt;CustomerDTO&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">await</span> Parallel.ForEachAsync(departmentIds, <span style="color:#66d9ef">async</span> (departmentId, token) =&gt; 
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> clonedContext = _clientContext.Clone(_clientContext.Url);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> query = Camlex.Query()
</span></span><span style="display:flex;"><span>        .ViewFields(<span style="color:#66d9ef">new</span> CustomerDTO().ViewFields().ToArray().Append(<span style="color:#e6db74">&#34;DepartmentId&#34;</span>))
</span></span><span style="display:flex;"><span>        .LeftJoin(x =&gt; x[<span style="color:#e6db74">&#34;DepartmentLookup&#34;</span>].ForeignList(DEPARTMENT_LIST_GUID))
</span></span><span style="display:flex;"><span>        .ProjectedField(x =&gt; x[<span style="color:#e6db74">&#34;DepartmentId&#34;</span>].List(DEPARTMENT_LIST_GUID).ShowField(<span style="color:#e6db74">&#34;ID&#34;</span>))
</span></span><span style="display:flex;"><span>        .Where(x =&gt; x[<span style="color:#e6db74">&#34;DepartmentId&#34;</span>] == departmentId);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> departmentCustomers = <span style="color:#66d9ef">await</span> SharePointService.GetItemsFromListByQuery&lt;CustomerDTO&gt;(
</span></span><span style="display:flex;"><span>        CUSTOMER_LIST_GUID,
</span></span><span style="display:flex;"><span>        clonedContext,
</span></span><span style="display:flex;"><span>        query);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (departmentCustomers?.Any() == <span style="color:#66d9ef">true</span>)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> customer <span style="color:#66d9ef">in</span> departmentCustomers)
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            customers.Add(customer);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><hr>
<h2 id="why-this-code-fails-in-production">Why This Code Fails in Production</h2>
<ol>
<li><strong>No throttling safeguards</strong> — Multiple parallel requests overwhelm SharePoint.</li>
<li><strong>No retry logic</strong> — Requests fail instead of recovering.</li>
<li><strong>No request limiting</strong> — All departments are processed simultaneously.</li>
<li><strong>Silent failures</strong> — Errors are ignored without logging or fallback.</li>
</ol>
<hr>
<h2 id="the-better-way">The Better Way</h2>
<p>Instead of hard-coding fixes here, I recommend Bert Jansen’s detailed guide on throttling and rate limit handling:<br>
<a href="https://github.com/OneDrive/samples/blob/master/scenarios/throttling-ratelimit-handling/readme.md">Throttling &amp; Rate Limit Handling Patterns</a></p>
<p>Key principles:</p>
<ul>
<li><strong>Limit concurrency</strong> with <code>SemaphoreSlim</code>.</li>
<li><strong>Use exponential backoff</strong> for retries.</li>
<li><strong>Monitor rate limit headers</strong> to adjust requests dynamically.</li>
<li><strong>Handle errors explicitly</strong> to prevent silent failures.</li>
</ul>
<hr>
<h2 id="conclusion">Conclusion</h2>
<p>DevProxy has become an essential tool in my Microsoft 365 development workflow. It helps me:</p>
<ul>
<li>Catch throttling issues <strong>before</strong> production.</li>
<li>Test error handling and retry logic <strong>locally</strong>.</li>
<li>Deliver applications that can survive real-world API limits.</li>
</ul>
<p>If I’d used DevProxy from the start, I could have avoided the last-minute throttling meltdown entirely.</p>
<p>💡 <strong>Pro tip:</strong> Make DevProxy part of your <em>early</em> development process, not your emergency toolkit.</p>
<hr>
<h2 id="additional-resources">Additional Resources</h2>
<ul>
<li><a href="https://github.com/dotnet/dev-proxy">DevProxy GitHub Repository</a></li>
<li><a href="https://docs.microsoft.com/en-us/graph/throttling">Microsoft Graph Throttling Guidelines</a></li>
<li><a href="https://aka.ms/devproxy/docs">DevProxy Documentation</a></li>
</ul>
]]></content:encoded></item><item><title>Microsoft Graph SDK Authentication in C#: Quick Start Guide</title><link>https://jeppe-spanggaard.dk/blogs/graph-sdk-authentication-csharp/</link><pubDate>Fri, 20 Jun 2025 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/graph-sdk-authentication-csharp/</guid><description>Learn how to authenticate to Microsoft Graph in C# with ClientCertificateCredential, with working snippets for both the v4 SDK and the v5/v6 SDK.</description><content:encoded><![CDATA[<h2 id="what-is-microsoft-graph">What is Microsoft Graph?</h2>
<p>Microsoft Graph is the unified REST API for Microsoft 365. Think of it as a single gateway to access data across SharePoint, Teams, OneDrive, Outlook, Entra ID, and more, all through one consistent API.</p>
<p><strong>Graph vs CSOM/PnP.Framework:</strong></p>
<ul>
<li><strong>Graph</strong>: Modern REST API, works across all Microsoft 365 services</li>
<li><strong>CSOM/PnP</strong>: SharePoint-specific, more SharePoint features available</li>
</ul>
<h2 id="when-to-use-graph-sdk-vs-pnpframework">When to Use Graph SDK vs PnP.Framework</h2>
<p>Reach for the <strong>Graph SDK</strong> when you need Teams, OneDrive, Exchange or Entra ID data, when you want the built-in retry and batching, or when the feature spans several Microsoft 365 services.</p>
<p>Reach for <strong>PnP.Framework</strong> when you need the SharePoint-specific surface: site templates, provisioning, taxonomy, search refiners, managed metadata, content types.</p>
<p>Graph won&rsquo;t help you with SharePoint classic features like master pages and web parts, its search is thinner than the SharePoint Search API, and it can&rsquo;t talk to SharePoint Server on-premises at all.</p>
<h2 id="which-sdk-version-are-you-on">Which SDK Version Are You On?</h2>
<p>This is the part that costs people an afternoon, so it comes before the code.</p>
<p>The .NET SDK was rewritten in <strong>v5</strong>. It moved to Kiota-generated clients, and <code>.Request()</code> was removed from every call. That one change means a v4 sample does not compile on v5, and a v5 sample does not compile on v4. Most of the samples you&rsquo;ll find online, including the first version of this post, are v4.</p>
<p><strong>v6 is the current major, and v5 code compiles on it unchanged.</strong> I checked: the same file builds clean against <code>5.105.0</code> and <code>6.5.0</code>. So there are really two dialects to care about, not three.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># current: v5 syntax, v6 package</span>
</span></span><span style="display:flex;"><span>dotnet add package Microsoft.Graph --version 6.5.0
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># legacy: only if you&#39;re pinned to v4 already</span>
</span></span><span style="display:flex;"><span>dotnet add package Microsoft.Graph --version 4.54.0
</span></span></code></pre></div><p>Pin it. <code>Install-Package Microsoft.Graph</code> with no version gives you the newest major, and if you then paste a v4 sample into it you get a wall of <code>does not contain a definition for 'Request'</code>.</p>
<p>One thing to get out of the way: <strong>there is no <code>Microsoft.Graph.Auth</code> package</strong>. It never had a stable release and the namespace doesn&rsquo;t exist in v4, v5 or v6. If a sample tells you to add <code>using Microsoft.Graph.Auth;</code>, that sample predates all of this. You want <code>Azure.Identity</code>.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>dotnet add package Azure.Identity
</span></span></code></pre></div><h2 id="add-graph-permissions">Add Graph Permissions</h2>
<p>In your existing app registration, go to &ldquo;API permissions&rdquo; then &ldquo;Add a permission&rdquo; then &ldquo;Microsoft Graph&rdquo; then &ldquo;Application permissions&rdquo;:</p>
<ul>
<li><strong>Sites.ReadWrite.All</strong>: SharePoint sites and lists access</li>
</ul>
<p>Click &ldquo;Grant admin consent&rdquo; after adding permissions, or every call comes back 403.</p>
<h2 id="the-code-v5-and-v6">The Code, v5 and v6</h2>
<p>Same certificate and app registration as the <a href="https://jeppe-spanggaard.dk/blogs/pnp-framework-authentication-csharp/">PnP.Framework post</a>.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">using</span> Azure.Identity;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">using</span> Microsoft.Graph;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">using</span> Microsoft.Graph.Models;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">using</span> System.Security.Cryptography.X509Certificates;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Program</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">readonly</span> <span style="color:#66d9ef">string</span> TenantId = <span style="color:#e6db74">&#34;&lt;tenant-id&gt;&#34;</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">readonly</span> <span style="color:#66d9ef">string</span> ClientId = <span style="color:#e6db74">&#34;&lt;client-id&gt;&#34;</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">readonly</span> <span style="color:#66d9ef">string</span> CertificatePath = <span style="color:#e6db74">@&#34;C:\Temp\cert\appcert.pfx&#34;</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">readonly</span> <span style="color:#66d9ef">string</span> CertificatePassword = <span style="color:#e6db74">&#34;&lt;password&gt;&#34;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">async</span> Task Main(<span style="color:#66d9ef">string</span>[] args)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> certificate = X509CertificateLoader.LoadPkcs12FromFile(CertificatePath, CertificatePassword);
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> options = <span style="color:#66d9ef">new</span> ClientCertificateCredentialOptions
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            AuthorityHost = AzureAuthorityHosts.AzurePublicCloud,
</span></span><span style="display:flex;"><span>        };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> credential = <span style="color:#66d9ef">new</span> ClientCertificateCredential(TenantId, ClientId, certificate, options);
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> scopes = <span style="color:#66d9ef">new</span>[] { <span style="color:#e6db74">&#34;https://graph.microsoft.com/.default&#34;</span> };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> graphClient = <span style="color:#66d9ef">new</span> GraphServiceClient(credential, scopes);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> org = <span style="color:#66d9ef">await</span> graphClient.Organization.GetAsync();
</span></span><span style="display:flex;"><span>        Console.WriteLine(<span style="color:#e6db74">$&#34;Connected to: {org?.Value?.FirstOrDefault()?.DisplayName}&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> site = <span style="color:#66d9ef">await</span> graphClient.Sites[<span style="color:#e6db74">&#34;root&#34;</span>].GetAsync();
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> lists = <span style="color:#66d9ef">await</span> graphClient.Sites[site.Id].Lists.GetAsync();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        Console.WriteLine(<span style="color:#e6db74">&#34;Lists in this site:&#34;</span>);
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> list <span style="color:#66d9ef">in</span> lists?.Value ?? [])
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            Console.WriteLine(<span style="color:#e6db74">$&#34;- {list.DisplayName}&#34;</span>);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> docLib = (lists?.Value ?? []).FirstOrDefault(l =&gt; l.DisplayName == <span style="color:#e6db74">&#34;Documents&#34;</span>);
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (docLib != <span style="color:#66d9ef">null</span>)
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> content = System.Text.Encoding.UTF8.GetBytes(<span style="color:#e6db74">&#34;Hello from Graph SDK!&#34;</span>);
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> drive = <span style="color:#66d9ef">await</span> graphClient.Sites[site.Id].Lists[docLib.Id].Drive.GetAsync();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> file = <span style="color:#66d9ef">await</span> graphClient.Drives[drive.Id]
</span></span><span style="display:flex;"><span>                .Items[<span style="color:#e6db74">&#34;root:/sample-document.txt:&#34;</span>]
</span></span><span style="display:flex;"><span>                .Content
</span></span><span style="display:flex;"><span>                .PutAsync(<span style="color:#66d9ef">new</span> MemoryStream(content));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            Console.WriteLine(<span style="color:#e6db74">$&#34;Uploaded file: {file?.Name}&#34;</span>);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li><code>ClientCertificateCredential</code> comes from <code>Azure.Identity</code>, not from Graph. It&rsquo;s a <code>TokenCredential</code>, and <code>GraphServiceClient</code> takes one directly. No auth provider, no handler, no wrapper.</li>
<li><code>.default</code> as the scope is what tells Entra ID to issue every application permission you consented to. You don&rsquo;t list individual scopes for app-only auth.</li>
<li>No <code>.Request()</code> anywhere. In v5 the request builder ends in the verb, so it&rsquo;s <code>.GetAsync()</code>, <code>.PutAsync()</code>, <code>.PostAsync()</code> straight off the path.</li>
<li>Collections come back wrapped. It&rsquo;s <code>lists.Value</code>, not <code>lists</code>, and everything is nullable, so the compiler will nag you until you handle it. That nagging is correct: <code>Sites[&quot;root&quot;]</code> really can hand you back a null.</li>
<li><code>X509CertificateLoader</code> is .NET 9 and later. On .NET 8 or earlier use <code>new X509Certificate2(path, password)</code>, which still works but is marked obsolete (<code>SYSLIB0057</code>) on newer targets.</li>
</ol>
<h2 id="the-same-code-v4">The Same Code, v4</h2>
<p>If you&rsquo;re pinned to <code>4.54.0</code>, the credential setup is identical and only the calls change.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">using</span> Azure.Identity;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">using</span> Microsoft.Graph;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">using</span> System.Security.Cryptography.X509Certificates;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> certificate = <span style="color:#66d9ef">new</span> X509Certificate2(CertificatePath, CertificatePassword);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> options = <span style="color:#66d9ef">new</span> ClientCertificateCredentialOptions
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    AuthorityHost = AzureAuthorityHosts.AzurePublicCloud,
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> credential = <span style="color:#66d9ef">new</span> ClientCertificateCredential(TenantId, ClientId, certificate, options);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> scopes = <span style="color:#66d9ef">new</span>[] { <span style="color:#e6db74">&#34;https://graph.microsoft.com/.default&#34;</span> };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> graphClient = <span style="color:#66d9ef">new</span> GraphServiceClient(credential, scopes);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> org = <span style="color:#66d9ef">await</span> graphClient.Organization.Request().GetAsync();
</span></span><span style="display:flex;"><span>Console.WriteLine(<span style="color:#e6db74">$&#34;Connected to: {org.First().DisplayName}&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> site = <span style="color:#66d9ef">await</span> graphClient.Sites[<span style="color:#e6db74">&#34;root&#34;</span>].Request().GetAsync();
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> lists = <span style="color:#66d9ef">await</span> graphClient.Sites[site.Id].Lists.Request().GetAsync();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> list <span style="color:#66d9ef">in</span> lists)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    Console.WriteLine(<span style="color:#e6db74">$&#34;- {list.DisplayName}&#34;</span>);
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> docLib = lists.FirstOrDefault(l =&gt; l.DisplayName == <span style="color:#e6db74">&#34;Documents&#34;</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (docLib != <span style="color:#66d9ef">null</span>)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> content = System.Text.Encoding.UTF8.GetBytes(<span style="color:#e6db74">&#34;Hello from Graph SDK!&#34;</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> drive = <span style="color:#66d9ef">await</span> graphClient.Sites[site.Id].Lists[docLib.Id].Drive.Request().GetAsync();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> file = <span style="color:#66d9ef">await</span> graphClient.Drives[drive.Id].Root
</span></span><span style="display:flex;"><span>        .ItemWithPath(<span style="color:#e6db74">&#34;sample-document.txt&#34;</span>)
</span></span><span style="display:flex;"><span>        .Content
</span></span><span style="display:flex;"><span>        .Request()
</span></span><span style="display:flex;"><span>        .PutAsync&lt;DriveItem&gt;(<span style="color:#66d9ef">new</span> MemoryStream(content));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Console.WriteLine(<span style="color:#e6db74">$&#34;Uploaded file: {file.Name}&#34;</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Three differences worth naming, because they&rsquo;re the ones that break a paste:</p>
<table>
  <thead>
      <tr>
          <th></th>
          <th>v4</th>
          <th>v5 and v6</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Call shape</td>
          <td><code>.Request().GetAsync()</code></td>
          <td><code>.GetAsync()</code></td>
      </tr>
      <tr>
          <td>Collections</td>
          <td>iterate the result directly</td>
          <td>iterate <code>result.Value</code></td>
      </tr>
      <tr>
          <td>Upload path</td>
          <td><code>.Root.ItemWithPath(&quot;x&quot;)</code></td>
          <td><code>.Items[&quot;root:/x:&quot;]</code></td>
      </tr>
  </tbody>
</table>
<p><code>GraphServiceClient(credential, scopes)</code> is the same in both, which is the good news: the authentication half of this post doesn&rsquo;t change between versions. Only the calls do.</p>
<h2 id="key-differences-from-pnpframework">Key Differences from PnP.Framework</h2>
<table>
  <thead>
      <tr>
          <th>Operation</th>
          <th>PnP.Framework</th>
          <th>Graph SDK</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Get Lists</strong></td>
          <td><code>context.Web.Lists</code></td>
          <td><code>graphClient.Sites[id].Lists</code></td>
      </tr>
      <tr>
          <td><strong>Upload File</strong></td>
          <td><code>docLib.RootFolder.Files.Add()</code></td>
          <td><code>drive.Items[&quot;root:/name:&quot;].Content.PutAsync()</code></td>
      </tr>
      <tr>
          <td><strong>Authentication</strong></td>
          <td><code>AuthenticationManager</code></td>
          <td><code>ClientCertificateCredential</code></td>
      </tr>
  </tbody>
</table>
<p><strong>Graph advantages:</strong> access to Teams, OneDrive and Exchange data with the same client.
<strong>PnP advantages:</strong> more SharePoint-specific features like content types and site columns.</p>
<h2 id="troubleshooting">Troubleshooting</h2>
<ul>
<li><strong><code>The type or namespace name 'Auth' does not exist in the namespace 'Microsoft.Graph'</code></strong>: you copied a sample with <code>using Microsoft.Graph.Auth;</code>. Delete the line, add <code>Azure.Identity</code>.</li>
<li><strong><code>'OrganizationRequestBuilder' does not contain a definition for 'Request'</code></strong>: v4 code on a v5 or v6 package. Drop the <code>.Request()</code> calls.</li>
<li><strong><code>SYSLIB0057</code></strong>: the <code>X509Certificate2</code> constructor is obsolete on .NET 9 and later. Use <code>X509CertificateLoader.LoadPkcs12FromFile</code>.</li>
<li><strong>&ldquo;Insufficient privileges&rdquo;</strong>: permissions added but admin consent not granted.</li>
<li><strong>&ldquo;Certificate not found&rdquo;</strong>: check the path and password before you suspect anything cleverer.</li>
</ul>
<h2 id="next-steps">Next Steps</h2>
<p>You now have the Graph SDK working with your existing app registration, on whichever major version you&rsquo;re pinned to. To reach more of Microsoft 365, add the matching application permission:</p>
<ul>
<li><strong>User.Read.All</strong> for user data</li>
<li><strong>Group.Read.All</strong> for Teams and groups</li>
<li><strong>Files.ReadWrite.All</strong> for broader file operations</li>
<li><strong>Mail.Read</strong> for Exchange data</li>
</ul>
<p>If you&rsquo;re still on v4, the upgrade is mostly mechanical: delete every <code>.Request()</code>, add <code>.Value</code> where you iterate, and fix the upload path. The authentication code you just wrote carries over untouched.</p>
]]></content:encoded></item><item><title>Export files as zip from SharePoint</title><link>https://jeppe-spanggaard.dk/blogs/download-multiple-files-from-sharepoint/</link><pubDate>Sun, 01 Dec 2024 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/download-multiple-files-from-sharepoint/</guid><description>Learn how to download multiple SharePoint files as a zip from C# code, using Graph batching to fetch them and streaming each to disk instead of into memory.</description><content:encoded><![CDATA[<p>I have long been frustrated by the inability to download multiple files from SharePoint as a zip file via code, in the same way it’s possible through the user interface.
When this suddenly became a requirement from a customer , I had to come up with a solution 💡. Naturally, the solution needed to be robust 💪 — I could use the endpoint that SharePoint itself utilizes, though it’s not officially documented, but I decided against taking that route 🚫.</p>
<h2 id="what-was-the-solution-then-">What was the solution then? 🤔</h2>
<p>The solution came to me 🤔 while setting up the App Service Plan for my function—it struck me that I had 250GB of storage (App Service Premium Plan) available. I figured I could use this for something.</p>
<p>I was initially unsure whether I had permissions to write to this storage, but after a quick test, I confirmed that I could easily write to it—and, of course, read from it again. 🚀</p>
<p>Whether it’s the best solution to the problem, I’m not sure 🤷‍♂️, but I know it works, and I have full control over it, ensuring it won’t suddenly disappear—unlike an unofficial endpoint might. 💡</p>
<h3 id="fetch-the-files-">Fetch the files 📥</h3>
<p>When fetching the files, it’s, of course, important to minimize the number of calls to avoid throttling. The way I’ve attempted to prevent this is by using <a href="https://learn.microsoft.com/en-us/graph/json-batching">Graph batching</a>, which allows me to bundle 20 requests into one. 📦</p>
<p><strong>Example class</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">FileInfoDTO</span> {
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> FileName { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> RelativePath { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>Graph batching</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#66d9ef">internal</span> <span style="color:#66d9ef">async</span> Task DownloadFilesFromPathsToTempFolderAsync(
</span></span><span style="display:flex;"><span>    List&lt;FileInfoDTO?&gt;? fileInfo, 
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> tempFolderPath, 
</span></span><span style="display:flex;"><span>    Site siteInfo, 
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string?</span> driveId) {
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	IEnumerable&lt;FileInfoDTO?[]&gt; chucked = fileInfo!.Chunk(<span style="color:#ae81ff">20</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">foreach</span> (FileInfoDTO?[] chunk <span style="color:#66d9ef">in</span> chucked) {
</span></span><span style="display:flex;"><span>		<span style="color:#66d9ef">await</span> DownloadFilesFromPathsToTempFolderAsync(chunk, tempFolderPath, siteInfo, driveId);
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">async</span> Task DownloadFilesFromPathsToTempFolderAsync(
</span></span><span style="display:flex;"><span>    FileInfoDTO?[] fileInfos, 
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> tempFolderPath, 
</span></span><span style="display:flex;"><span>    Site siteInfo, 
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string?</span> driveId) {
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">using</span> BatchRequestContent batchRequestContent = <span style="color:#66d9ef">new</span> BatchRequestContent();
</span></span><span style="display:flex;"><span>	Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">string</span>&gt; nameMapping = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">string</span>&gt;(fileInfos.Length);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">foreach</span> (FileInfoDTO? fileInfo <span style="color:#66d9ef">in</span> fileInfos) {
</span></span><span style="display:flex;"><span>		<span style="color:#66d9ef">string</span> requestId = batchRequestContent.AddBatchRequestStep(
</span></span><span style="display:flex;"><span>			GraphClient.Sites[siteInfo.Id]
</span></span><span style="display:flex;"><span>                       .Drives[driveId]
</span></span><span style="display:flex;"><span>                       .Root
</span></span><span style="display:flex;"><span>                       .ItemWithPath(fileInfo.RelativePath)
</span></span><span style="display:flex;"><span>                       .Content.Request());
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>		nameMapping[requestId] = fileInfo.FileName!;
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	BatchResponseContent batchResponse = <span style="color:#66d9ef">await</span> GraphClient.Batch.Request().PostAsync(batchRequestContent);
</span></span><span style="display:flex;"><span>	Dictionary&lt;<span style="color:#66d9ef">string</span>, HttpResponseMessage&gt; responses = <span style="color:#66d9ef">await</span> batchResponse.GetResponsesAsync();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">foreach</span> ((<span style="color:#66d9ef">string</span> requestId, HttpResponseMessage response) <span style="color:#66d9ef">in</span> responses) {
</span></span><span style="display:flex;"><span>		<span style="color:#66d9ef">if</span> (!response.IsSuccessStatusCode &amp;&amp; response.StatusCode != HttpStatusCode.Redirect) {
</span></span><span style="display:flex;"><span>			<span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> Exception(<span style="color:#e6db74">$&#34;Error while getting file content: {response.ReasonPhrase}&#34;</span>);
</span></span><span style="display:flex;"><span>		}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>		<span style="color:#66d9ef">string</span> filePath = Path.Combine(tempFolderPath, nameMapping[requestId]);
</span></span><span style="display:flex;"><span>		<span style="color:#66d9ef">if</span> (response.StatusCode == HttpStatusCode.Redirect) {
</span></span><span style="display:flex;"><span>			<span style="color:#66d9ef">await</span> DownloadFileFromRedirectAsync(response.Headers.Location, filePath);
</span></span><span style="display:flex;"><span>		} <span style="color:#66d9ef">else</span> {
</span></span><span style="display:flex;"><span>			<span style="color:#66d9ef">await</span> WriteContentToFileAsync(response.Content, filePath);
</span></span><span style="display:flex;"><span>		}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>		response.Content.Dispose();
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>You should be aware that the StatusCode returned may not always be 200 and can still be valid — many of my requests, for example, returned with a Redirect. 🔄
As a result, I had to implement handling for that as well.</p>
<p>When I finally managed to get all my requests working to fetch the files, the next problem arose&hellip; How could I download the files and save them to my tempFolderPath without loading all the files into memory?</p>
<p>If I did, I’d quickly run out of Memory. The solution to this turned out to be the following:</p>
<p><strong>Example class</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">async</span> Task DownloadFileFromRedirectAsync(Uri? redirectUri, <span style="color:#66d9ef">string</span> filePath) {
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">using</span> HttpResponseMessage redirectResponse = 
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">await</span> GraphClient.HttpProvider.SendAsync(
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">new</span> HttpRequestMessage(HttpMethod.Get, redirectUri));
</span></span><span style="display:flex;"><span>	
</span></span><span style="display:flex;"><span>    redirectResponse.EnsureSuccessStatusCode();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">await</span> WriteContentToFileAsync(redirectResponse.Content, filePath);
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">async</span> Task WriteContentToFileAsync(HttpContent content, <span style="color:#66d9ef">string</span> filePath) {
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">await</span> <span style="color:#66d9ef">using</span> FileStream fileStream = 
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">new</span> FileStream(
</span></span><span style="display:flex;"><span>            filePath, 
</span></span><span style="display:flex;"><span>            FileMode.Create, 
</span></span><span style="display:flex;"><span>            FileAccess.Write, 
</span></span><span style="display:flex;"><span>            FileShare.None, 
</span></span><span style="display:flex;"><span>            <span style="color:#ae81ff">4096</span>, 
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">true</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">await</span> <span style="color:#66d9ef">using</span> Stream contentStream = 
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">await</span> content.ReadAsStreamAsync();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">await</span> contentStream.CopyToAsync(fileStream);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>By using using statements and ReadAsStream, each file only resides in Memory for a very short time before it is disposed. ♻️💡</p>
<p><strong>Example of a large file</strong></p>
<p><img src="https://jeppe-spanggaard.dk/images/MemoryUsages_hu_bdb7cf989460e88c.webp" srcset="/images/MemoryUsages_hu_bdb7cf989460e88c.webp 298w" sizes="(max-width: 760px) 100vw, 720px"
    width="298" height="194"
    alt="Memory Usages" loading="lazy" decoding="async"></p>
<h3 id="return-the-zip-file-">Return the ZIP file 🚀</h3>
<p>So how did I implement it all in an endpoint? I did it as follows, and it even works locally on my PC, allowing me to test it easily.
To avoid using too much Memory again, I return the ZIP file as a FileStreamResult. 🚀🗂️</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#a6e22e">[Function(&#34;DownloadFilesFromSharepoint&#34;)]</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">async</span> Task&lt;IActionResult&gt; DownloadFilesFromSharepoint(
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">		[HttpTrigger(AuthorizationLevel.Anonymous, &#34;post&#34;, Route = &#34;files/download&#34;)]</span> HttpRequest req) {
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	System.Guid guid = System.Guid.NewGuid();
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">string</span> tempFolderPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), guid.ToString());
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">string</span> zipFilePath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), <span style="color:#e6db74">$&#34;{guid}.zip&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	_logger.LogInformation(<span style="color:#e6db74">$&#34;Zip file path: {zipFilePath}&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	List&lt;FileInfoDTO?&gt;? fileInfo = <span style="color:#66d9ef">await</span> ...;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">if</span> (!System.IO.Directory.Exists(tempFolderPath)) {
</span></span><span style="display:flex;"><span>		System.IO.Directory.CreateDirectory(tempFolderPath);
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">await</span> DownloadFilesFromPathsToTempFolderAsync(fileInfo, tempFolderPath);
</span></span><span style="display:flex;"><span>	System.IO.Compression.ZipFile.CreateFromDirectory(tempFolderPath, zipFilePath);
</span></span><span style="display:flex;"><span>	System.IO.Directory.Delete(tempFolderPath, <span style="color:#66d9ef">true</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> FileStreamResult(<span style="color:#66d9ef">new</span> FileStream(zipFilePath, FileMode.Open), <span style="color:#e6db74">&#34;application/zip&#34;</span>) {
</span></span><span style="display:flex;"><span>		FileDownloadName = <span style="color:#e6db74">&#34;files.zip&#34;</span>,
</span></span><span style="display:flex;"><span>		EnableRangeProcessing = <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>	};
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>However, there’s one thing I haven’t yet found the perfect solution for—namely, deleting my ZIP files. 🗑️</p>
<p>Since I return them as a FileStreamResult and don’t load them into Memory as a byte[], I can’t delete the files immediately. Instead, I handle this with a timer job afterward, which I don’t think is the best solution. 🤷‍♂️⏳</p>
<p>But again, it solved the customer&rsquo;s problem, and they’re happy 😊, so I’m not planning to do much more about it. 🚀</p>
<h3 id="tldr">TL;DR</h3>
<p>This post explores how to programmatically download multiple files from SharePoint as a zip file using C# and Microsoft Graph API. 🚀</p>
<p><strong>Key takeaways:</strong></p>
<ul>
<li><strong>Storage Solution:</strong> Leveraged 250GB of App Service Premium storage to temporarily store files.</li>
<li><strong>Efficient API Usage:</strong> Minimized Graph API calls using batching to avoid throttling (bundling 20 requests into one).</li>
<li><strong>Memory Optimization:</strong> Used <code>ReadAsStream</code> and <code>FileStreamResult</code> to handle files efficiently without overloading RAM.</li>
<li><strong>ZIP File Handling:</strong> Created a ZIP file from the downloaded files and returned it via a streaming endpoint.</li>
<li><strong>Cleanup Challenge:</strong> Deleting ZIP files after returning them remains unresolved, currently handled via a timer job.</li>
</ul>
<p>It’s not perfect, but it works, and most importantly, it solved the customer’s problem. 😊</p>
]]></content:encoded></item></channel></rss>