CSOM Performance: Fast Taxonomy Loading for SharePoint List Items with Many Terms

Dec 21, 2025 min read

TL;DR

Problem: Loading taxonomy fields via CSOM for SharePoint list items with many terms is painfully slow. Including taxonomy fields in ViewFields makes the initial query extremely expensive.

Solution: Batch-load items in chunks, use FieldValuesForEdit to get raw taxonomy data, and parse the internal format directly instead of using TaxonomyFieldValue API calls.

Impact: ~70-75% performance improvement. Load times dropped from 90-120 seconds to 25-35 seconds for lists with many taxonomy terms.

The Magic:

// Step 1: Load items WITHOUT taxonomy fields in ViewFields (much faster)
CamlQuery query = new CamlQuery();
query.Query = @"
    <View>
        <ViewFields>
            <FieldRef Name='ID'/>
            <FieldRef Name='Title'/>
            <!-- NO taxonomy fields here! -->
        </ViewFields>
    </View>";

ListItemCollection items = list.GetItems(query);
clientContext.Load(items);
await clientContext.ExecuteQueryAsync();

// Step 2: Load taxonomy fields separately using FieldValuesForEdit
foreach (ListItem[] chunk in items.Cast<ListItem>().Chunk(100))
{
    var taxonomyData = await LoadTaxonomyField(chunk, "TaxonomyFieldInternalName");
}

⚠️ Important: This is an undocumented approach that works with SharePoint’s internal formats. Use with caution and test thoroughly.


When Fast Wasn’t Fast Enough

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.

The traditional CSOM approach was… well, let’s just say coffee breaks became very popular during load times.

The loading process was taking 90-120 seconds for a few hundred items. Users were literally walking away from their computers while waiting for data to load.

That’s when I realized we needed to completely rethink how we approach taxonomy loading in CSOM.

The Traditional (Slow) Way

Here’s what most developers do, and what I was doing initially:

// The traditional approach - including taxonomy fields in ViewFields
CamlQuery query = new CamlQuery();
query.Query = @"
    <View>
        <ViewFields>
            <FieldRef Name='ID'/>
            <FieldRef Name='Title'/>
            <FieldRef Name='CategoryField'/>     <!-- Taxonomy field -->
            <FieldRef Name='TagField'/>          <!-- Taxonomy field -->
            <FieldRef Name='OtherTaxField'/>     <!-- Taxonomy field -->
        </ViewFields>
    </View>";

ListItemCollection items = list.GetItems(query);
clientContext.Load(items);
await clientContext.ExecuteQueryAsync();

// Then access taxonomy fields normally
foreach (ListItem item in items)
{
    var categoryField = item["CategoryField"] as TaxonomyFieldValueCollection;
    var tagField = item["TagField"] as TaxonomyFieldValueCollection;
    // Process taxonomy values...
}

Problems with this approach:

  • Including taxonomy fields in ViewFields is extremely slow
  • SharePoint has to load and process all taxonomy data upfront
  • Multiple expensive taxonomy service calls during the initial load
  • No control over how taxonomy data is loaded

Result: Coffee break time. Lots of it.

The Optimization Journey

After digging into SharePoint’s internals and some experimentation, I discovered two key things:

  1. Including taxonomy fields in ViewFields is what kills performance. SharePoint has to load and process all taxonomy data during the initial query, which is extremely slow.

  2. SharePoint stores taxonomy data in a raw format that can be accessed via FieldValuesForEdit and parsed directly. This bypasses the expensive taxonomy service calls entirely.

The breakthrough: 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 FieldValuesForEdit approach.

Key Insights:

  1. Exclude taxonomy fields from ViewFields - This is the biggest performance gain. Load regular fields first, taxonomy fields separately.
  2. FieldValuesForEdit contains raw taxonomy data in the format: Label|GUID;Label|GUID - much faster to parse than TaxonomyFieldValue API
  3. Batch loading is crucial - load all items’ FieldValuesForEdit in one call per chunk
  4. Chunking prevents timeouts - process items in manageable batches
  5. Dictionary lookups are fast - map terms back to items efficiently

The Optimized Solution

Here’s the approach that cut our load times by 70-75%:

Main Loading Logic

public async Task<List<MyItemDTO?>?> GetItemsByOptions(string[] options)
{
    // Step 1: Load list items WITHOUT taxonomy fields in ViewFields
    // This is much faster than including taxonomy fields in the initial query
    List list = clientContext.Web.Lists.GetByTitle("YourListName");
    
    CamlQuery query = new CamlQuery();
    query.Query = @"
        <View>
            <ViewFields>
                <FieldRef Name='ID'/>
                <FieldRef Name='Title'/>
                <FieldRef Name='OtherRegularFields'/>
                <!-- Notice: NO taxonomy fields here -->
            </ViewFields>
            <Query>
                <!-- Your where clause here -->
            </Query>
        </View>";
    
    ListItemCollection items = list.GetItems(query);
    clientContext.Load(items);
    await clientContext.ExecuteQueryAsync();

    if (items == null || items.Count == 0)
        return null;

    // Step 2: Process in chunks and load taxonomy fields separately
    List<MyItemDTO> result = new List<MyItemDTO>();
    
    foreach (ListItem[] chunk in items.Cast<ListItem>().Chunk(100))
    {
        // Load taxonomy fields separately using FieldValuesForEdit
        var categoryTerms = await LoadTaxonomyField(chunk, "CategoryField");
        var tagTerms = await LoadTaxonomyField(chunk, "TagField");

        // Step 3: Map everything together
        foreach (var item in chunk)
        {
            var dto = new MyItemDTO
            {
                Id = item.Id,
                Title = item["Title"]?.ToString(),
                // Map regular fields normally
            };

            // Add taxonomy terms from our separate loading
            if (categoryTerms.TryGetValue(item.Id.ToString(), out var terms))
                dto.CategoryTerms = terms;
                
            if (tagTerms.TryGetValue(item.Id.ToString(), out var tags))
                dto.TagTerms = tags;

            result.Add(dto);
        }
    }

    return result;
}

Batch Taxonomy Loading

private async Task<Dictionary<string, List<TaxonomyTerm>>> LoadTaxonomyField(
    IEnumerable<ListItem> items, 
    string taxonomyFieldInternalName)
{
    // Load FieldValuesForEdit for all items in one batch
    foreach (var item in items)
    {
        clientContext.Load(item, i => i.FieldValuesForEdit);
    }
    await clientContext.ExecuteQueryRetryAsync();

    // Get the field ID for internal format matching
    var list = clientContext.Web.Lists.GetById(listGuid);
    var field = list.Fields.GetFieldByInternalName(taxonomyFieldInternalName);
    clientContext.Load(field);
    await clientContext.ExecuteQueryRetryAsync();

    string fieldId = field.Id.ToString();

    // Parse taxonomy data from each item's FieldValuesForEdit
    return items.ToDictionary(
        item => item.Id.ToString(),
        item => ParseTaxonomyFromEditValues(item, fieldId)
    );
}

private List<TaxonomyTerm> ParseTaxonomyFromEditValues(ListItem item, string fieldId)
{
    // Find the correct field key (SharePoint uses shortened GUIDs)
    var fieldValues = item.FieldValuesForEdit.FieldValues
        .Where(x => x.Key.EndsWith(fieldId.Replace("-", "").Substring(4)));
    
    if (!fieldValues.Any())
        return new List<TaxonomyTerm>();

    string actualFieldKey = fieldValues.First().Key;
    
    if (!item.FieldValuesForEdit.FieldValues.ContainsKey(actualFieldKey))
        return new List<TaxonomyTerm>();

    // Parse the raw taxonomy string
    string rawTaxonomyData = item.FieldValuesForEdit[actualFieldKey];
    return ParseTaxonomyEditString(rawTaxonomyData);
}

Raw Format Parser

private List<TaxonomyTerm> ParseTaxonomyEditString(string editString)
{
    if (string.IsNullOrWhiteSpace(editString))
        return new List<TaxonomyTerm>();

    // Format: "Label1|GUID1;Label2|GUID2;Label3|GUID3"
    return editString
        .Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries)
        .Select(entry => 
        {
            var parts = entry.Split('|');
            if (parts.Length == 2 && Guid.TryParse(parts[1], out Guid guid))
            {
                return new TaxonomyTerm 
                { 
                    Label = parts[0].Trim(), 
                    TermGuid = guid 
                };
            }
            return null;
        })
        .Where(t => t != null)
        .Select(t => t!)
        .ToList();
}

public class TaxonomyTerm
{
    public string Label { get; set; }
    public Guid TermGuid { get; set; }
}

Performance Impact

The results were dramatic:

Before optimization:

  • 90-120 seconds for a few hundred items with multiple taxonomy fields
  • Multiple ExecuteQueryRetryAsync() calls per item
  • Heavy taxonomy service usage

After optimization:

  • 25-35 seconds for the same dataset
  • ~70-75% performance improvement
  • Minimal API calls (2 per chunk: one for items, one for field metadata)

The exact savings depend on the number of items and terms, but the pattern held consistently across different datasets.

Should You Try This Approach?

If you’re experiencing slow loading times with taxonomy fields, this might be worth testing.

I can’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.

My recommendation:

  • If your current taxonomy loading is painfully slow, try this approach in a test environment
  • Measure the before and after performance in your specific scenario
  • Keep the complexity vs. benefit trade-off in mind
  • Always have a fallback to the traditional approach

Remember the limitations:

  • This is an undocumented approach that works with SharePoint’s internal formats
  • You only get basic term information (label + GUID)
  • You’ll need to test thoroughly in your environment

The traditional CSOM approach works fine for many scenarios. But if you’re hitting performance walls with taxonomy fields, this optimization might be exactly what you need.

Important Disclaimers

⚠️ This is an undocumented approach that relies on SharePoint’s internal storage format for taxonomy fields. While it has worked reliably in my experience, it’s not officially supported by Microsoft.

Key Takeaways

  1. Batch operations are crucial - Load multiple items’ data in single API calls
  2. Chunking prevents timeouts - Process large datasets in manageable pieces
  3. Raw formats can be faster - Sometimes bypassing official APIs improves performance
  4. Know when to optimize - Don’t add complexity unless you really need the performance
  5. Document your risks - Be transparent about using undocumented approaches

The traditional CSOM taxonomy approach works fine for simple scenarios. But when you’re dealing with lots of items and lots of terms, sometimes you need to think outside the box.

Jeppe Spanggaard

A passionate software developer. I love to build software that makes a difference!