Real-world patterns (paging, batching, delta sync, throttling, large uploads, versions) for SharePoint Online via Microsoft Graph

This section is the “production glue” that turns nice demos into something you can run against real SharePoint Online libraries and lists at scale.


1) Paging: the rule that saves you from missing data

Microsoft Graph often returns partial results plus an @odata.nextLink. The only correct way to fetch the next page is to call the entire URL found in @odata.nextLink and treat it as an opaque string (don’t parse $skiptoken yourself). (Microsoft Learn)

Common places you’ll hit paging in SharePoint

  • /sites/{id}/lists
  • /lists/{id}/items
  • /drives/{id}/root/children
  • /drives/{id}/items/{itemId}/children

Minimal C# paging helper (raw HTTP, safest across SDK versions)

using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

public static class GraphPaging
{
    public static async Task<List<JsonElement>> GetAllPagesAsync(
        HttpClient http,
        string firstUrl,
        CancellationToken ct)
    {
        var results = new List<JsonElement>();
        string? url = firstUrl;

        while (!string.IsNullOrWhiteSpace(url))
        {
            using var req = new HttpRequestMessage(HttpMethod.Get, url);
            using var res = await http.SendAsync(req, ct);

            res.EnsureSuccessStatusCode();
            var json = await res.Content.ReadAsStringAsync(ct);
            using var doc = JsonDocument.Parse(json);

            if (doc.RootElement.TryGetProperty("value", out var value) && value.ValueKind == JsonValueKind.Array)
            {
                foreach (var item in value.EnumerateArray())
                    results.Add(item.Clone());
            }

            // Use the entire @odata.nextLink URL as-is :contentReference[oaicite:1]{index=1}
            url = doc.RootElement.TryGetProperty("@odata.nextLink", out var nextLink)
                ? nextLink.GetString()
                : null;
        }

        return results;
    }
}


2) Throttling & retries: how to survive 429/503 without drama

Microsoft explicitly recommends:

  • Honor Retry-After
  • Retry after the suggested delay
  • Keep retrying with the recommended delay until success (Microsoft Learn)

Production retry handler (C# DelegatingHandler)

Use this for raw HttpClient and also as the transport under Graph SDK (if you construct the SDK with your HttpClient).

using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

public sealed class GraphRetryHandler : DelegatingHandler
{
    private readonly int _maxRetries;

    public GraphRetryHandler(HttpMessageHandler innerHandler, int maxRetries = 8)
        : base(innerHandler)
    {
        _maxRetries = maxRetries;
    }

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
    {
        // Buffer the request content if present (so we can resend).
        // For large uploads, you typically use upload sessions instead (see section 5).
        HttpContent? bufferedContent = null;
        if (request.Content != null)
        {
            var bytes = await request.Content.ReadAsByteArrayAsync(ct);
            bufferedContent = new ByteArrayContent(bytes);
            foreach (var h in request.Content.Headers)
                bufferedContent.Headers.TryAddWithoutValidation(h.Key, h.Value);
        }

        for (int attempt = 0; attempt <= _maxRetries; attempt++)
        {
            using var cloned = CloneRequest(request, bufferedContent);

            var response = await base.SendAsync(cloned, ct);
            if (!IsThrottlingOrTransient(response.StatusCode))
                return response;

            var delay = GetRetryAfterDelay(response) ?? ComputeBackoff(attempt);
            response.Dispose();

            await Task.Delay(delay, ct);
        }

        // Final attempt (let it throw if it fails)
        using var last = CloneRequest(request, bufferedContent);
        return await base.SendAsync(last, ct);
    }

    private static bool IsThrottlingOrTransient(HttpStatusCode code)
        => code == (HttpStatusCode)429 || code == HttpStatusCode.ServiceUnavailable || code == HttpStatusCode.GatewayTimeout;

    private static TimeSpan? GetRetryAfterDelay(HttpResponseMessage response)
    {
        // Graph throttling guidance: use Retry-After when present :contentReference[oaicite:3]{index=3}
        if (response.Headers.TryGetValues("Retry-After", out var values))
        {
            var raw = values.FirstOrDefault();
            if (int.TryParse(raw, out var seconds))
                return TimeSpan.FromSeconds(Math.Max(1, seconds));
        }
        return null;
    }

    private static TimeSpan ComputeBackoff(int attempt)
    {
        // Exponential backoff with cap
        var seconds = Math.Min(60, Math.Pow(2, attempt));
        return TimeSpan.FromSeconds(seconds);
    }

    private static HttpRequestMessage CloneRequest(HttpRequestMessage original, HttpContent? bufferedContent)
    {
        var clone = new HttpRequestMessage(original.Method, original.RequestUri);

        // Copy headers
        foreach (var header in original.Headers)
            clone.Headers.TryAddWithoutValidation(header.Key, header.Value);

        clone.Version = original.Version;

        // Copy content (buffered)
        clone.Content = bufferedContent;

        return clone;
    }
}


3) Delta sync: incremental changes instead of “scan everything daily”

Delta query (“change tracking”) lets you sync only what changed since last run. (Microsoft Learn)

You typically do:

  1. Initial full enumeration via delta (no token)
  2. Keep calling @odata.nextLink until you get @odata.deltaLink
  3. Persist the deltaLink
  4. Next run: call that deltaLink directly to get only changes

A) driveItem delta for document libraries

  • Endpoint: GET /drives/{drive-id}/root/delta (or delta from a folder item)
  • It returns pages plus @odata.nextLink or final @odata.deltaLink (Microsoft Learn)

Important real-world behavior: delta can return duplicate entries; treat results as a change-log and dedupe by id, keeping the last occurrence for each item. (Microsoft Learn)

B) listItem delta for list items (and for library “fields”)

  • Endpoint: GET /sites/{site-id}/lists/{list-id}/items/delta
  • Same nextLink/deltaLink behavior (Microsoft Learn)

Key nuance for libraries: driveItem delta is great for file/folder changes, but if you need custom column values (“fields”), Microsoft guidance indicates using listItem delta to get column values. (Microsoft Learn)

Delta sync loop (C# raw HTTP, stores deltaLink)

using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

public static class GraphDeltaSync
{
    public static async Task RunDriveDeltaAsync(HttpClient http, string driveId, string stateFile, CancellationToken ct)
    {
        // 1) Use saved deltaLink if available; else start fresh
        string? url = File.Exists(stateFile)
            ? await File.ReadAllTextAsync(stateFile, ct)
            : $"https://graph.microsoft.com/v1.0/drives/{driveId}/root/delta";

        var allChanges = new List<JsonElement>();
        string? deltaLink = null;

        while (!string.IsNullOrWhiteSpace(url))
        {
            using var res = await http.GetAsync(url, ct);
            res.EnsureSuccessStatusCode();

            var json = await res.Content.ReadAsStringAsync(ct);
            using var doc = JsonDocument.Parse(json);

            if (doc.RootElement.TryGetProperty("value", out var value) && value.ValueKind == JsonValueKind.Array)
            {
                foreach (var item in value.EnumerateArray())
                    allChanges.Add(item.Clone());
            }

            // Continue paging until no nextLink; then capture deltaLink :contentReference[oaicite:9]{index=9}
            if (doc.RootElement.TryGetProperty("@odata.nextLink", out var next))
            {
                url = next.GetString();
                continue;
            }

            if (doc.RootElement.TryGetProperty("@odata.deltaLink", out var delta))
            {
                deltaLink = delta.GetString();
            }

            url = null;
        }

        // Dedupe by "id" keeping last occurrence (delta can include repeats) :contentReference[oaicite:10]{index=10}
        var latestById = new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
        foreach (var change in allChanges)
        {
            if (change.TryGetProperty("id", out var idProp))
            {
                var id = idProp.GetString() ?? "";
                if (!string.IsNullOrWhiteSpace(id))
                    latestById[id] = change;
            }
        }

        Console.WriteLine($"Changes returned: {allChanges.Count}");
        Console.WriteLine($"Distinct items changed: {latestById.Count}");

        // Persist deltaLink for next run
        if (!string.IsNullOrWhiteSpace(deltaLink))
        {
            await File.WriteAllTextAsync(stateFile, deltaLink, ct);
            Console.WriteLine("Saved deltaLink state.");
        }
    }
}


4) JSON batching: fewer roundtrips, faster “fan-out” reads

Graph supports JSON batching via POST /$batch:

Batch use cases in SharePoint

  • Fetch details for 20 list items by ID
  • Fetch 20 drive items metadata
  • Resolve multiple sites/lists in one round trip

Batch request example (raw HTTP)

using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

public static class GraphBatch
{
    public static async Task<string> SendBatchAsync(HttpClient http, CancellationToken ct)
    {
        // $batch supports up to 20 requests :contentReference[oaicite:13]{index=13}
        var payload = new
        {
            requests = new object[]
            {
                new { id = "1", method = "GET", url = "/sites?search=ExampleSite" },
                new { id = "2", method = "GET", url = "/sites/contoso.sharepoint.com:/sites/ExampleSite/lists" }
            }
        };

        var json = JsonSerializer.Serialize(payload);
        using var req = new HttpRequestMessage(HttpMethod.Post, "https://graph.microsoft.com/v1.0/$batch")
        {
            Content = new StringContent(json, Encoding.UTF8, "application/json")
        };

        using var res = await http.SendAsync(req, ct);
        res.EnsureSuccessStatusCode();
        return await res.Content.ReadAsStringAsync(ct);
    }
}


5) Large file uploads: stop trying to PUT huge files

For large files, use createUploadSession:

  • It creates a resumable session that supports chunk uploads and resume (Microsoft Learn)
  • The Graph SDKs have a built-in “large file upload task” helper (Microsoft Learn)

Endpoint

  • POST /drives/{drive-id}/items/{parent-id}:/path/filename:/createUploadSession (or similar forms) (Microsoft Learn)

What to implement

  1. Create upload session
  2. Upload byte ranges to the returned upload URL
  3. Handle resumable uploads and transient failures (your retry handler helps)

If you’re using the Graph SDK, Microsoft explicitly documents the SDK approach for large file upload. (Microsoft Learn)


6) Version history (SharePoint libraries): list and inspect versions

If versioning is enabled, you can list versions of a file:

Notes from docs:

  • Versions are returned newest → oldest
  • $orderby isn’t supported for this call (Microsoft Learn)

7) Practical endpoints table (advanced operations)

GoalEndpointWhy it matters
Paging guidance(concept) @odata.nextLinkMust call it as-is (Microsoft Learn)
Batch up to 20 requestsPOST /$batchFewer roundtrips (Microsoft Learn)
Drive delta syncGET /drives/{drive-id}/root/deltaIncremental file/folder sync (Microsoft Learn)
List item delta syncGET /sites/{site-id}/lists/{list-id}/items/deltaIncremental list sync (Microsoft Learn)
Query shaping$select, $expand, $filter, $topPerformance + reduced payload (Microsoft Learn)
Large upload sessionPOST /drives/{drive-id}/items/{id}/createUploadSessionChunking + resume (Microsoft Learn)
List file versionsGET /drives/{drive-id}/items/{item-id}/versionsVersion history (Microsoft Learn)

8) Full “production skeleton” console app (C#): auth + retry + paging + delta + batching hooks

This is a single-file starter you can expand. It intentionally uses placeholders and raw HTTP so it stays stable regardless of Graph SDK version changes.

using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Azure.Identity;

internal static class Program
{
    // ------------------------------------------------------------
    // CONFIG (placeholders)
    // ------------------------------------------------------------
    private const string TenantId = "YOUR_TENANT_ID";
    private const string ClientId = "YOUR_CLIENT_ID";
    private const string ClientSecret = "YOUR_CLIENT_SECRET";

    private static readonly string[] GraphScopes = new[] { "https://graph.microsoft.com/.default" };

    // Example targets (placeholders)
    private const string DriveId = "YOUR_DRIVE_ID";
    private const string DeltaStateFile = "drive-delta.state";

    public static async Task Main()
    {
        using var cts = new CancellationTokenSource();
        var ct = cts.Token;

        try
        {
            // 1) Create token credential
            var credential = new ClientSecretCredential(TenantId, ClientId, ClientSecret);

            // 2) Build HttpClient with retry handler (429/503 safe) :contentReference[oaicite:27]{index=27}
            using var http = CreateGraphHttpClient(credential);

            // 3) Example: Paging read (replace with a real URL you need)
            // Graph best practice: use nextLink as opaque :contentReference[oaicite:28]{index=28}
            var firstUrl = "https://graph.microsoft.com/v1.0/sites?search=ExampleSite";
            var pages = await GraphPaging.GetAllPagesAsync(http, firstUrl, ct);
            Console.WriteLine($"Paged results count: {pages.Count}");

            // 4) Example: Delta sync (drive) :contentReference[oaicite:29]{index=29}
            await GraphDeltaSync.RunDriveDeltaAsync(http, DriveId, DeltaStateFile, ct);

            // 5) Example: JSON batching :contentReference[oaicite:30]{index=30}
            var batchResponse = await GraphBatch.SendBatchAsync(http, ct);
            Console.WriteLine("Batch response (raw JSON):");
            Console.WriteLine(batchResponse);
        }
        catch (Exception ex)
        {
            Console.WriteLine("ERROR:");
            Console.WriteLine(ex);
        }
    }

    private static HttpClient CreateGraphHttpClient(ClientSecretCredential credential)
    {
        // Acquire token for Graph (.default)
        // Using TokenCredential-based auth keeps it consistent with modern guidance.
        var auth = new GraphAuthHandler(credential, GraphScopes)
        {
            InnerHandler = new GraphRetryHandler(new HttpClientHandler(), maxRetries: 8)
        };

        var http = new HttpClient(auth);
        http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
        return http;
    }
}

public sealed class GraphAuthHandler : DelegatingHandler
{
    private readonly Azure.Core.TokenCredential _credential;
    private readonly string[] _scopes;

    public GraphAuthHandler(Azure.Core.TokenCredential credential, string[] scopes)
    {
        _credential = credential;
        _scopes = scopes;
    }

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var token = await _credential.GetTokenAsync(
            new Azure.Core.TokenRequestContext(_scopes),
            cancellationToken);

        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
        return await base.SendAsync(request, cancellationToken);
    }
}

This skeleton intentionally composes:

  • GraphAuthHandler (adds bearer token)
  • GraphRetryHandler (handles throttling/transient retries with Retry-After) (Microsoft Learn)
  • Paging helper that respects @odata.nextLink as opaque (Microsoft Learn)
  • Delta sync loop that persists delta state (Microsoft Learn)

9) Recommended Microsoft Learn links (advanced “keep-open” set)

Core reliability

Query efficiency

Change tracking

Performance

Files


Summary tables

A) Step summary (what to implement in a real system)

StepImplementOutput
1Retry handler honoring Retry-AfterStable under throttling (Microsoft Learn)
2Paging loop using @odata.nextLink as-isNo missing records (Microsoft Learn)
3Delta sync with persisted deltaLinkFast incremental sync (Microsoft Learn)
4Dedupe delta changes by ID (keep last)Correct “latest state” (Microsoft Learn)
5Batch fan-out reads with /$batchFewer round trips (Microsoft Learn)
6Upload sessions for big filesReliable large uploads (Microsoft Learn)
7Version API usage where neededAuditable file history (Microsoft Learn)

B) Technical decision matrix (advanced operations)

RequirementPreferred techniqueEndpoint family
Tenant-scale readsPaging + batching@odata.nextLink, /$batch (Microsoft Learn)
Daily sync of a libraryDelta + dedupedriveItem: delta (Microsoft Learn)
Sync including custom columnslistItem deltalistItem: delta (Microsoft Learn)
High-throughput resilienceRetry-After + backoffThrottling guidance (Microsoft Learn)
Upload > small file sizeUpload session / SDK taskcreateUploadSession (Microsoft Learn)
Preserve/inspect versionsVersions API/versions (Microsoft Learn)
Edvaldo Guimrães Filho Avatar

Published by