I’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.
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 Service Prioritization in SharePoint, where every CSOM call has an actual price tag: USD $1.00 per 1,000 calls. Suddenly all those wasted calls weren’t just slow. They were an invoice.
That changed how I write CSOM. I count round trips now, the way you’d count database queries in a hot loop. Over the past year I’ve written several posts about the specific techniques that came out of that habit, and this post ties them together into one playbook.
TL;DR
Five rules cover most CSOM performance problems. Batch up to 100 operations before each ExecuteQueryAsync() instead of executing one by one. Query related lists with CAML joins instead of one query per list. Compare values before updating, because 60-80% of “save” clicks change nothing. Move try-catch logic to the server with ExceptionHandlingScope so a fallback doesn’t cost an extra round trip. And keep taxonomy fields out of your ViewFields, loading them separately via FieldValuesForEdit.
Each rule has its own detailed post with full code. This one gives you the map.
Every ExecuteQuery Is a Road Trip
Here’s the mental model that makes all four techniques click: every ExecuteQueryAsync() is a road trip to the SharePoint server. Doesn’t matter if you’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.
So the whole playbook boils down to four questions:
- Can I deliver more packages per trip? (batching)
- Can one trip cover several destinations? (CAML joins)
- Is this trip even necessary? (change detection)
- Can I avoid a second trip when something goes wrong? (ExceptionHandlingScope, and the taxonomy trick)
Let’s take them one at a time.
Rule 1: Batch or Suffer
The most common sin. A loop that loads items one by one:
// ❌ 50 items = 50 round trips = 3-5 seconds
foreach (var id in itemIds)
{
var item = list.GetItemById(id);
context.Load(item);
await context.ExecuteQueryAsync();
ProcessItem(item);
}
CSOM happily queues up operations until you call ExecuteQueryAsync(). Around 100 operations per batch is the reliable sweet spot:
// ✅ 50 items = 1 round trip = ~200-500ms
var items = chunk.Select(id => {
var item = list.GetItemById(id);
list.Context.Load(item);
return item;
}).ToList();
await context.ExecuteQueryAsync();
In my testing, that’s a 10x+ improvement for the price of restructuring a loop. It works for reads, writes, deletes, all of it.
Full post with the reusable ProcessInChunks helper: CSOM Performance Optimization: Why You Should Batch Your SharePoint Operations.
Rule 2: Join Lists, Don’t Query Them One by One
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 2 resource units toward your throttling budget, so three lists cost 6 units plus three round trips plus the merging code nobody wants to maintain.
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 CAMLEX instead of raw CAML XML because lambda expressions are readable and the XML it generates is not:
var query = Camlex.Query()
.Where(x => (string)x["CustomerName"] == "Contoso") // filter on a field 2 lists away!
.LeftJoin(x => x["OrderTaskLookUp"].ForeignList(ListGuidOrderTasks))
.LeftJoin(x => x["OrderDetailLookUp"].PrimaryList(ListGuidOrderTasks).ForeignList(ListGuidOrders))
.LeftJoin(x => x["CustomerLookUp"].PrimaryList(ListGuidOrders).ForeignList(ListGuidCustomers))
.ProjectedField(x => x["CustomerName"].List(ListGuidCustomers).ShowField("CustomerName"));
var camlQuery = new CamlQuery { ViewXml = query.ToString() };
var items = ordersList.GetItems(camlQuery);
That’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).
Full post with the raw CAML comparison and the field type list: Efficient Multi-List Queries in CSOM: Using CAML Joins with CAMLEX.
Rule 3: Did Anything Actually Change?
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, roughly 70% of our SharePoint update calls weren’t changing anything. SharePoint accepts identical data with a smile and bills you for the privilege.
The fix is change detection before the update:
var differences = GetDifferences(listItem, newValues, treatEmptyStringAsNull: true);
if (differences.Any())
{
foreach (var (fieldName, newValue) in differences)
{
listItem[fieldName] = newValue;
}
listItem.Update();
clientContext.ExecuteQuery();
}
// Nothing changed? Do absolutely nothing.
The hard part is the comparison itself. SharePoint field types fight you: user fields where only LookupId matters, multi-choice fields that come back in random order, taxonomy values, dates with kind mismatches. Naive oldValue == newValue doesn’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.
Full post with the complete comparison engine: SharePoint CSOM: Prevent Unnecessary Updates and API Calls.
Rule 4: Let the Server Do the Catching
This one came straight out of that Service Prioritization cost analysis. The client’s solution made about 90,000 CSOM calls per month, and 88% of them were EnsureUser calls for users who were already on the site. 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.
ExceptionHandlingScope lets you ship try-catch logic to the server and resolve it in one round trip:
var scope = new ExceptionHandlingScope(clientContext);
using (scope.StartScope())
{
using (scope.StartTry())
{
// Optimistic: works for users already known to the site
listItem["AssignedTo"] = FieldUserValue.FromUser("user@company.com");
listItem.Update();
}
using (scope.StartCatch())
{
// Fallback: only runs server-side if the try failed
var user = clientContext.Web.EnsureUser("user@company.com");
listItem["AssignedTo"] = FieldUserValue.FromUser("user@company.com");
}
}
clientContext.ExecuteQuery(); // one trip
Result for that client: a 75-85% reduction in monthly calls. One honest caveat, and it’s important: the scope reduces round trips, not server requests. Each operation inside still counts toward throttling. The call reduction came from the optimistic-first logic, and ExceptionHandlingScope is what made that logic affordable.
Full post with the cost breakdown: SharePoint’s Server-Side Try-Catch: ExceptionHandlingScope.
Rule 5: Keep Taxonomy Out of Your ViewFields
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.
The fix has two parts. First, query the list without the taxonomy fields in ViewFields, which is where most of the cost hides. Then load the taxonomy data separately through FieldValuesForEdit, where SharePoint stores it as a raw string you can parse directly:
// Raw format in FieldValuesForEdit: "Label1|GUID1;Label2|GUID2"
foreach (ListItem[] chunk in items.Cast<ListItem>().Chunk(100))
{
foreach (var item in chunk)
{
clientContext.Load(item, i => i.FieldValuesForEdit);
}
await clientContext.ExecuteQueryAsync();
// parse Label|GUID pairs per item
}
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’s been reliable for me, but test it in your environment and keep the traditional approach as a fallback.
Full post with the parser: CSOM Performance: Fast Taxonomy Loading for SharePoint List Items with Many Terms.
Which One First?
If your workload is write-heavy (forms, syncs, integrations), start with change detection. It’s the only technique that eliminates entire operations instead of making them cheaper, and it’s invisible to users.
If your workload is read-heavy, start with batching. It’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.
ExceptionHandlingScope is for when a specific fallback pattern (like EnsureUser) 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.
The good news is they stack. Batched updates that skip unchanged items, with server-side fallbacks, is exactly how my current projects run.
Gotchas
- 100 operations per batch is the sweet spot. CSOM reliably handles around that many. Bigger batches mean bigger payloads and less predictable behavior across environments. Ask me how I know.
- ExceptionHandlingScope doesn’t reduce throttling pressure by itself. 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.
- Your numbers will differ from mine. 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.
- Test under throttling before you trust any of it. Dev Proxy can simulate 429 responses locally so you find out how your batches behave under pressure before production does.
- The taxonomy trick gives you label and GUID, nothing more. If you need full term paths or custom properties, you’re back to the taxonomy API for those items.
- Joins need lookup columns and GUIDs. CAML joins only work across lists connected by lookup columns, and referencing lists by GUID instead of title saves you from “list does not exist” surprises.
If you’re also downloading files in the same solution, the round-trip mindset applies there too - I’ve covered that in downloading multiple files from SharePoint.
Wrapping Up
Count your round trips before SharePoint counts them for you. That’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).
Each technique stands on its own, so grab the one that matches your bottleneck:
- Batch your SharePoint operations
- Join multiple lists in one CAML query
- Prevent unnecessary updates
- Server-side try-catch with ExceptionHandlingScope
- Fast taxonomy loading
