<?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>SharePoint CSOM Performance on Jeppe Spanggaard - Software Developer | .NET, Azure &amp; Microsoft 365</title><link>https://jeppe-spanggaard.dk/tags/csom/</link><description>Recent content in SharePoint CSOM Performance on Jeppe Spanggaard - Software Developer | .NET, Azure &amp; Microsoft 365</description><generator>Hugo</generator><language>en-US</language><lastBuildDate>Thu, 20 Aug 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jeppe-spanggaard.dk/tags/csom/index.xml" rel="self" type="application/rss+xml"/><item><title>AllowIncrementalResults: CAML Past the 5,000 Item Wall</title><link>https://jeppe-spanggaard.dk/blogs/caml-allowincrementalresults-list-view-threshold/</link><pubDate>Thu, 20 Aug 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/caml-allowincrementalresults-list-view-threshold/</guid><description>Learn how to query SharePoint lists beyond the 5,000 item list view threshold in CSOM with AllowIncrementalResults, paging and indexed columns.</description><content:encoded><![CDATA[<p>Nothing about the query changed. The list just got bigger.</p>
<p>That&rsquo;s the list view threshold, and it&rsquo;s the reason code that works fine in dev falls over in production two years later. <code>AllowIncrementalResults</code> is the CSOM property that gets you through it.</p>
<h2 id="why-a-ten-row-query-fails-on-an-8000-item-list">Why a Ten-Row Query Fails on an 8,000 Item List</h2>
<p>The error looks like this:</p>
<pre tabindex="0"><code>Microsoft.SharePoint.Client.ServerException: The attempted operation is
prohibited because it exceeds the list view threshold.
</code></pre><p>CSOM hands you that as a <code>ServerException</code>, but the thing throwing it server-side is <code>SPQueryThrottledException</code>. Worth knowing both names: the message is what you get in the debugger, the class name is what half the search results are filed under. SharePoint REST hits the same wall for the same reason, though not with the same fix: <code>AllowIncrementalResults</code> is a <code>CamlQuery</code> property and there is no REST equivalent of it.</p>
<p>What throws people is that your filter has nothing to do with it. You can ask for ten rows out of 8,000 and still get the exception, because SharePoint is protecting itself against the scan, not against the result set.</p>
<h2 id="set-the-flag-then-page">Set the Flag, Then Page</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-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>    ViewXml = viewXml,               <span style="color:#75715e">// &lt;RowLimit&gt;5000&lt;/RowLimit&gt;, indexed field in &lt;Where&gt;</span>
</span></span><span style="display:flex;"><span>    AllowIncrementalResults = <span style="color:#66d9ef">true</span>,  <span style="color:#75715e">// the part that gets you past the threshold</span>
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>ListItemCollection items;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">do</span> {
</span></span><span style="display:flex;"><span>    items = list.GetItems(query);
</span></span><span style="display:flex;"><span>    context.Load(items);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> context.ExecuteQueryAsync();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Process(items);
</span></span><span style="display:flex;"><span>    query.ListItemCollectionPosition = items.ListItemCollectionPosition;
</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><code>AllowIncrementalResults = true</code> tells SharePoint it&rsquo;s allowed to hand back what it has, in chunks, instead of refusing the whole query. Without it, a big list plus a filter is a <code>ServerException</code> and nothing else.</li>
<li><code>&lt;RowLimit&gt;</code> in the view XML decides the chunk size. It&rsquo;s the same knob you&rsquo;d use for ordinary paging, and it has to be there - an unbounded query is exactly what the threshold exists to stop. I use 5000.</li>
<li><code>ListItemCollectionPosition</code> is the cursor. Feed it back into the next query, stop when the collection comes back with a null position. That&rsquo;s the last page.</li>
<li>Every iteration is one round trip, so the chunk size is a real trade-off: bigger pages mean fewer trips and heavier payloads. I count round trips out of habit these days, for <a href="https://jeppe-spanggaard.dk/blogs/sharepoint-csom-performance-playbook/">reasons I&rsquo;ve written about at length</a>.</li>
</ol>
<h2 id="indexed-columns-are-not-optional">Indexed Columns Are Not Optional</h2>
<p>This is the part that turns &ldquo;it doesn&rsquo;t work&rdquo; into an afternoon. The flag isn&rsquo;t a bypass. The fields you filter and sort on still have to be indexed on the list, or you&rsquo;re back to the same exception with a more confusing story, because now you <em>did</em> set the flag.</p>
<p>One exception, and it&rsquo;s a useful one: a field you&rsquo;ve projected across a <code>&lt;Joins&gt;</code> isn&rsquo;t a column on the list at all, so it can&rsquo;t be indexed, and the flag carries it anyway. I only found that out by <a href="https://jeppe-spanggaard.dk/blogs/list-view-threshold-what-still-works/">measuring it on a 10,000 item list</a>, where a join filtered on a projected field is refused flat without the flag and returns every matching row with it. So the rule is: real columns need an index, projections need the flag.</p>
<p>Add the index in list settings, and remember SharePoint allows up to 20 per list. And if your first thought is &ldquo;the list is already too big to index&rdquo; - that&rsquo;s the reflex I had too, and it&rsquo;s wrong. Microsoft&rsquo;s <a href="https://support.microsoft.com/en-us/sharepoint/data-and-lists/add-an-index-to-a-list-or-library-column">add an index to a list or library column</a> walks through it and says you can index a list of any size manually. If it does get blocked, do it inside the tenant&rsquo;s daily time window.</p>
<h2 id="gotchas">Gotchas</h2>
<ul>
<li><strong>The flag doesn&rsquo;t raise the threshold.</strong> It changes the failure into incremental delivery. Unindexed <code>&lt;Where&gt;</code> and <code>&lt;OrderBy&gt;</code> columns still fail, with the projected-field exception above.</li>
<li><strong>No <code>&lt;RowLimit&gt;</code>, no paging.</strong> Without it you&rsquo;re asking for everything again, and the position never comes back. If you&rsquo;re here because <code>RowLimit</code> &ldquo;isn&rsquo;t working&rdquo;, check where you put it: it belongs in the view XML outside <code>&lt;Query&gt;</code>, and on its own it caps one response rather than giving you the rest of the rows. Paging is what gives you the rest.</li>
<li><strong>You cannot parallelise the paging.</strong> Each page&rsquo;s cursor comes out of the previous response, so the loop is strictly sequential no matter how many threads you throw at it. Chunk size is your only real lever on total time.</li>
<li><strong>Calculated columns can&rsquo;t be indexed.</strong> Neither can a few other types. Discovering that late is how a filter design dies, because the column your whole query hangs on turns out to be the one column SharePoint won&rsquo;t index - and no amount of <code>AllowIncrementalResults</code> saves it.</li>
<li><strong><code>ListItemCollectionPosition</code> belongs to the query, not the list.</strong> Set it on the <code>CamlQuery</code> you&rsquo;re about to execute. Reusing a stale position from an earlier query shape gives you nonsense.</li>
<li><strong>The ceiling moves per tenant and per time of day.</strong> Large list throttling is a service-side feature, so &ldquo;it worked yesterday&rdquo; is not evidence that it will work in the batch job tonight.</li>
<li><strong>The other APIs hit the same wall, and only one of them is polite about it.</strong> I used to say I&rsquo;d never got this to work outside CSOM, so I <a href="https://jeppe-spanggaard.dk/blogs/list-view-threshold-what-still-works/">went and measured all three</a>. REST answers an unindexed filter with a bare <code>500</code>. Graph answers <code>400</code> and names the header that would let it through. Neither has an equivalent of this flag, so filtering a big list still ends up back in CSOM, which is one more entry on the <a href="https://jeppe-spanggaard.dk/blogs/csom-vs-sharepoint-rest-vs-graph/">pick-one list</a>.</li>
<li><strong>Folders change the picture.</strong> Scoping a query to a folder cuts the item count SharePoint has to consider, which sometimes solves the problem without any of this.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>If a CAML query is throwing the list view threshold exception, the fix is three things together: <code>AllowIncrementalResults = true</code>, a <code>&lt;RowLimit&gt;</code> to page with, and indexes on the columns you filter and sort on. Two out of three still throws.</p>
<p>It&rsquo;s also one of the reasons list item work stays in CSOM for me. <a href="https://jeppe-spanggaard.dk/blogs/csom-vs-sharepoint-rest-vs-graph/">Picking the right SharePoint API per operation</a> means knowing which one has an answer for the awkward cases, and large lists are as awkward as it gets.</p>
<p>The wider version of that argument is in the two benchmark posts: <a href="https://jeppe-spanggaard.dk/blogs/caml-joins-across-lists/">what a CAML join costs at 1,000 items</a>, and <a href="https://jeppe-spanggaard.dk/blogs/list-view-threshold-what-still-works/">what survives at 10,000</a>. The short version is that past the threshold this stops being a speed question. It becomes a question of whether you get the right answer at all, and of how much of the list you have to hold in memory to find it.</p>
]]></content:encoded></item><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>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>The SharePoint CSOM Performance Playbook: Stop Paying for Wasted API Calls</title><link>https://jeppe-spanggaard.dk/blogs/sharepoint-csom-performance-playbook/</link><pubDate>Wed, 20 May 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/sharepoint-csom-performance-playbook/</guid><description>Learn how to speed up SharePoint CSOM with five proven techniques - batching, CAML joins, change detection, server-side exception handling, and fast taxonomy loading.</description><content:encoded><![CDATA[<p>I&rsquo;ve spent years writing CSOM code, and I keep seeing the same performance sins in every codebase I review. Including my own older code, which is the humbling part.</p>
<p>For a long time, slow CSOM was just annoying. Users waited, someone made coffee, life went on. Then I sat down with a client to calculate the cost of <a href="https://learn.microsoft.com/en-us/sharepoint/sharepoint-prioritization">Service Prioritization in SharePoint</a>, where every CSOM call has an actual price tag: USD $1.00 per 1,000 calls. Suddenly all those wasted calls weren&rsquo;t just slow. They were an invoice.</p>
<p>That changed how I write CSOM. I count round trips now, the way you&rsquo;d count database queries in a hot loop. Over the past year I&rsquo;ve written several posts about the specific techniques that came out of that habit, and this post ties them together into one playbook.</p>
<h2 id="every-executequery-is-a-road-trip">Every ExecuteQuery Is a Road Trip</h2>
<p>Here&rsquo;s the mental model that makes all four techniques click: every <code>ExecuteQueryAsync()</code> is a road trip to the SharePoint server. Doesn&rsquo;t matter if you&rsquo;re delivering one package or a hundred, the drive takes the same time. Network latency, authentication, server processing - the overhead is per trip, not per operation.</p>
<p>So the whole playbook boils down to four questions:</p>
<ol>
<li>Can I deliver more packages per trip? (batching)</li>
<li>Can one trip cover several destinations? (CAML joins)</li>
<li>Is this trip even necessary? (change detection)</li>
<li>Can I avoid a second trip when something goes wrong? (ExceptionHandlingScope, and the taxonomy trick)</li>
</ol>
<p>Let&rsquo;s take them one at a time.</p>
<h2 id="rule-1-batch-or-suffer">Rule 1: Batch or Suffer</h2>
<p>The most common sin. A loop that loads items one by one:</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">// ❌ 50 items = 50 round trips = 3-5 seconds</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> id <span style="color:#66d9ef">in</span> itemIds)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> item = list.GetItemById(id);
</span></span><span style="display:flex;"><span>    context.Load(item);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> context.ExecuteQueryAsync();
</span></span><span style="display:flex;"><span>    ProcessItem(item);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>CSOM happily queues up operations until you call <code>ExecuteQueryAsync()</code>. Around 100 operations per batch is the reliable sweet spot:</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">// ✅ 50 items = 1 round trip = ~200-500ms</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> items = chunk.Select(id =&gt; {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> item = list.GetItemById(id);
</span></span><span style="display:flex;"><span>    list.Context.Load(item);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> item;
</span></span><span style="display:flex;"><span>}).ToList();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">await</span> context.ExecuteQueryAsync();
</span></span></code></pre></div><p>In my testing, that&rsquo;s a 10x+ improvement for the price of restructuring a loop. It works for reads, writes, deletes, all of it.</p>
<p>Full post with the reusable <code>ProcessInChunks</code> helper: <a href="https://jeppe-spanggaard.dk/blogs/csom-performance-optimization-chunking/">Batch Your CSOM Operations Instead of Looping One by One</a>.</p>
<h2 id="rule-2-join-lists-dont-query-them-one-by-one">Rule 2: Join Lists, Don&rsquo;t Query Them One by One</h2>
<p>Related data across multiple lists is where round trips multiply quietly. Customers in one list, orders in another, order items in a third, so you write three queries and merge the results in C#. Every multi-item query costs <a href="https://learn.microsoft.com/en-us/sharepoint/dev/general-development/how-to-avoid-getting-throttled-or-blocked-in-sharepoint-online">2 resource units</a> toward your throttling budget, so three lists cost 6 units plus three round trips plus the merging code nobody wants to maintain.</p>
<p>CAML supports joins, even though the SharePoint UI never hints at it. One query, one trip, 2 resource units, and the server does the merging. I use <a href="https://github.com/sadomovalex/camlex">CAMLEX</a> instead of raw CAML XML because lambda expressions are readable and the XML it generates is not:</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 = Camlex.Query()
</span></span><span style="display:flex;"><span>    .Where(x =&gt; (<span style="color:#66d9ef">string</span>)x[<span style="color:#e6db74">&#34;CustomerName&#34;</span>] == <span style="color:#e6db74">&#34;Contoso&#34;</span>) <span style="color:#75715e">// filter on a field 2 lists away!</span>
</span></span><span style="display:flex;"><span>    .LeftJoin(x =&gt; x[<span style="color:#e6db74">&#34;OrderTaskLookUp&#34;</span>].ForeignList(ListGuidOrderTasks))
</span></span><span style="display:flex;"><span>    .LeftJoin(x =&gt; x[<span style="color:#e6db74">&#34;OrderDetailLookUp&#34;</span>].PrimaryList(ListGuidOrderTasks).ForeignList(ListGuidOrders))
</span></span><span style="display:flex;"><span>    .LeftJoin(x =&gt; x[<span style="color:#e6db74">&#34;CustomerLookUp&#34;</span>].PrimaryList(ListGuidOrders).ForeignList(ListGuidCustomers))
</span></span><span style="display:flex;"><span>    .ProjectedField(x =&gt; x[<span style="color:#e6db74">&#34;CustomerName&#34;</span>].List(ListGuidCustomers).ShowField(<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> camlQuery = <span style="color:#66d9ef">new</span> CamlQuery { ViewXml = query.ToString() };
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> items = ordersList.GetItems(camlQuery);
</span></span></code></pre></div><p>That&rsquo;s 66% fewer resource units than the three-query version, and you can even filter on a value several lists away. The lists must be connected via lookup columns, and ProjectedFields only supports simple field types (text, number, date - not user, choice, or managed metadata fields).</p>
<p>Full post with the raw CAML comparison and the field type list: <a href="https://jeppe-spanggaard.dk/blogs/joining-multiple-lists-csom-caml/">Efficient Multi-List Queries in CSOM: Using CAML Joins with CAMLEX</a>.</p>
<h2 id="rule-3-did-anything-actually-change">Rule 3: Did Anything Actually Change?</h2>
<p>Users are save-happy. They open a form, change nothing, and click save anyway. Automated syncs are worse. When I dug into a typical day of API logs on one project, <strong>roughly 70% of our SharePoint update calls weren&rsquo;t changing anything</strong>. SharePoint accepts identical data with a smile and bills you for the privilege.</p>
<p>The fix is change detection before the update:</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> differences = GetDifferences(listItem, newValues, treatEmptyStringAsNull: <span style="color:#66d9ef">true</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (differences.Any())
</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> (fieldName, newValue) <span style="color:#66d9ef">in</span> differences)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        listItem[fieldName] = newValue;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    listItem.Update();
</span></span><span style="display:flex;"><span>    clientContext.ExecuteQuery();
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Nothing changed? Do absolutely nothing.</span>
</span></span></code></pre></div><p>The hard part is the comparison itself. SharePoint field types fight you: user fields where only <code>LookupId</code> matters, multi-choice fields that come back in random order, taxonomy values, dates with kind mismatches. Naive <code>oldValue == newValue</code> doesn&rsquo;t survive contact with any of them, and JSON serialization comparison turned out 3-5x slower than a proper normalizing comparer when I benchmarked both.</p>
<p>Full post with the complete comparison engine: <a href="https://jeppe-spanggaard.dk/blogs/sharepoint-csom-prevent-unnecessary-updates/">SharePoint CSOM: Prevent Unnecessary Updates and API Calls</a>.</p>
<h2 id="rule-4-let-the-server-do-the-catching">Rule 4: Let the Server Do the Catching</h2>
<p>This one came straight out of that Service Prioritization cost analysis. The client&rsquo;s solution made about 90,000 CSOM calls per month, and <strong>88% of them were <code>EnsureUser</code> calls for users who were already on the site</strong>. The classic defensive pattern - always ensure the user before setting a user field - was two round trips per assignment, and the first one was almost always pointless.</p>
<p><code>ExceptionHandlingScope</code> lets you ship try-catch logic to the server and resolve it in one round trip:</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> scope = <span style="color:#66d9ef">new</span> ExceptionHandlingScope(clientContext);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">using</span> (scope.StartScope())
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">using</span> (scope.StartTry())
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Optimistic: works for users already known to the site</span>
</span></span><span style="display:flex;"><span>        listItem[<span style="color:#e6db74">&#34;AssignedTo&#34;</span>] = FieldUserValue.FromUser(<span style="color:#e6db74">&#34;user@company.com&#34;</span>);
</span></span><span style="display:flex;"><span>        listItem.Update();
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">using</span> (scope.StartCatch())
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Fallback: only runs server-side if the try failed</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> user = clientContext.Web.EnsureUser(<span style="color:#e6db74">&#34;user@company.com&#34;</span>);
</span></span><span style="display:flex;"><span>        listItem[<span style="color:#e6db74">&#34;AssignedTo&#34;</span>] = FieldUserValue.FromUser(<span style="color:#e6db74">&#34;user@company.com&#34;</span>);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>clientContext.ExecuteQuery(); <span style="color:#75715e">// one trip</span>
</span></span></code></pre></div><p>Result for that client: a 75-85% reduction in monthly calls. One honest caveat, and it&rsquo;s important: the scope reduces <em>round trips</em>, not <em>server requests</em>. Each operation inside still counts toward throttling. The call reduction came from the optimistic-first logic, and ExceptionHandlingScope is what made that logic affordable.</p>
<p>Full post with the cost breakdown: <a href="https://jeppe-spanggaard.dk/blogs/sharepoint-exceptionhandlingscope-csom-ensureuser/">SharePoint&rsquo;s Server-Side Try-Catch: ExceptionHandlingScope</a>.</p>
<h2 id="rule-5-keep-taxonomy-out-of-your-viewfields">Rule 5: Keep Taxonomy Out of Your ViewFields</h2>
<p>Taxonomy fields are the slowest thing you can put in a CAML query. On a list where items carried 5 to 50 terms across multiple managed metadata fields, loading a few hundred items took 90-120 seconds. Users literally walked away from their computers.</p>
<p>The fix has two parts. First, query the list <em>without</em> the taxonomy fields in ViewFields, which is where most of the cost hides. Then load the taxonomy data separately through <code>FieldValuesForEdit</code>, where SharePoint stores it as a raw string you can parse directly:</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">// Raw format in FieldValuesForEdit: &#34;Label1|GUID1;Label2|GUID2&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> (ListItem[] chunk <span style="color:#66d9ef">in</span> items.Cast&lt;ListItem&gt;().Chunk(<span style="color:#ae81ff">100</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> item <span style="color:#66d9ef">in</span> chunk)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        clientContext.Load(item, i =&gt; i.FieldValuesForEdit);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> clientContext.ExecuteQueryAsync();
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// parse Label|GUID pairs per item</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>That took the same load from 90-120 seconds down to 25-35 seconds, a 70-75% improvement. Fair warning: this parses an internal, undocumented format. It&rsquo;s been reliable for me, but test it in your environment and keep the traditional approach as a fallback.</p>
<p>Full post with the parser: <a href="https://jeppe-spanggaard.dk/blogs/csom-taxonomy-performance-optimization/">Load SharePoint Taxonomy Fields Fast With FieldValuesForEdit</a>.</p>
<h2 id="which-one-first">Which One First?</h2>
<p>If your workload is write-heavy (forms, syncs, integrations), start with change detection. It&rsquo;s the only technique that eliminates entire operations instead of making them cheaper, and it&rsquo;s invisible to users.</p>
<p>If your workload is read-heavy, start with batching. It&rsquo;s the smallest code change for the biggest win, and the chunking helper is reusable everywhere. And if those reads span related lists, add joins next: they cut resource units, not just latency, which stretches your throttling budget further.</p>
<p>ExceptionHandlingScope is for when a specific fallback pattern (like <code>EnsureUser</code>) dominates your call logs. Check your logs first - I only found the 88% figure because I went looking. And the taxonomy trick is a targeted weapon: only reach for it when managed metadata is measurably your bottleneck, because it carries the undocumented-format risk.</p>
<p>The good news is they stack. Batched updates that skip unchanged items, with server-side fallbacks, is exactly how my current projects run.</p>
<h2 id="gotchas">Gotchas</h2>
<ul>
<li><strong>100 operations per batch is the sweet spot.</strong> CSOM reliably handles around that many. Bigger batches mean bigger payloads and less predictable behavior across environments. Ask me how I know.</li>
<li><strong>ExceptionHandlingScope doesn&rsquo;t reduce throttling pressure by itself.</strong> Round trips go down, but every operation inside the scope still counts as a request. The savings come from the optimistic-first logic it enables.</li>
<li><strong>Your numbers will differ from mine.</strong> The 70%, 75-85%, and 70-75% figures are from real projects, but latency, item complexity, and tenant load all move them. Measure before and after in your own environment.</li>
<li><strong>Test under throttling before you trust any of it.</strong> <a href="https://jeppe-spanggaard.dk/blogs/devproxy-throttling-testing/">Dev Proxy</a> can simulate 429 responses locally so you find out how your batches behave under pressure before production does.</li>
<li><strong>The taxonomy trick gives you label and GUID, nothing more.</strong> If you need full term paths or custom properties, you&rsquo;re back to the taxonomy API for those items.</li>
<li><strong>Joins need lookup columns and GUIDs.</strong> CAML joins only work across lists connected by lookup columns, and referencing lists by GUID instead of title saves you from &ldquo;list does not exist&rdquo; surprises.</li>
</ul>
<p>If you&rsquo;re also downloading files in the same solution, the round-trip mindset applies there too - I&rsquo;ve covered that in <a href="https://jeppe-spanggaard.dk/blogs/download-multiple-files-from-sharepoint/">downloading multiple files from SharePoint</a>.</p>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>Count your round trips before SharePoint counts them for you. That&rsquo;s the whole playbook in one sentence: fewer trips (batching), combined trips (CAML joins), no pointless trips (change detection), no second trips (ExceptionHandlingScope), and lighter trips (taxonomy loading).</p>
<p>Each technique stands on its own, so grab the one that matches your bottleneck:</p>
<ul>
<li><a href="https://jeppe-spanggaard.dk/blogs/csom-performance-optimization-chunking/">Batch your SharePoint operations</a></li>
<li><a href="https://jeppe-spanggaard.dk/blogs/joining-multiple-lists-csom-caml/">Join multiple lists in one CAML query</a></li>
<li><a href="https://jeppe-spanggaard.dk/blogs/sharepoint-csom-prevent-unnecessary-updates/">Prevent unnecessary updates</a></li>
<li><a href="https://jeppe-spanggaard.dk/blogs/sharepoint-exceptionhandlingscope-csom-ensureuser/">Server-side try-catch with ExceptionHandlingScope</a></li>
<li><a href="https://jeppe-spanggaard.dk/blogs/csom-taxonomy-performance-optimization/">Fast taxonomy loading</a></li>
</ul>
]]></content:encoded></item><item><title>Load SharePoint Taxonomy Fields Fast With FieldValuesForEdit</title><link>https://jeppe-spanggaard.dk/blogs/csom-taxonomy-performance-optimization/</link><pubDate>Sun, 21 Dec 2025 00:00:00 +0000</pubDate><author>Jeppe Spanggaard</author><guid>https://jeppe-spanggaard.dk/blogs/csom-taxonomy-performance-optimization/</guid><description>An undocumented but effective approach to dramatically speed up CSOM taxonomy field loading when dealing with SharePoint list items that have many terms.</description><content:encoded><![CDATA[<h2 id="when-fast-wasnt-fast-enough">When Fast Wasn&rsquo;t Fast Enough</h2>
<p>I was working on a SharePoint solution where we had a list with items containing multiple taxonomy fields. Each item could have anywhere from 5 to 50 taxonomy terms across different fields. The client needed to load hundreds of these items regularly.</p>
<p>The traditional CSOM approach was&hellip; well, let&rsquo;s just say coffee breaks became very popular during load times.</p>
<p><strong>The loading process was taking 90-120 seconds for a few hundred items.</strong> Users were literally walking away from their computers while waiting for data to load.</p>
<p>That&rsquo;s when I realized we needed to completely rethink how we approach taxonomy loading in CSOM.</p>
<h2 id="the-traditional-slow-way">The Traditional (Slow) Way</h2>
<p>Here&rsquo;s what most developers do, and what I was doing initially:</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">// The traditional approach - including taxonomy fields in ViewFields</span>
</span></span><span style="display:flex;"><span>CamlQuery query = <span style="color:#66d9ef">new</span> CamlQuery();
</span></span><span style="display:flex;"><span>query.Query = <span style="color:#e6db74">@&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &lt;View&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        &lt;ViewFields&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            &lt;FieldRef Name=&#39;ID&#39;/&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            &lt;FieldRef Name=&#39;Title&#39;/&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            &lt;FieldRef Name=&#39;CategoryField&#39;/&gt;     &lt;!-- Taxonomy field --&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            &lt;FieldRef Name=&#39;TagField&#39;/&gt;          &lt;!-- Taxonomy field --&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            &lt;FieldRef Name=&#39;OtherTaxField&#39;/&gt;     &lt;!-- Taxonomy field --&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        &lt;/ViewFields&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &lt;/View&gt;&#34;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>ListItemCollection items = list.GetItems(query);
</span></span><span style="display:flex;"><span>clientContext.Load(items);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">await</span> clientContext.ExecuteQueryAsync();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Then access taxonomy fields normally</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> (ListItem item <span style="color:#66d9ef">in</span> items)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> categoryField = item[<span style="color:#e6db74">&#34;CategoryField&#34;</span>] <span style="color:#66d9ef">as</span> TaxonomyFieldValueCollection;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> tagField = item[<span style="color:#e6db74">&#34;TagField&#34;</span>] <span style="color:#66d9ef">as</span> TaxonomyFieldValueCollection;
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Process taxonomy values...</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>Problems with this approach:</strong></p>
<ul>
<li><strong>Including taxonomy fields in ViewFields is extremely slow</strong></li>
<li>SharePoint has to load and process all taxonomy data upfront</li>
<li>Multiple expensive taxonomy service calls during the initial load</li>
<li>No control over how taxonomy data is loaded</li>
</ul>
<p>Result: Coffee break time. Lots of it.</p>
<h2 id="the-optimization-journey">The Optimization Journey</h2>
<p>After digging into SharePoint&rsquo;s internals and some experimentation, I discovered two key things:</p>
<ol>
<li>
<p><strong>Including taxonomy fields in ViewFields is what kills performance.</strong> SharePoint has to load and process all taxonomy data during the initial query, which is extremely slow.</p>
</li>
<li>
<p><strong>SharePoint stores taxonomy data in a raw format</strong> that can be accessed via <code>FieldValuesForEdit</code> and parsed directly. This bypasses the expensive taxonomy service calls entirely.</p>
</li>
</ol>
<p><strong>The breakthrough:</strong> Separate regular field loading from taxonomy field loading. Load items with only the fields you need immediately, then load taxonomy fields separately using the faster <code>FieldValuesForEdit</code> approach.</p>
<h3 id="key-insights">Key Insights:</h3>
<ol>
<li><strong>Exclude taxonomy fields from ViewFields</strong> - This is the biggest performance gain. Load regular fields first, taxonomy fields separately.</li>
<li><strong><code>FieldValuesForEdit</code> contains raw taxonomy data</strong> in the format: <code>Label|GUID;Label|GUID</code> - much faster to parse than TaxonomyFieldValue API</li>
<li><strong>Batch loading is crucial</strong> - load all items&rsquo; FieldValuesForEdit in one call per chunk</li>
<li><strong>Chunking prevents timeouts</strong> - process items in manageable batches</li>
<li><strong>Dictionary lookups are fast</strong> - map terms back to items efficiently</li>
</ol>
<h2 id="the-optimized-solution">The Optimized Solution</h2>
<p>Here&rsquo;s the approach that cut our load times by 70-75%:</p>
<h3 id="main-loading-logic">Main Loading Logic</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">public</span> <span style="color:#66d9ef">async</span> Task&lt;List&lt;MyItemDTO?&gt;?&gt; GetItemsByOptions(<span style="color:#66d9ef">string</span>[] options)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Step 1: Load list items WITHOUT taxonomy fields in ViewFields</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// This is much faster than including taxonomy fields in the initial query</span>
</span></span><span style="display:flex;"><span>    List list = clientContext.Web.Lists.GetByTitle(<span style="color:#e6db74">&#34;YourListName&#34;</span>);
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    CamlQuery query = <span style="color:#66d9ef">new</span> CamlQuery();
</span></span><span style="display:flex;"><span>    query.Query = <span style="color:#e6db74">@&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        &lt;View&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            &lt;ViewFields&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">                &lt;FieldRef Name=&#39;ID&#39;/&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">                &lt;FieldRef Name=&#39;Title&#39;/&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">                &lt;FieldRef Name=&#39;OtherRegularFields&#39;/&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">                &lt;!-- Notice: NO taxonomy fields here --&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            &lt;/ViewFields&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            &lt;Query&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">                &lt;!-- Your where clause here --&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            &lt;/Query&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        &lt;/View&gt;&#34;</span>;
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    ListItemCollection items = list.GetItems(query);
</span></span><span style="display:flex;"><span>    clientContext.Load(items);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> clientContext.ExecuteQueryAsync();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (items == <span style="color:#66d9ef">null</span> || items.Count == <span style="color:#ae81ff">0</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Step 2: Process in chunks and load taxonomy fields separately</span>
</span></span><span style="display:flex;"><span>    List&lt;MyItemDTO&gt; result = <span style="color:#66d9ef">new</span> List&lt;MyItemDTO&gt;();
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> (ListItem[] chunk <span style="color:#66d9ef">in</span> items.Cast&lt;ListItem&gt;().Chunk(<span style="color:#ae81ff">100</span>))
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Load taxonomy fields separately using FieldValuesForEdit</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> categoryTerms = <span style="color:#66d9ef">await</span> LoadTaxonomyField(chunk, <span style="color:#e6db74">&#34;CategoryField&#34;</span>);
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> tagTerms = <span style="color:#66d9ef">await</span> LoadTaxonomyField(chunk, <span style="color:#e6db74">&#34;TagField&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Step 3: Map everything together</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> item <span style="color:#66d9ef">in</span> chunk)
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> dto = <span style="color:#66d9ef">new</span> MyItemDTO
</span></span><span style="display:flex;"><span>            {
</span></span><span style="display:flex;"><span>                Id = item.Id,
</span></span><span style="display:flex;"><span>                Title = item[<span style="color:#e6db74">&#34;Title&#34;</span>]?.ToString(),
</span></span><span style="display:flex;"><span>                <span style="color:#75715e">// Map regular fields normally</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">// Add taxonomy terms from our separate loading</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> (categoryTerms.TryGetValue(item.Id.ToString(), <span style="color:#66d9ef">out</span> <span style="color:#66d9ef">var</span> terms))
</span></span><span style="display:flex;"><span>                dto.CategoryTerms = terms;
</span></span><span style="display:flex;"><span>                
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> (tagTerms.TryGetValue(item.Id.ToString(), <span style="color:#66d9ef">out</span> <span style="color:#66d9ef">var</span> tags))
</span></span><span style="display:flex;"><span>                dto.TagTerms = tags;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            result.Add(dto);
</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> result;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="batch-taxonomy-loading">Batch Taxonomy Loading</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">private</span> <span style="color:#66d9ef">async</span> Task&lt;Dictionary&lt;<span style="color:#66d9ef">string</span>, List&lt;TaxonomyTerm&gt;&gt;&gt; LoadTaxonomyField(
</span></span><span style="display:flex;"><span>    IEnumerable&lt;ListItem&gt; items, 
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> taxonomyFieldInternalName)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Load FieldValuesForEdit for all items in one batch</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> item <span style="color:#66d9ef">in</span> items)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        clientContext.Load(item, i =&gt; i.FieldValuesForEdit);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> clientContext.ExecuteQueryRetryAsync();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Get the field ID for internal format matching</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> list = clientContext.Web.Lists.GetById(listGuid);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> field = list.Fields.GetFieldByInternalName(taxonomyFieldInternalName);
</span></span><span style="display:flex;"><span>    clientContext.Load(field);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> clientContext.ExecuteQueryRetryAsync();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> fieldId = field.Id.ToString();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Parse taxonomy data from each item&#39;s FieldValuesForEdit</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> items.ToDictionary(
</span></span><span style="display:flex;"><span>        item =&gt; item.Id.ToString(),
</span></span><span style="display:flex;"><span>        item =&gt; ParseTaxonomyFromEditValues(item, fieldId)
</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> List&lt;TaxonomyTerm&gt; ParseTaxonomyFromEditValues(ListItem item, <span style="color:#66d9ef">string</span> fieldId)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Find the correct field key (SharePoint uses shortened GUIDs)</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> fieldValues = item.FieldValuesForEdit.FieldValues
</span></span><span style="display:flex;"><span>        .Where(x =&gt; x.Key.EndsWith(fieldId.Replace(<span style="color:#e6db74">&#34;-&#34;</span>, <span style="color:#e6db74">&#34;&#34;</span>).Substring(<span style="color:#ae81ff">4</span>)));
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (!fieldValues.Any())
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> List&lt;TaxonomyTerm&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> actualFieldKey = fieldValues.First().Key;
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (!item.FieldValuesForEdit.FieldValues.ContainsKey(actualFieldKey))
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> List&lt;TaxonomyTerm&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Parse the raw taxonomy string</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> rawTaxonomyData = item.FieldValuesForEdit[actualFieldKey];
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> ParseTaxonomyEditString(rawTaxonomyData);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="raw-format-parser">Raw Format Parser</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">private</span> List&lt;TaxonomyTerm&gt; ParseTaxonomyEditString(<span style="color:#66d9ef">string</span> editString)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#66d9ef">string</span>.IsNullOrWhiteSpace(editString))
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> List&lt;TaxonomyTerm&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Format: &#34;Label1|GUID1;Label2|GUID2;Label3|GUID3&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> editString
</span></span><span style="display:flex;"><span>        .Split(<span style="color:#66d9ef">new</span>[] { <span style="color:#e6db74">&#39;;&#39;</span> }, StringSplitOptions.RemoveEmptyEntries)
</span></span><span style="display:flex;"><span>        .Select(entry =&gt; 
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> parts = entry.Split(<span style="color:#e6db74">&#39;|&#39;</span>);
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> (parts.Length == <span style="color:#ae81ff">2</span> &amp;&amp; Guid.TryParse(parts[<span style="color:#ae81ff">1</span>], <span style="color:#66d9ef">out</span> Guid guid))
</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> TaxonomyTerm 
</span></span><span style="display:flex;"><span>                { 
</span></span><span style="display:flex;"><span>                    Label = parts[<span style="color:#ae81ff">0</span>].Trim(), 
</span></span><span style="display:flex;"><span>                    TermGuid = guid 
</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> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>        })
</span></span><span style="display:flex;"><span>        .Where(t =&gt; t != <span style="color:#66d9ef">null</span>)
</span></span><span style="display:flex;"><span>        .Select(t =&gt; t!)
</span></span><span style="display:flex;"><span>        .ToList();
</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">TaxonomyTerm</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> Label { <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> Guid TermGuid { <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="performance-impact">Performance Impact</h2>
<p>The results were dramatic:</p>
<p><strong>Before optimization:</strong></p>
<ul>
<li>90-120 seconds for a few hundred items with multiple taxonomy fields</li>
<li>Multiple <code>ExecuteQueryRetryAsync()</code> calls per item</li>
<li>Heavy taxonomy service usage</li>
</ul>
<p><strong>After optimization:</strong></p>
<ul>
<li>25-35 seconds for the same dataset</li>
<li><strong>~70-75% performance improvement</strong></li>
<li>Minimal API calls (2 per chunk: one for items, one for field metadata)</li>
</ul>
<p>The exact savings depend on the number of items and terms, but the pattern held consistently across different datasets.</p>
<h2 id="should-you-try-this-approach">Should You Try This Approach?</h2>
<p><strong>If you&rsquo;re experiencing slow loading times with taxonomy fields, this might be worth testing.</strong></p>
<p>I can&rsquo;t give you specific numbers for when this optimization makes sense - every environment and scenario is different. What I can tell you is that in my case, it made a dramatic difference.</p>
<p><strong>My recommendation:</strong></p>
<ul>
<li>If your current taxonomy loading is painfully slow, try this approach in a test environment</li>
<li>Measure the before and after performance in your specific scenario</li>
<li>Keep the complexity vs. benefit trade-off in mind</li>
<li>Always have a fallback to the traditional approach</li>
</ul>
<p><strong>Remember the limitations:</strong></p>
<ul>
<li>This is an undocumented approach that works with SharePoint&rsquo;s internal formats</li>
<li>You only get basic term information (label + GUID)</li>
<li>You&rsquo;ll need to test thoroughly in your environment</li>
</ul>
<p>The traditional CSOM approach works fine for many scenarios. But if you&rsquo;re hitting performance walls with taxonomy fields, this optimization might be exactly what you need.</p>
<h2 id="important-disclaimers">Important Disclaimers</h2>
<p>⚠️ <strong>This is an undocumented approach</strong> that relies on SharePoint&rsquo;s internal storage format for taxonomy fields. While it has worked reliably in my experience, it&rsquo;s not officially supported by Microsoft.</p>
<h2 id="key-takeaways">Key Takeaways</h2>
<ol>
<li><strong>Batch operations are crucial</strong> - Load multiple items&rsquo; data in single API calls</li>
<li><strong>Chunking prevents timeouts</strong> - Process large datasets in manageable pieces</li>
<li><strong>Raw formats can be faster</strong> - Sometimes bypassing official APIs improves performance</li>
<li><strong>Know when to optimize</strong> - Don&rsquo;t add complexity unless you really need the performance</li>
<li><strong>Document your risks</strong> - Be transparent about using undocumented approaches</li>
</ol>
<p>The traditional CSOM taxonomy approach works fine for simple scenarios. But when you&rsquo;re dealing with lots of items and lots of terms, sometimes you need to think outside the box.</p>
<p>Fast taxonomy loading is one of five techniques in <a href="https://jeppe-spanggaard.dk/blogs/sharepoint-csom-performance-playbook/">The SharePoint CSOM Performance Playbook</a>, which is where I&rsquo;d start if taxonomy isn&rsquo;t the only thing slowing you down.</p>
<p>I demoed this approach on the <a href="https://pnp.github.io/blog/weekly-agenda/26-01-26/">Microsoft 365 &amp; Power Platform community demos call</a> in January 2026, under the same title as this post. Those calls are worth an hour of your month if you build on this stack.</p>
]]></content:encoded></item><item><title>SharePoint CSOM: Prevent Unnecessary Updates and API Calls</title><link>https://jeppe-spanggaard.dk/blogs/sharepoint-csom-prevent-unnecessary-updates/</link><pubDate>Wed, 05 Nov 2025 10:00:00 +0100</pubDate><author>Jeppe Spanggaard</author><guid>https://jeppe-spanggaard.dk/blogs/sharepoint-csom-prevent-unnecessary-updates/</guid><description>The SharePoint API call that wasted resources is now optimized with smart change detection, reducing unnecessary updates and costs.</description><content:encoded><![CDATA[<h2 id="the-moment-everything-clicked">The Moment Everything Clicked</h2>
<p>So there I was, building what I thought was a pretty straightforward API endpoint. Users fill out a form, click save, and boom—SharePoint list item gets updated. Simple, right?</p>
<p>Wrong.</p>
<p>It was during one of those routine maintenance windows when I decided to review some API logs that I noticed something odd. The numbers just didn&rsquo;t add up. We were making way more SharePoint API calls than seemed necessary for the amount of actual data changes happening in the system.</p>
<p><strong>Every operation was hitting SharePoint. Even when nothing had actually changed.</strong></p>
<p>That&rsquo;s when it hit me. Our application was treating every potential update as an actual update, regardless of whether the data was different from what SharePoint already had. Users could open a form, change nothing, click save, and we&rsquo;d still fire off an API call to &ldquo;update&rdquo; the item with identical values.</p>
<p>But SharePoint doesn&rsquo;t care if you&rsquo;re setting a field to the exact same value it already has. It still counts that as an API call. It still hits your throttling limits. And if you&rsquo;re paying for &ldquo;Service Prioritization in SharePoint&rdquo;? It still costs you money.</p>
<h2 id="the-why-did-i-even-build-this-moment">The &ldquo;Why Did I Even Build This?&rdquo; Moment</h2>
<p>I spent some time digging deeper into the patterns, and the numbers were&hellip; enlightening.</p>
<p>This wasn&rsquo;t just a user behavior issue. It was happening everywhere:</p>
<ul>
<li>Users frequently save forms without making actual changes</li>
<li>Automated processes periodically &ldquo;sync&rdquo; data that&rsquo;s already current</li>
<li>Applications send complete object states rather than just changed fields</li>
<li>Integration systems perform routine updates as part of larger workflows</li>
</ul>
<p>When I looked at a typical day&rsquo;s worth of operations, I found that <strong>roughly 70% of our SharePoint update calls weren&rsquo;t actually changing anything</strong>. We were essentially paying SharePoint to accept identical data and politely say &ldquo;thanks for the update!&rdquo;</p>
<p>The more I thought about it, the more I realized this pattern was probably happening in systems everywhere. How many developers have built the same &ldquo;just update everything&rdquo; approach without stopping to ask whether anything actually changed?</p>
<h2 id="sharepoints-sure-ill-take-your-money-attitude">SharePoint&rsquo;s &ldquo;Sure, I&rsquo;ll Take Your Money&rdquo; Attitude</h2>
<p>Here&rsquo;s the thing about SharePoint&rsquo;s CSOM that nobody really talks about: it doesn&rsquo;t care if you&rsquo;re being wasteful. You want to set a field to the exact same value it already has? &ldquo;No problem!&rdquo; says SharePoint. &ldquo;That&rsquo;ll be one API call, please.&rdquo;</p>
<p>And if you&rsquo;re using Service Prioritization in SharePoint? That&rsquo;s real money walking out the door.</p>
<p>Let me show you what I mean with some concrete numbers from a client I worked with recently:</p>
<h3 id="the-head-scratching-cases">The Head-Scratching Cases</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:#75715e">// These should be considered equal, but .NET says &#34;nope&#34;</span>
</span></span><span style="display:flex;"><span>currentValue: <span style="color:#e6db74">&#34;John Doe&#34;</span>
</span></span><span style="display:flex;"><span>newValue: <span style="color:#e6db74">&#34;John Doe&#34;</span>  <span style="color:#75715e">// Easy case, no problem here</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>currentValue: <span style="color:#66d9ef">null</span>
</span></span><span style="display:flex;"><span>newValue: <span style="color:#e6db74">&#34;&#34;</span>  <span style="color:#75715e">// Is empty string the same as null? Your call!</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>currentValue: <span style="color:#ae81ff">42</span>
</span></span><span style="display:flex;"><span>newValue: <span style="color:#e6db74">&#34;42&#34;</span>  <span style="color:#75715e">// Different types, same value. Fun times.</span>
</span></span></code></pre></div><h3 id="the-sharepoint-special-cases">The SharePoint Special Cases</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:#75715e">// User fields (oh boy...)</span>
</span></span><span style="display:flex;"><span>currentValue: FieldUserValue { LookupId = <span style="color:#ae81ff">15</span>, LookupValue = <span style="color:#e6db74">&#34;John Doe&#34;</span> }
</span></span><span style="display:flex;"><span>newValue: FieldUserValue { LookupId = <span style="color:#ae81ff">15</span>, LookupValue = <span style="color:#e6db74">&#34;John Doe&#34;</span> }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Multi-choice fields that hate you</span>
</span></span><span style="display:flex;"><span>currentValue: [<span style="color:#e6db74">&#34;Option A&#34;</span>, <span style="color:#e6db74">&#34;Option C&#34;</span>, <span style="color:#e6db74">&#34;Option B&#34;</span>]
</span></span><span style="display:flex;"><span>newValue: [<span style="color:#e6db74">&#34;Option B&#34;</span>, <span style="color:#e6db74">&#34;Option A&#34;</span>, <span style="color:#e6db74">&#34;Option C&#34;</span>]  <span style="color:#75715e">// Same options, different order</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Taxonomy fields (because why not make it complicated?)</span>
</span></span><span style="display:flex;"><span>currentValue: TaxonomyFieldValue { TermGuid = <span style="color:#e6db74">&#34;abc-123&#34;</span>, Label = <span style="color:#e6db74">&#34;Technology&#34;</span> }
</span></span><span style="display:flex;"><span>newValue: TaxonomyFieldValue { TermGuid = <span style="color:#e6db74">&#34;abc-123&#34;</span>, Label = <span style="color:#e6db74">&#34;Technology&#34;</span> }
</span></span></code></pre></div><p>After an hour of testing different scenarios, I realized I needed something more sophisticated than <code>oldValue == newValue</code>.</p>
<h2 id="building-the-actually-smart-comparison-engine">Building the &ldquo;Actually Smart&rdquo; Comparison Engine</h2>
<p>Alright, time to get serious. I needed a comparison function that could handle all of SharePoint&rsquo;s quirky field types and still be fast enough to not slow down my API.</p>
<p>Here&rsquo;s what I ended up building (and yes, it&rsquo;s a bit of a beast):</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">static</span> List&lt;(<span style="color:#66d9ef">string</span> InternalName, <span style="color:#66d9ef">object?</span> NewValue)&gt; GetDifferences(
</span></span><span style="display:flex;"><span>    ListItem item,
</span></span><span style="display:flex;"><span>    IDictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">object?</span>&gt; newValues,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">bool</span> treatEmptyStringAsNull)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> diffs = <span style="color:#66d9ef">new</span> List&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:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> kvp <span style="color:#66d9ef">in</span> newValues)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> name = kvp.Key;
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> newVal = kvp.Value;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">object?</span> currentVal = <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (item.FieldValues != <span style="color:#66d9ef">null</span> &amp;&amp; item.FieldValues.TryGetValue(name, <span style="color:#66d9ef">out</span> <span style="color:#66d9ef">var</span> cv))
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            currentVal = cv;
</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> (!ValuesEqual(currentVal, newVal, treatEmptyStringAsNull))
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            diffs.Add((name, newVal));
</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> diffs;
</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">bool</span> ValuesEqual(<span style="color:#66d9ef">object?</span> a, <span style="color:#66d9ef">object?</span> b, <span style="color:#66d9ef">bool</span> treatEmptyStringAsNull)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (ReferenceEquals(a, b))
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <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">if</span> (a <span style="color:#66d9ef">is</span> <span style="color:#66d9ef">null</span> || b <span style="color:#66d9ef">is</span> <span style="color:#66d9ef">null</span>)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (treatEmptyStringAsNull)
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> ((a <span style="color:#66d9ef">is</span> <span style="color:#66d9ef">null</span> &amp;&amp; IsEmptyStringLike(b)) || (b <span style="color:#66d9ef">is</span> <span style="color:#66d9ef">null</span> &amp;&amp; IsEmptyStringLike(a)))
</span></span><span style="display:flex;"><span>            {
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">return</span> <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">return</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>    a = Normalize(a, treatEmptyStringAsNull);
</span></span><span style="display:flex;"><span>    b = Normalize(b, treatEmptyStringAsNull);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (a <span style="color:#66d9ef">is</span> IStructuralEquatable seA &amp;&amp; b <span style="color:#66d9ef">is</span> IStructuralEquatable seB)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> StructuralComparisons.StructuralEqualityComparer.Equals(seA, seB);
</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> Equals(a, b);
</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">bool</span> IsEmptyStringLike(<span style="color:#66d9ef">object?</span> x)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> x <span style="color:#66d9ef">is</span> <span style="color:#66d9ef">string</span> s &amp;&amp; <span style="color:#66d9ef">string</span>.IsNullOrWhiteSpace(s);
</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">object?</span> Normalize(<span style="color:#66d9ef">object?</span> v, <span style="color:#66d9ef">bool</span> treatEmptyStringAsNull)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (v == <span style="color:#66d9ef">null</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">null</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">switch</span> (v)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">case</span> <span style="color:#66d9ef">string</span> s:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> (treatEmptyStringAsNull &amp;&amp; <span style="color:#66d9ef">string</span>.IsNullOrWhiteSpace(s)) ? <span style="color:#66d9ef">null</span> : s;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">case</span> <span style="color:#66d9ef">bool</span> b:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> b;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">case</span> <span style="color:#66d9ef">byte</span> or <span style="color:#66d9ef">sbyte</span> or <span style="color:#66d9ef">short</span> or <span style="color:#66d9ef">ushort</span> or <span style="color:#66d9ef">int</span> or <span style="color:#66d9ef">uint</span> or <span style="color:#66d9ef">long</span> or <span style="color:#66d9ef">ulong</span> or <span style="color:#66d9ef">float</span> or <span style="color:#66d9ef">double</span> or <span style="color:#66d9ef">decimal</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> Convert.ToDecimal(v, CultureInfo.InvariantCulture);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">case</span> DateTime dt:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> utc = (dt.Kind == DateTimeKind.Utc ? dt : DateTime.SpecifyKind(dt, DateTimeKind.Unspecified)).ToUniversalTime();
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> DateTime(utc.Year, utc.Month, utc.Day, utc.Hour, utc.Minute, utc.Second, DateTimeKind.Utc);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">case</span> FieldUserValue u:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> u.LookupId;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">case</span> FieldLookupValue l:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> l.LookupId;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">case</span> IEnumerable&lt;FieldLookupValue&gt; multiLookup:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> multiLookup.Select(x =&gt; x?.LookupId ?? <span style="color:#ae81ff">0</span>).OrderBy(x =&gt; x).ToArray();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">case</span> TaxonomyFieldValue tx:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> tx.TermGuid?.Trim().ToLowerInvariant();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">case</span> TaxonomyFieldValueCollection txc:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> txc
</span></span><span style="display:flex;"><span>                .Where(x =&gt; x != <span style="color:#66d9ef">null</span> &amp;&amp; !<span style="color:#66d9ef">string</span>.IsNullOrEmpty(x.TermGuid))
</span></span><span style="display:flex;"><span>                .Select(x =&gt; x.TermGuid.Trim().ToLowerInvariant())
</span></span><span style="display:flex;"><span>                .OrderBy(g =&gt; g)
</span></span><span style="display:flex;"><span>                .ToArray();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">case</span> FieldGeolocationValue geo:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> ValueTuple&lt;<span style="color:#66d9ef">double</span>, <span style="color:#66d9ef">double</span>, <span style="color:#66d9ef">double</span>, <span style="color:#66d9ef">double</span>&gt;(
</span></span><span style="display:flex;"><span>                Math.Round(geo.Latitude, <span style="color:#ae81ff">6</span>),
</span></span><span style="display:flex;"><span>                Math.Round(geo.Longitude, <span style="color:#ae81ff">6</span>),
</span></span><span style="display:flex;"><span>                Math.Round(geo.Altitude, <span style="color:#ae81ff">2</span>),
</span></span><span style="display:flex;"><span>                Math.Round(geo.Measure, <span style="color:#ae81ff">2</span>));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">case</span> <span style="color:#66d9ef">string</span>[] ss:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> ss
</span></span><span style="display:flex;"><span>                .Select(x =&gt; treatEmptyStringAsNull &amp;&amp; <span style="color:#66d9ef">string</span>.IsNullOrWhiteSpace(x) ? <span style="color:#66d9ef">null</span> : x)
</span></span><span style="display:flex;"><span>                .OrderBy(x =&gt; x, StringComparer.Ordinal)
</span></span><span style="display:flex;"><span>                .ToArray();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">case</span> IEnumerable enumerable when v <span style="color:#66d9ef">is</span> not <span style="color:#66d9ef">string</span>:
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> list = <span style="color:#66d9ef">new</span> List&lt;<span style="color:#66d9ef">object?</span>&gt;();
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> e <span style="color:#66d9ef">in</span> enumerable)
</span></span><span style="display:flex;"><span>            {
</span></span><span style="display:flex;"><span>                list.Add(Normalize(e, treatEmptyStringAsNull));
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> list.ToArray();
</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">default</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> v;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="the-magic-behind-the-scenes">The Magic Behind the Scenes</h2>
<h3 id="1-the-normalization-dance">1. <strong>The Normalization Dance</strong></h3>
<p>The <code>Normalize</code> method is where the real magic happens. It takes SharePoint&rsquo;s various field types and converts them into something we can actually compare:</p>
<ul>
<li><strong>Numbers</strong>: Everything becomes a <code>decimal</code> because SharePoint loves to mix integers and strings</li>
<li><strong>DateTime</strong>: Converted to UTC and truncated to seconds (because who needs millisecond precision for list items?)</li>
<li><strong>User/Lookup Fields</strong>: We only care about the <code>LookupId</code>, not the display text that might change</li>
<li><strong>Collections</strong>: Sorted alphabetically because SharePoint doesn&rsquo;t guarantee order</li>
<li><strong>Strings</strong>: Can optionally treat empty/whitespace as null (trust me, you want this)</li>
</ul>
<h3 id="2-the">2. <strong>The &ldquo;It&rsquo;s Not You, It&rsquo;s SharePoint&rdquo; Handling</strong></h3>
<p>For arrays and complex objects, I use <code>IStructuralEquatable</code> to do deep comparisons. Because yes, SharePoint will absolutely give you arrays that contain the same items but in different orders.</p>
<h3 id="3-the-business-logic-escape-hatch">3. <strong>The Business Logic Escape Hatch</strong></h3>
<p>The <code>treatEmptyStringAsNull</code> parameter saved my sanity. Different parts of your application might handle empty values differently, and this lets you define your own rules for what counts as &ldquo;unchanged.&rdquo;</p>
<h2 id="but-wait-why-not-just-use-json-comparison">But Wait, Why Not Just Use JSON Comparison?</h2>
<p>Before you ask (because I know you&rsquo;re thinking it): &ldquo;Why build this elaborate comparison engine when you could just serialize both objects to JSON and compare the strings?&rdquo;</p>
<p>Great question. I actually thought the same thing initially. How hard could it be, right? Just <code>JsonSerializer.Serialize()</code> both the old and new values, compare the resulting strings, and call it a day.</p>
<p>Here&rsquo;s what the &ldquo;simple&rdquo; JSON approach looks like:</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">static</span> List&lt;(<span style="color:#66d9ef">string</span> InternalName, <span style="color:#66d9ef">object?</span> NewValue)&gt; GetDifferencesJson(
</span></span><span style="display:flex;"><span>    IDictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">object?</span>&gt; oldValues,
</span></span><span style="display:flex;"><span>    IDictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">object?</span>&gt; newValues,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">bool</span> treatEmptyStringAsNull)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> diffs = <span style="color:#66d9ef">new</span> List&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:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> kvp <span style="color:#66d9ef">in</span> newValues)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> name = kvp.Key;
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> newVal = kvp.Value;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">object?</span> currentVal = <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>        oldValues?.TryGetValue(name, <span style="color:#66d9ef">out</span> currentVal);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> newJson = JsonSerializer.Serialize(newVal);
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> oldJson = JsonSerializer.Serialize(currentVal);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (newJson != oldJson)
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            diffs.Add((name, newVal));
</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> diffs;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Looks clean and simple, right? That&rsquo;s what I thought too. So I built both approaches and put them head-to-head with BenchmarkDotNet. The results were&hellip; eye-opening.</p>
<h3 id="the-performance-reality-check">The Performance Reality Check</h3>
<p>Here&rsquo;s what I found when comparing the performance of my custom approach versus JSON string comparison:</p>
<p><strong>Single List Item Comparison:</strong></p>
<ul>
<li><strong>Custom Dictionary Approach</strong>: 749,625 operations/second (1.334 μs per operation)</li>
<li><strong>JSON String Approach</strong>: 201,109 operations/second (4.972 μs per operation)</li>
<li><strong>Performance difference</strong>: 3.7x faster with the custom approach</li>
</ul>
<p><strong>Batch Operations (300 items):</strong></p>
<ul>
<li><strong>Custom Dictionary Approach</strong>: 3,705 operations/second (269.91 μs per batch)</li>
<li><strong>JSON String Approach</strong>: 712 operations/second (1,404.06 μs per batch)</li>
<li><strong>Performance difference</strong>: 5.2x faster with the custom approach</li>
</ul>
<h3 id="why-json-comparison-falls-short">Why JSON Comparison Falls Short</h3>
<p>The numbers tell the story, but here&rsquo;s what&rsquo;s actually happening under the hood:</p>
<ol>
<li><strong>Serialization Overhead</strong>: Converting SharePoint field values to JSON strings requires multiple allocations and string operations</li>
<li><strong>Memory Pressure</strong>: JSON approach allocated nearly 50% more memory (4.67KB vs 3.17KB per operation)</li>
<li><strong>Type Conversion Issues</strong>: JSON serialization doesn&rsquo;t handle SharePoint&rsquo;s quirky field types the way we need</li>
<li><strong>Garbage Collection</strong>: More allocations = more GC pressure = slower overall performance</li>
</ol>
<p>When you&rsquo;re processing hundreds or thousands of list items in batch operations, that 3-5x performance difference really adds up. Plus, the custom approach gives us complete control over how different field types are compared, which JSON comparison simply can&rsquo;t provide.</p>
<h2 id="putting-it-all-together-the-real-world-implementation">Putting It All Together: The Real-World Implementation</h2>
<p>Here&rsquo;s how I actually use this in my APIs:</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">async</span> Task&lt;<span style="color:#66d9ef">bool</span>&gt; UpdateListItemAsync(<span style="color:#66d9ef">int</span> itemId, Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">object?</span>&gt; newValues)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> clientContext = GetClientContext();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> list = clientContext.Web.Lists.GetByTitle(<span style="color:#e6db74">&#34;YourList&#34;</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> listItem = list.GetItemById(itemId);
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    clientContext.Load(listItem);
</span></span><span style="display:flex;"><span>    clientContext.ExecuteQuery();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// The moment of truth - what actually changed?</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> differences = GetDifferences(listItem, newValues, treatEmptyStringAsNull: <span style="color:#66d9ef">true</span>);
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (!differences.Any())
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Nothing changed! Skip the update entirely</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">false</span>; <span style="color:#75715e">// &#34;Thanks for playing, but you didn&#39;t actually change anything&#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">// Only update the fields that actually changed</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> (fieldName, newValue) <span style="color:#66d9ef">in</span> differences)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        listItem[fieldName] = newValue;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    listItem.Update();
</span></span><span style="display:flex;"><span>    clientContext.ExecuteQuery();
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">true</span>; <span style="color:#75715e">// &#34;Something actually happened!&#34;</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="the-numbers-dont-lie-and-theyre-pretty-great">The Numbers Don&rsquo;t Lie (And They&rsquo;re Pretty Great)</h2>
<p>After implementing this change detection across our applications, here&rsquo;s what we typically see:</p>
<p><strong>Before implementing change detection:</strong></p>
<ul>
<li>Applications making thousands of update requests per day</li>
<li>Every single one triggered a SharePoint <code>ExecuteQuery()</code></li>
<li>High API call volumes and occasional throttling issues</li>
</ul>
<p><strong>After implementing change detection:</strong></p>
<ul>
<li>Same number of update requests from users/systems</li>
<li>60-80% contained no actual changes and were skipped</li>
<li>Dramatically reduced SharePoint API consumption</li>
<li>Much lower throttling risk</li>
</ul>
<p><strong>The typical impact:</strong></p>
<ul>
<li><strong>Significant reduction in monthly API calls</strong></li>
<li><strong>Cost savings</strong> with Service Prioritization in SharePoint (varies by usage)</li>
<li><strong>Reduced throttling risk</strong> during busy periods</li>
<li><strong>Faster API responses</strong> because skipped operations are basically instant</li>
</ul>
<p>But here&rsquo;s what really matters: the performance improvement isn&rsquo;t just about the numbers. Users notice when applications feel more responsive, and developers notice when they stop getting throttling alerts.</p>
<h2 id="the-gotchas-because-there-are-always-gotchas">The Gotchas (Because There Are Always Gotchas)</h2>
<h3 id="when-this-approach-might-not-be-for-you">When This Approach Might Not Be For You</h3>
<ol>
<li>
<p><strong>Audit Requirements</strong>: If you need to log every single &ldquo;save&rdquo; action regardless of whether anything changed, this might not work for your use case.</p>
</li>
<li>
<p><strong>Always Update Timestamps</strong>: Some business requirements dictate that &ldquo;LastModified&rdquo; should always be updated when a user clicks save, even if the content is identical.</p>
</li>
<li>
<p><strong>Super Simple Scenarios</strong>: If you&rsquo;re only updating one field and it&rsquo;s a simple string comparison, the overhead of this elaborate comparison might not be worth it.</p>
</li>
</ol>
<h3 id="the-sharepoint-field-types-that-made-me-question-my-life-choices">The SharePoint Field Types That Made Me Question My Life Choices</h3>
<p>Some field types need special handling:</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">// Calculated fields - don&#39;t even try to update these</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (field.ReadOnlyField || field.FieldTypeKind == FieldType.Calculated)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">continue</span>; <span style="color:#75715e">// SharePoint will just ignore you anyway</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">// Attachment fields - these need completely different logic</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (field.FieldTypeKind == FieldType.Attachments)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Handle separately - attachments are their own special nightmare</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">continue</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="wrapping-up-and-why-this-matters-more-than-you-think">Wrapping Up (And Why This Matters More Than You Think)</h2>
<p>Look, I know this might seem like over-engineering for a simple &ldquo;update SharePoint item&rdquo; operation. But here&rsquo;s the thing: those small inefficiencies add up fast.</p>
<p>In my case, this one change:</p>
<ul>
<li><strong>Improved user experience</strong> with faster response times</li>
<li><strong>Reduced throttling headaches</strong> during busy periods</li>
<li><strong>Made me feel like a responsible developer</strong> (this one&rsquo;s important too!)</li>
</ul>
<p>The pattern I&rsquo;ve shown you isn&rsquo;t just about SharePoint or just about API optimization. It&rsquo;s about asking the fundamental question: <strong>&ldquo;Is this operation actually necessary?&rdquo;</strong></p>
<p>That question has made me a better developer across all the platforms I work with, not just SharePoint.</p>
<p>So next time you&rsquo;re building any kind of update operation—whether it&rsquo;s SharePoint, a database, or that JSON file you&rsquo;re definitely not using as a database—pause for a moment and ask: &ldquo;Did anything actually change?&rdquo;</p>
<p>Your future self (and your API bills) will thank you.</p>
<p><strong>Pro tip</strong>: Start implementing this pattern on your highest-traffic endpoints first. That&rsquo;s where you&rsquo;ll see the biggest impact, and it&rsquo;ll give you the confidence to roll it out everywhere else.</p>
<p>Now go forth and eliminate those unnecessary API calls. Your SharePoint environment will purr like a happy cat.</p>
<p>Change detection is one of five techniques in <a href="https://jeppe-spanggaard.dk/blogs/sharepoint-csom-performance-playbook/">The SharePoint CSOM Performance Playbook</a>. If you want the whole picture of where CSOM calls leak, start there.</p>
]]></content:encoded></item><item><title>SharePoint's Server-Side Try-Catch: ExceptionHandlingScope</title><link>https://jeppe-spanggaard.dk/blogs/sharepoint-exceptionhandlingscope-csom-ensureuser/</link><pubDate>Mon, 20 Oct 2025 10:00:00 +0100</pubDate><author>Jeppe Spanggaard</author><guid>https://jeppe-spanggaard.dk/blogs/sharepoint-exceptionhandlingscope-csom-ensureuser/</guid><description>Discover how SharePoint's ExceptionHandlingScope can optimize your API calls, reduce costs, and enhance performance with server-side exception handling.</description><content:encoded><![CDATA[<h2 id="the-service-prioritization-in-sharepoint-cost-analysis">The Service Prioritization in SharePoint Cost Analysis</h2>
<p>Recently, I was working with a client to calculate the cost of enabling <a href="https://learn.microsoft.com/en-us/sharepoint/sharepoint-prioritization">Service Prioritization in SharePoint</a> on one of their app registrations. The pricing model is straightforward: USD $0.50 per 1,000 Graph calls and USD $1.00 per 1,000 SP REST/CSOM calls.</p>
<p>During the analysis, I discovered something that caught my attention: their solution was making approximately <strong>90,000 calls per month</strong>, and <strong>88% of all their CSOM calls were <code>EnsureUser</code> operations.</strong></p>
<p>This was particularly interesting because the users being &ldquo;ensured&rdquo; were already associated with the site where the data was being created. It seemed like there should be a more efficient approach - similar to what&rsquo;s possible with Graph API.</p>
<h2 id="the-problem-with-user-field-assignment">The Problem with User Field Assignment</h2>
<p>If you&rsquo;ve worked with SharePoint user fields, you&rsquo;re familiar with this common pattern:</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">// The standard approach</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> user = clientContext.Web.EnsureUser(<span style="color:#e6db74">&#34;user@company.com&#34;</span>);
</span></span><span style="display:flex;"><span>clientContext.Load(user);
</span></span><span style="display:flex;"><span>clientContext.ExecuteQuery(); <span style="color:#75715e">// Network call #1</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>listItem[<span style="color:#e6db74">&#34;AssignedTo&#34;</span>] = <span style="color:#66d9ef">new</span> FieldUserValue()
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    LookupId = user.Id
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>listItem.Update();
</span></span><span style="display:flex;"><span>clientContext.ExecuteQuery(); <span style="color:#75715e">// Network call #2</span>
</span></span></code></pre></div><p>This requires two network calls for each user assignment. Microsoft provides <code>FieldUserValue.FromUser(&quot;user@company.com&quot;)</code> as an alternative, but it throws an exception if the user isn&rsquo;t already &ldquo;known&rdquo; to the site. This leads to either making two calls to be safe, or handling exceptions with additional calls.</p>
<h2 id="discovering-exceptionhandlingscope">Discovering ExceptionHandlingScope</h2>
<p>While researching optimization approaches, I came across a section in Microsoft&rsquo;s documentation for &ldquo;<a href="https://learn.microsoft.com/en-us/sharepoint/dev/sp-add-ins/complete-basic-operations-using-sharepoint-client-library-code">Complete basic operations using SharePoint client library code</a>&rdquo; about <strong>ExceptionHandlingScope</strong>.</p>
<p>ExceptionHandlingScope allows you to implement try-catch logic that executes on the SharePoint server rather than requiring multiple round trips from your client application. Instead of this painful dance:</p>
<ol>
<li>Try operation → Network call</li>
<li>Handle exception → Network call</li>
<li>Retry operation → Network call</li>
</ol>
<p>You get this beautiful symphony:</p>
<ol>
<li>Send try-catch logic to server → Network call</li>
<li>Server handles everything internally</li>
<li>Get result → You&rsquo;re done</li>
</ol>
<p>Here&rsquo;s the magic in action:</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> scope = <span style="color:#66d9ef">new</span> ExceptionHandlingScope(clientContext);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">using</span> (scope.StartScope())
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">using</span> (scope.StartTry())
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Try the optimistic approach first</span>
</span></span><span style="display:flex;"><span>        listItem[<span style="color:#e6db74">&#34;CaseResponsible&#34;</span>] = FieldUserValue.FromUser(<span style="color:#e6db74">&#34;user@company.com&#34;</span>);
</span></span><span style="display:flex;"><span>        listItem.Update();
</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">using</span> (scope.StartCatch())
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// If that fails, ensure the user exists</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> user = clientContext.Web.EnsureUser(<span style="color:#e6db74">&#34;user@company.com&#34;</span>);
</span></span><span style="display:flex;"><span>        listItem[<span style="color:#e6db74">&#34;CaseResponsible&#34;</span>] = FieldUserValue.FromUser(<span style="color:#e6db74">&#34;user@company.com&#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:#75715e">// Execute once - the server handles all the logic</span>
</span></span><span style="display:flex;"><span>clientContext.ExecuteQuery();
</span></span></code></pre></div><p><strong>One network call. That&rsquo;s it.</strong></p>
<p>The server receives your entire try-catch block, attempts the optimistic operation first, and only falls back to <code>EnsureUser</code> if needed. No round trips. No guessing. No waste.</p>
<h2 id="real-world-impact-significant-call-reduction">Real-World Impact: Significant Call Reduction</h2>
<p>Looking back at the client scenario with ExceptionHandlingScope:</p>
<ul>
<li><strong>Before</strong>: ~79,200 <code>EnsureUser</code> calls + ~10,800 actual operations = 90,000 total calls</li>
<li><strong>After</strong>: ~10,800-21,600 operations (depending on how many users need ensuring) = substantial reduction</li>
</ul>
<p>This represents a potential <strong>75-85% reduction</strong> in API calls, with corresponding improvements in both performance and Service Prioritization in SharePoint costs.</p>
<h3 id="the-cost-breakdown">The Cost Breakdown</h3>
<p>Let&rsquo;s translate this into actual Service Prioritization in SharePoint costs:</p>
<p><strong>Before optimization:</strong></p>
<ul>
<li>90,000 CSOM calls per month</li>
<li>At USD $1.00 per 1,000 calls</li>
<li><strong>Monthly cost: $90</strong></li>
</ul>
<p><strong>After ExceptionHandlingScope optimization:</strong></p>
<ul>
<li>Best case: 10,800 calls (if most users are already known) = <strong>$10.80/month</strong></li>
<li>Worst case: 21,600 calls (if many users need ensuring) = <strong>$21.60/month</strong></li>
<li><strong>Monthly savings: $68.40 - $79.20</strong></li>
</ul>
<p><strong>Annual savings: $820 - $950</strong></p>
<p>These savings become even more significant at scale. For organizations with multiple solutions or higher call volumes, the cost difference can easily reach thousands of dollars annually.</p>
<p>More importantly, the performance improvements—reduced network latency, faster operations, and lower throttling risk—often provide value that far exceeds the direct cost savings.</p>
<h2 id="throttling-the-hidden-performance-killer">Throttling: The Hidden Performance Killer</h2>
<p>Beyond cost savings, ExceptionHandlingScope provides significant throttling benefits that are often more impactful than the financial savings.</p>
<h3 id="understanding-sharepoint-throttling">Understanding SharePoint Throttling</h3>
<p>SharePoint applies throttling limits to prevent abuse and ensure service stability. When you exceed these limits, you&rsquo;ll encounter:</p>
<ul>
<li><strong>HTTP 429 (Too Many Requests)</strong> responses</li>
<li><strong>Exponential backoff delays</strong> (sometimes minutes)</li>
<li><strong>User experience degradation</strong> as operations slow down</li>
<li><strong>Potential service interruptions</strong> during peak usage</li>
</ul>
<h3 id="the-throttling-math">The Throttling Math</h3>
<p>In our client scenario:</p>
<p><strong>Before ExceptionHandlingScope:</strong></p>
<ul>
<li>90,000 calls per month = ~3,000 calls per day</li>
<li>During peak hours, this could easily trigger throttling</li>
<li>Each throttled request requires retry with exponential backoff</li>
<li>A single throttled operation can delay your entire batch</li>
</ul>
<p><strong>After ExceptionHandlingScope:</strong></p>
<ul>
<li>10,800-21,600 calls per month = ~360-720 calls per day</li>
<li><strong>10x reduction in throttling risk</strong></li>
<li>Smoother operation during peak business hours</li>
<li>More predictable performance for end users</li>
</ul>
<h3 id="real-world-throttling-impact">Real-World Throttling Impact</h3>
<p>Consider a typical business day where multiple users are creating items simultaneously:</p>
<ul>
<li><strong>Without optimization</strong>: 88% of your throttling budget consumed by redundant EnsureUser calls</li>
<li><strong>With ExceptionHandlingScope</strong>: 88% of your throttling budget available for actual business operations</li>
</ul>
<p>The beauty of ExceptionHandlingScope is that it doesn&rsquo;t just reduce the number of calls—it makes your remaining calls more valuable and less likely to be throttled.</p>
<h2 id="important-exceptionhandlingscope-is-not-a-magic-bullet">Important: ExceptionHandlingScope Is NOT a Magic Bullet</h2>
<p><strong>Critical Disclaimer</strong>: ExceptionHandlingScope itself does NOT reduce the number of requests to SharePoint. Each operation within the scope still counts as a separate request for throttling purposes.</p>
<p>The reduction in our scenario comes from <strong>changing the approach</strong>, not from using ExceptionHandlingScope:</p>
<h3 id="what-actually-reduces-calls">What Actually Reduces Calls</h3>
<ul>
<li><strong>Before</strong>: Always calling <code>EnsureUser</code> + setting the field = 2 calls per user</li>
<li><strong>After</strong>: Using <code>FieldUserValue.FromUser()</code> directly, falling back to <code>EnsureUser</code> only when needed</li>
</ul>
<h3 id="what-exceptionhandlingscope-actually-does">What ExceptionHandlingScope Actually Does</h3>
<p>ExceptionHandlingScope reduces <strong>network round trips</strong>, not <strong>server requests</strong>:</p>
<ul>
<li><strong>Network benefit</strong>: 1 round trip instead of potentially 2-3</li>
<li><strong>Throttling impact</strong>: Each operation still counts toward throttling limits</li>
<li><strong>Performance gain</strong>: Reduced latency, not reduced server load</li>
</ul>
<h3 id="the-real-magic">The Real Magic</h3>
<p>The 75-85% reduction in our client scenario comes from this logic 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:#75715e">// This approach reduces actual requests</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Most users are already known to the site</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// So most operations only need 1 request instead of 2</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">using</span> (scope.StartTry())
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// This succeeds for ~80% of users (1 request)</span>
</span></span><span style="display:flex;"><span>    listItem[<span style="color:#e6db74">&#34;Field&#34;</span>] = FieldUserValue.FromUser(<span style="color:#e6db74">&#34;user@company.com&#34;</span>);
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">using</span> (scope.StartCatch())
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// This only runs for ~20% of users (1 request)</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> user = clientContext.Web.EnsureUser(<span style="color:#e6db74">&#34;user@company.com&#34;</span>);
</span></span><span style="display:flex;"><span>    listItem[<span style="color:#e6db74">&#34;Field&#34;</span>] = FieldUserValue.FromUser(<span style="color:#e6db74">&#34;user@company.com&#34;</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>ExceptionHandlingScope simply makes this pattern efficient by handling the try-catch logic server-side instead of requiring multiple network round trips to determine which approach to use.</p>
<p><strong>Bottom line</strong>: ExceptionHandlingScope optimizes network efficiency, but the request reduction comes from smarter business logic, not from the scope itself.</p>
<h2 id="the-broader-lesson">The Broader Lesson</h2>
<p>This experience highlighted an important principle: before optimizing an existing approach, it&rsquo;s worth questioning whether there&rsquo;s a fundamentally different way to solve the problem. In this case, server-side exception handling provided a cleaner solution than client-side optimization or caching strategies.</p>
<p>ExceptionHandlingScope has been available since SharePoint 2013, but it&rsquo;s not widely discussed in the SharePoint development community. It&rsquo;s a good reminder to periodically review Microsoft&rsquo;s documentation for features that might address current challenges in new ways.</p>
<h2 id="your-next-steps">Your Next Steps</h2>
<p>Before you write your next SharePoint operation that involves potential exceptions:</p>
<ol>
<li><strong>Pause and calculate</strong>: How many network calls will your approach generate?</li>
<li><strong>Question the pattern</strong>: Could server-side logic handle the complexity?</li>
<li><strong>Explore ExceptionHandlingScope</strong>: Can your try-catch logic run on the server?</li>
<li><strong>Measure the impact</strong>: Compare network calls before and after implementation</li>
</ol>
<p>Sometimes the most powerful optimizations come from using the tools that were there all along. ExceptionHandlingScope isn&rsquo;t just a performance optimization—it&rsquo;s a reminder that the platform often provides solutions we didn&rsquo;t know we were looking for.</p>
<p>Next time you&rsquo;re facing a SharePoint performance challenge, remember: the answer might not be in the latest framework or cutting-edge technique. Sometimes, it&rsquo;s hiding in plain sight in the documentation you scrolled past.</p>
<p>ExceptionHandlingScope is one of five techniques in <a href="https://jeppe-spanggaard.dk/blogs/sharepoint-csom-performance-playbook/">The SharePoint CSOM Performance Playbook</a>, alongside batching, CAML joins, change detection and fast taxonomy loading.</p>
]]></content:encoded></item><item><title>Batch Your CSOM Operations Instead of Looping One by One</title><link>https://jeppe-spanggaard.dk/blogs/csom-performance-optimization-chunking/</link><pubDate>Sun, 31 Aug 2025 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/csom-performance-optimization-chunking/</guid><description>Learn how to dramatically improve CSOM performance by batching operations instead of executing them one by one</description><content:encoded><![CDATA[<h2 id="the-problem-one-by-one-operations-kill-performance">The Problem: One-by-One Operations Kill Performance</h2>
<p>Following up on my previous post about <a href="https://jeppe-spanggaard.dk/blogs/devproxy-throttling-testing/">DevProxy and throttling testing</a>, there&rsquo;s another critical performance issue I see regularly in SharePoint CSOM code: executing operations one by one instead of batching them.</p>
<p>Consider this common pattern that I see everywhere:</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 approach is slow and inefficient</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> id <span style="color:#66d9ef">in</span> itemIds)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> item = list.GetItemById(id);
</span></span><span style="display:flex;"><span>    context.Load(item);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> context.ExecuteQueryAsync(); <span style="color:#75715e">// Executing for EACH item!</span>
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Process the item</span>
</span></span><span style="display:flex;"><span>    ProcessItem(item);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>This code makes a server round-trip for every single item.</strong> If you&rsquo;re processing 50 items, that&rsquo;s 50 separate calls to SharePoint. Each call has network latency and server processing time.</p>
<h2 id="the-solution-batch-operations">The Solution: Batch Operations</h2>
<p>SharePoint CSOM reliably handles batches of around <strong>100 operations</strong> before calling <code>ExecuteQueryAsync()</code>. This means you can dramatically reduce the number of server round-trips.</p>
<p>Microsoft&rsquo;s official documentation also emphasizes this pattern in their <a href="https://learn.microsoft.com/en-us/sharepoint/dev/sp-add-ins/complete-basic-operations-using-sharepoint-client-library-code#group-data-retrieval-on-the-same-object-together-to-improve-performance">performance guidelines</a>, showing how grouping data retrieval operations significantly improves performance.</p>
<p>Here&rsquo;s the pattern I use in most of my SharePoint projects:</p>
<h2 id="the-helper-methods">The Helper Methods</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-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">async</span> Task&lt;List&lt;T&gt;&gt; GetItemsByIds&lt;T&gt;(<span style="color:#66d9ef">this</span> List list, IEnumerable&lt;<span style="color:#66d9ef">int</span>&gt; ids) 
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">where</span> T : <span style="color:#66d9ef">new</span>()
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (ids == <span style="color:#66d9ef">null</span> || !ids.Any())
</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> List&lt;T&gt;();
</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> listItems = <span style="color:#66d9ef">await</span> CSOMHelpers.ProcessInChunks(ids.ToList(), <span style="color:#ae81ff">100</span>, <span style="color:#66d9ef">async</span> chunk =&gt; {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> items = chunk.Select(id =&gt; {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> item = list.GetItemById(id);
</span></span><span style="display:flex;"><span>            list.Context.Load(item);
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> item;
</span></span><span style="display:flex;"><span>        }).ToList();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">await</span> ExecuteQuery(list.Context, () =&gt; { <span style="color:#75715e">/* Load calls already done above */</span> });
</span></span><span style="display:flex;"><span>        
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> items;
</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> listItems.ToList();
</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">internal</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">async</span> Task&lt;List&lt;TOut&gt;&gt; ProcessInChunks&lt;TIn, TOut&gt;(List&lt;TIn&gt; source, <span style="color:#66d9ef">int</span> chunkSize, Func&lt;List&lt;TIn&gt;, Task&lt;List&lt;TOut&gt;&gt;&gt; action)
</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">new</span> List&lt;TOut&gt;();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> chunk <span style="color:#66d9ef">in</span> source.Chunk(chunkSize))
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        result.AddRange(<span style="color:#66d9ef">await</span> action(chunk.ToList()));
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> result;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="performance-impact-the-numbers">Performance Impact: The Numbers</h2>
<p>Let me show you the difference with a real-world example. Loading 50 SharePoint list items:</p>
<h3 id="before-one-by-one">Before (One-by-One)</h3>
<ul>
<li><strong>50 server calls</strong></li>
<li><strong>Network latency</strong>: 50ms × 50 = 2.5 seconds</li>
<li><strong>Actual total time is usually higher due to server-side processing, hence</strong>: ~3-5 seconds</li>
</ul>
<h3 id="after-batched">After (Batched)</h3>
<ul>
<li><strong>1 server call</strong> (all 50 items in one batch)</li>
<li><strong>Network latency</strong>: 50ms × 1 = 50ms</li>
<li><strong>Total time</strong>: ~200-500ms</li>
</ul>
<blockquote>
<p><strong>⚠️ Performance Disclaimer</strong></p>
<p>The numbers shown above are from a specific example scenario and are meant to illustrate the concept of batching benefits. <strong>Your actual performance gains will vary significantly</strong> based on:</p>
<ul>
<li>Network latency and bandwidth</li>
<li>SharePoint server load and location</li>
<li>Item complexity and field count</li>
<li>Query complexity and filtering</li>
<li>Authentication overhead</li>
<li>Time of day and concurrent users</li>
</ul>
<p>While batching almost always improves performance, the exact improvement factor can range from marginal gains to dramatic improvements. The key takeaway is the <strong>pattern and approach</strong>, not the specific numbers.</p>
</blockquote>
<h2 id="applying-the-pattern-to-crud-operations">Applying the Pattern to CRUD Operations</h2>
<p>This pattern works for all CSOM operations, not just reading, but for all the CRUD operations.</p>
<h2 id="why-100-items">Why 100 Items?</h2>
<p>SharePoint has practical limits on how many operations you can batch:</p>
<ul>
<li><strong>CSOM limit</strong>: Around 100 operations per batch</li>
<li><strong>REST API limit</strong>: Different limits depending on operation type</li>
<li><strong>Network payload</strong>: Larger batches mean bigger HTTP requests</li>
</ul>
<p>I&rsquo;ve found <strong>100 items</strong> to be the sweet spot that:</p>
<ul>
<li>Maximizes performance gains</li>
<li>Stays well within SharePoint limits</li>
<li>Keeps HTTP payloads manageable</li>
<li>Works reliably across different SharePoint environments</li>
</ul>
<p>Push past those limits and SharePoint tells you, in one of two ways. Too many operations in a single <code>ExecuteQuery</code> gives you <code>The request uses too many resources</code>. Too much <em>payload</em> in one request gives you <code>The server does not allow messages larger than 2097152 bytes</code> - that&rsquo;s 2 MB, and in SharePoint Online it&rsquo;s a server-side cap you can&rsquo;t raise from client code. No configuration setting, no header, no retry gets you past either one. Splitting the batch is the fix, which is what the helper above is for.</p>
<h2 id="combining-with-throttling-protection">Combining with Throttling Protection</h2>
<p>When you combine this batching approach with the throttling protection patterns from my <a href="https://jeppe-spanggaard.dk/blogs/devproxy-throttling-testing/">DevProxy post</a>, you get robust, high-performance SharePoint applications.</p>
<h2 id="best-practices">Best Practices</h2>
<ol>
<li><strong>Batch whenever you can</strong> - even for small numbers of items, batching usually helps.</li>
<li><strong>Use 100 as your chunk size</strong> for most scenarios</li>
<li><strong>Combine with retry logic</strong> for production resilience</li>
<li><strong>Test with DevProxy</strong> to ensure your batching works under throttling conditions</li>
<li><strong>Monitor performance</strong> before and after implementing batching</li>
</ol>
<h2 id="the-bottom-line">The Bottom Line</h2>
<p>Batching CSOM operations is one of the easiest wins for SharePoint performance optimization. The code pattern is straightforward to implement and reuse, but the performance impact is dramatic.</p>
<p><strong>Stop executing SharePoint operations one by one.</strong> Your users (and SharePoint servers) will thank you.</p>
<p>Batching is the first of five techniques I use to keep CSOM cheap. The rest, and the order I reach for them in, are in <a href="https://jeppe-spanggaard.dk/blogs/sharepoint-csom-performance-playbook/">The SharePoint CSOM Performance Playbook</a>.</p>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="https://docs.microsoft.com/en-us/sharepoint/dev/sp-add-ins/complete-basic-operations-using-sharepoint-client-library-code">SharePoint CSOM Best Practices</a></li>
<li><a href="https://jeppe-spanggaard.dk/blogs/devproxy-throttling-testing/">DevProxy for Testing Throttling</a></li>
<li><a href="https://docs.microsoft.com/en-us/sharepoint/dev/general-development/how-to-avoid-getting-throttled-or-blocked-in-sharepoint-online">SharePoint Performance Guidelines</a></li>
</ul>
]]></content:encoded></item><item><title>Efficient Multi-List Queries in CSOM: Using CAML Joins with CAMLEX</title><link>https://jeppe-spanggaard.dk/blogs/joining-multiple-lists-csom-caml/</link><pubDate>Mon, 28 Jul 2025 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/joining-multiple-lists-csom-caml/</guid><description>Learn how to efficiently query multiple SharePoint lists using CAML joins instead of multiple API calls to avoid throttling</description><content:encoded><![CDATA[<h2 id="the-problem-multiple-list-queries-and-throttling">The Problem: Multiple List Queries and Throttling</h2>
<p>When working with related data across multiple SharePoint lists, developers often fall into the trap of making multiple individual queries:</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">// ❌ Bad approach - Multiple API calls</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> customers = context.Web.Lists.GetByTitle(<span style="color:#e6db74">&#34;Customers&#34;</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> orders = context.Web.Lists.GetByTitle(<span style="color:#e6db74">&#34;Orders&#34;</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> orderItems = context.Web.Lists.GetByTitle(<span style="color:#e6db74">&#34;OrderItems&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Each query consumes 2 resource units</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> customerItems = customers.GetItems(camlQuery1);  <span style="color:#75715e">// 2 units</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> orderItems = orders.GetItems(camlQuery2);        <span style="color:#75715e">// 2 units  </span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> itemDetails = orderItems.GetItems(camlQuery3);   <span style="color:#75715e">// 2 units</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Total: 6 resource units + processing overhead</span>
</span></span></code></pre></div><p>This approach has several issues:</p>
<ul>
<li><strong>Higher resource consumption</strong>: Each query consumes <a href="https://learn.microsoft.com/en-us/sharepoint/dev/general-development/how-to-avoid-getting-throttled-or-blocked-in-sharepoint-online">2 resource units</a> per multi-item request</li>
<li><strong>Increased throttling risk</strong>: More API calls mean hitting limits faster</li>
<li><strong>Network overhead</strong>: Multiple round trips to SharePoint</li>
<li><strong>Complex data merging</strong>: Manual joining of results in C#</li>
</ul>
<h2 id="how-to-fix-it">How to fix it?</h2>
<p>I have worked alot with MSSQL where it is pretty easy to just join a table on a table on a table. But I did not know it was possible in CSOM. Eg. in the UI of SharePoint you can only expand one table - NOT multiple. But one day I deep dived into CAML and joins, and found out it was possible!</p>
<p><strong>Resource Unit Comparison:</strong></p>
<p>Let&rsquo;s break down the actual cost difference. According to Microsoft&rsquo;s <a href="https://learn.microsoft.com/en-us/sharepoint/dev/general-development/how-to-avoid-getting-throttled-or-blocked-in-sharepoint-online">throttling documentation</a>, each multi-item query consumes <strong>2 resource units</strong>.</p>
<p><strong>Multiple separate queries (❌ Bad approach):</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-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// Query 1: Get order items from main list</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> orderItems = ordersList.GetItems(query1);        <span style="color:#75715e">// 2 resource units</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Query 2: Get customer details  </span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> customers = customersList.GetItems(query2);      <span style="color:#75715e">// 2 resource units</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Query 3: Get order details</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> orderDetails = orderDetailsList.GetItems(query3); <span style="color:#75715e">// 2 resource units</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Total: 6 resource units + network overhead + manual C# joining</span>
</span></span></code></pre></div><p><strong>Single join query (✅ Good approach):</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-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// One query with joins gets ALL the data</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> items = ordersList.GetItems(joinQuery);          <span style="color:#75715e">// 2 resource units total</span>
</span></span></code></pre></div><p><strong>The math:</strong></p>
<ul>
<li>Multiple queries: <strong>2 units × 3 lists = 6 units</strong></li>
<li>Single join query: <strong>2 units total</strong></li>
<li><strong>Savings: 66% fewer resource units!</strong></li>
</ul>
<p>This becomes even more significant when you consider that SharePoint throttling limits are measured in resource units per time window. With joins, you can query 3x more data within the same throttling limits.</p>
<p><img src="https://jeppe-spanggaard.dk/images/CamlJoin_hu_ac4f9ad836dd7741.webp" srcset="/images/CamlJoin_hu_86b3b075eaa520a.webp 480w, /images/CamlJoin_hu_118714a818876a25.webp 720w, /images/CamlJoin_hu_ac4f9ad836dd7741.webp 1200w" sizes="(max-width: 760px) 100vw, 720px"
    width="1200" height="749"
    alt="alt text" style="background:url(data:image/webp;base64,UklGRlIAAABXRUJQVlA4IEYAAADwAwCdASoYAA8AP1mMt0upJKKYBACTFYT0gGGaEsaVl24t/frQwLnAAP7S9AkNB92WF1gIbVQK3xT5oPTvGrkAHO3MUAAA) center/cover no-repeat" loading="lazy" decoding="async"></p>
<h2 id="the-solution-caml-joins">The Solution: CAML Joins</h2>
<p>CAML actually supports joining multiple lists in a single query! This means you can get data from related lists without multiple API calls.</p>
<p><strong>First, install CAMLEX via NuGet:</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-powershell" data-lang="powershell"><span style="display:flex;"><span>Install-Package Camlex.Client.dll
</span></span></code></pre></div><p><strong>Then build your join query:</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-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> list = context.Web.Lists.GetByTitle(<span style="color:#e6db74">&#34;YourMainList&#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>
</span></span><span style="display:flex;"><span>query = query
</span></span><span style="display:flex;"><span>.LeftJoin(x =&gt; x[<span style="color:#e6db74">&#34;OrderTaskLookUp&#34;</span>].ForeignList(ListGuidOrderTasks))
</span></span><span style="display:flex;"><span>.LeftJoin(x =&gt; x[<span style="color:#e6db74">&#34;OrderDetailLookUp&#34;</span>].PrimaryList(ListGuidOrderTasks).ForeignList(ListGuidOrders))
</span></span><span style="display:flex;"><span>.LeftJoin(x =&gt; x[<span style="color:#e6db74">&#34;CustomerLookUp&#34;</span>].PrimaryList(ListGuidOrders).ForeignList(ListGuidCustomers))
</span></span><span style="display:flex;"><span>.ProjectedField(x =&gt; x[<span style="color:#e6db74">&#34;CustomerNo&#34;</span>].List(ListGuidCustomers).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(ListGuidCustomers).ShowField(<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> camlQuery = <span style="color:#66d9ef">new</span> CamlQuery();
</span></span><span style="display:flex;"><span>camlQuery.ViewXml = query.ToString();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> items = list.GetItems(camlQuery);
</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>Important:</strong> Your lists need to be connected via lookup columns for joins to work!</p>
<h2 id="why-camlex-instead-of-raw-caml">Why CAMLEX Instead of Raw CAML?</h2>
<p>I don&rsquo;t write raw CAML myself - it&rsquo;s verbose and error-prone. Instead, I use <a href="https://github.com/sadomovalex/camlex">CAMLEX</a> because:</p>
<ul>
<li><strong>Cleaner syntax</strong>: C# lambda expressions instead of XML</li>
<li><strong>IntelliSense support</strong>: Catch errors at compile time</li>
<li><strong>Easier for the next developer</strong>: Self-documenting code</li>
<li><strong>Less mistakes</strong>: No more XML typos or missing tags</li>
</ul>
<h2 id="what-else">What else?</h2>
<p>One thing is to join multiple lists, but to filter on a value 4 lists away - That is neat!</p>
<p>For example, filtering orders by the customer&rsquo;s name, which is 2 lists away:</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>
</span></span><span style="display:flex;"><span>query = query
</span></span><span style="display:flex;"><span>.Where(x =&gt; (<span style="color:#66d9ef">string</span>)x[<span style="color:#e6db74">&#34;CustomerName&#34;</span>] == <span style="color:#e6db74">&#34;Contoso&#34;</span>) <span style="color:#75715e">// &lt;---- Filtering on join field</span>
</span></span><span style="display:flex;"><span>.LeftJoin(x =&gt; x[<span style="color:#e6db74">&#34;OrderTaskLookUp&#34;</span>].ForeignList(ListGuidOrderTasks))
</span></span><span style="display:flex;"><span>.LeftJoin(x =&gt; x[<span style="color:#e6db74">&#34;OrderDetailLookUp&#34;</span>].PrimaryList(ListGuidOrderTasks).ForeignList(ListGuidOrders))
</span></span><span style="display:flex;"><span>.LeftJoin(x =&gt; x[<span style="color:#e6db74">&#34;CustomerLookUp&#34;</span>].PrimaryList(ListGuidOrders).ForeignList(ListGuidCustomers))
</span></span><span style="display:flex;"><span>.ProjectedField(x =&gt; x[<span style="color:#e6db74">&#34;CustomerNo&#34;</span>].List(ListGuidCustomers).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(ListGuidCustomers).ShowField(<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> camlQuery = <span style="color:#66d9ef">new</span> CamlQuery();
</span></span><span style="display:flex;"><span>camlQuery.ViewXml = query.ToString();
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> items = ordersList.GetItems(camlQuery);
</span></span></code></pre></div><p>This CAMLEX query automatically generates the following CAML XML - notice how complex the raw XML is compared to the clean C# syntax above:</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;View&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;Query&gt;</span>
</span></span><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;Geq&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;customerName&#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>Contoso<span style="color:#f92672">&lt;/Value&gt;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&lt;/Geq&gt;</span>
</span></span><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;/Query&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;ViewFields&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;Id&#34;</span> <span style="color:#f92672">/&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;FieldRef</span> <span style="color:#a6e22e">Name=</span><span style="color:#e6db74">&#34;customerName&#34;</span> <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;/ViewFields&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;Joins&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;Join</span> <span style="color:#a6e22e">Type=</span><span style="color:#e6db74">&#34;LEFT&#34;</span> <span style="color:#a6e22e">ListAlias=</span><span style="color:#e6db74">&#34;0f0cfc71-1c6e-4fd4-b6f2-279d0e3862f4&#34;</span><span style="color:#f92672">&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;OrderTaskLookUp&#34;</span> <span style="color:#a6e22e">RefType=</span><span style="color:#e6db74">&#34;Id&#34;</span> <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;FieldRef</span> <span style="color:#a6e22e">List=</span><span style="color:#e6db74">&#34;0f0cfc71-1c6e-4fd4-b6f2-279d0e3862f4&#34;</span> <span style="color:#a6e22e">Name=</span><span style="color:#e6db74">&#34;Id&#34;</span> <span style="color:#f92672">/&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;/Join&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;Join</span> <span style="color:#a6e22e">Type=</span><span style="color:#e6db74">&#34;LEFT&#34;</span> <span style="color:#a6e22e">ListAlias=</span><span style="color:#e6db74">&#34;ce57b9a2-5052-482c-a8c8-150c7d59ced3&#34;</span><span style="color:#f92672">&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">List=</span><span style="color:#e6db74">&#34;0f0cfc71-1c6e-4fd4-b6f2-279d0e3862f4&#34;</span> <span style="color:#a6e22e">Name=</span><span style="color:#e6db74">&#34;OrderDetailLookUp&#34;</span> <span style="color:#a6e22e">RefType=</span><span style="color:#e6db74">&#34;Id&#34;</span> <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;FieldRef</span> <span style="color:#a6e22e">List=</span><span style="color:#e6db74">&#34;ce57b9a2-5052-482c-a8c8-150c7d59ced3&#34;</span> <span style="color:#a6e22e">Name=</span><span style="color:#e6db74">&#34;Id&#34;</span> <span style="color:#f92672">/&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;/Join&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;Join</span> <span style="color:#a6e22e">Type=</span><span style="color:#e6db74">&#34;LEFT&#34;</span> <span style="color:#a6e22e">ListAlias=</span><span style="color:#e6db74">&#34;e204ed0f-7c25-432f-9228-3eb438c527e2&#34;</span><span style="color:#f92672">&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">List=</span><span style="color:#e6db74">&#34;ce57b9a2-5052-482c-a8c8-150c7d59ced3&#34;</span> <span style="color:#a6e22e">Name=</span><span style="color:#e6db74">&#34;CustomerLookUp&#34;</span> <span style="color:#a6e22e">RefType=</span><span style="color:#e6db74">&#34;Id&#34;</span> <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;FieldRef</span> <span style="color:#a6e22e">List=</span><span style="color:#e6db74">&#34;e204ed0f-7c25-432f-9228-3eb438c527e2&#34;</span> <span style="color:#a6e22e">Name=</span><span style="color:#e6db74">&#34;Id&#34;</span> <span style="color:#f92672">/&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;/Join&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;/Joins&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;ProjectedFields&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;Field</span> 
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">Name=</span><span style="color:#e6db74">&#34;customerNo&#34;</span> 
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">Type=</span><span style="color:#e6db74">&#34;Lookup&#34;</span> 
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">List=</span><span style="color:#e6db74">&#34;e204ed0f-7c25-432f-9228-3eb438c527e2&#34;</span> 
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">ShowField=</span><span style="color:#e6db74">&#34;customerNo&#34;</span> 
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;Field</span> 
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">Name=</span><span style="color:#e6db74">&#34;customerName&#34;</span> 
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">Type=</span><span style="color:#e6db74">&#34;Lookup&#34;</span> 
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">List=</span><span style="color:#e6db74">&#34;e204ed0f-7c25-432f-9228-3eb438c527e2&#34;</span> 
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">ShowField=</span><span style="color:#e6db74">&#34;customerName&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;/ProjectedFields&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/View&gt;</span>
</span></span></code></pre></div><p>Imagine having to write and maintain that XML manually! This is exactly why CAMLEX is so valuable - you get all the power of CAML joins with readable C# syntax.</p>
<h2 id="performance-benefits">Performance Benefits</h2>
<p><strong>Before (Multiple queries):</strong></p>
<ul>
<li>🔴 6+ resource units</li>
<li>🔴 Multiple network calls</li>
<li>🔴 Complex C# merging logic</li>
</ul>
<p><strong>After (Single join):</strong></p>
<ul>
<li>✅ 2 resource units only</li>
<li>✅ One network call</li>
<li>✅ Server-side joining</li>
</ul>
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li><strong>Use joins instead of multiple queries</strong> to reduce resource consumption</li>
<li><strong>CAMLEX makes CAML readable</strong> for you and the next developer</li>
<li><strong>You can filter on joined data</strong> even multiple lists away</li>
<li><strong>SharePoint UI limitations ≠ API limitations</strong> - joins work even if the UI doesn&rsquo;t show it</li>
</ul>
<h2 id="common-issues--solutions">Common Issues &amp; Solutions</h2>
<p><strong>❌ &ldquo;List does not exist&rdquo; error</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-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// Use list GUIDs instead of names for reliability</span>
</span></span><span style="display:flex;"><span>.ForeignList(<span style="color:#66d9ef">new</span> Guid(<span style="color:#e6db74">&#34;12345678-1234-1234-1234-123456789012&#34;</span>))
</span></span></code></pre></div><p><strong>❌ &ldquo;Field not found&rdquo; error</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-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// Use internal field names, not display names</span>
</span></span><span style="display:flex;"><span>.ShowField(<span style="color:#e6db74">&#34;Title&#34;</span>)        <span style="color:#75715e">// ✅ Internal name</span>
</span></span><span style="display:flex;"><span>.ShowField(<span style="color:#e6db74">&#34;Customer Name&#34;</span>) <span style="color:#75715e">// ❌ Display name</span>
</span></span></code></pre></div><p><strong>❌ Join returns no data</strong></p>
<ul>
<li>Verify lookup columns exist and are properly configured</li>
<li>Check that you&rsquo;re joining on the correct fields</li>
<li>Ensure the lookup field contains valid IDs</li>
</ul>
<p><strong>❌ &ldquo;Cannot project this field type&rdquo; error</strong></p>
<p>Only specific field types can be included in ProjectedFields:</p>
<p>✅ Supported ProjectedFields types:</p>
<ul>
<li>Calculated (treated as plain text)</li>
<li>ContentTypeId</li>
<li>Counter</li>
<li>Currency</li>
<li>DateTime</li>
<li>Guid</li>
<li>Integer</li>
<li>Note (one-line only)</li>
<li>Number</li>
<li>Text</li>
</ul>
<p>❌ NOT supported in ProjectedFields:</p>
<ul>
<li>Multi-line text fields</li>
<li>Rich text fields</li>
<li>Choice fields</li>
<li>Lookup fields (use joins instead)</li>
<li>User/Person fields</li>
<li>Managed metadata fields</li>
</ul>
<p><strong>💡 Pro tip:</strong> If you need data from unsupported field types, query them separately after getting your joined results.</p>
<p>CAML joins are one of five techniques in <a href="https://jeppe-spanggaard.dk/blogs/sharepoint-csom-performance-playbook/">The SharePoint CSOM Performance Playbook</a>, which covers when a join is the right answer and when batching or change detection would serve you better.</p>
]]></content:encoded></item></channel></rss>