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:
- Initial full enumeration via
delta(no token) - Keep calling
@odata.nextLinkuntil you get@odata.deltaLink - Persist the
deltaLink - Next run: call that
deltaLinkdirectly 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.nextLinkor 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:
- Up to 20 requests per batch (Microsoft Learn)
- Responses are per-request (not atomic transaction) (Microsoft Learn)
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
- Create upload session
- Upload byte ranges to the returned upload URL
- 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:
GET /drives/{drive-id}/items/{item-id}/versions(Microsoft Learn)
Notes from docs:
- Versions are returned newest → oldest
$orderbyisn’t supported for this call (Microsoft Learn)
7) Practical endpoints table (advanced operations)
| Goal | Endpoint | Why it matters |
|---|---|---|
| Paging guidance | (concept) @odata.nextLink | Must call it as-is (Microsoft Learn) |
| Batch up to 20 requests | POST /$batch | Fewer roundtrips (Microsoft Learn) |
| Drive delta sync | GET /drives/{drive-id}/root/delta | Incremental file/folder sync (Microsoft Learn) |
| List item delta sync | GET /sites/{site-id}/lists/{list-id}/items/delta | Incremental list sync (Microsoft Learn) |
| Query shaping | $select, $expand, $filter, $top | Performance + reduced payload (Microsoft Learn) |
| Large upload session | POST /drives/{drive-id}/items/{id}/createUploadSession | Chunking + resume (Microsoft Learn) |
| List file versions | GET /drives/{drive-id}/items/{item-id}/versions | Version 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.nextLinkas opaque (Microsoft Learn)- Delta sync loop that persists delta state (Microsoft Learn)
9) Recommended Microsoft Learn links (advanced “keep-open” set)
Core reliability
- Throttling guidance (Retry-After, 429/503) (Microsoft Learn)
- Best practices (includes paging + throttling pointers) (Microsoft Learn)
- Paging Graph data (
@odata.nextLink) (Microsoft Learn)
Query efficiency
- Query parameters (
$select,$filter,$expand, etc.) (Microsoft Learn)
Change tracking
- Delta query overview (concepts) (Microsoft Learn)
- driveItem delta (files/folders) (Microsoft Learn)
- listItem delta (list/library items) (Microsoft Learn)
Performance
- JSON batching (rules + 20-request limit) (Microsoft Learn)
- SDK batching guide (if you use the SDK) (Microsoft Learn)
Files
- createUploadSession (resumable uploads) (Microsoft Learn)
- Large file upload with Graph SDKs (Microsoft Learn)
- List versions (version history) (Microsoft Learn)
Summary tables
A) Step summary (what to implement in a real system)
| Step | Implement | Output |
|---|---|---|
| 1 | Retry handler honoring Retry-After | Stable under throttling (Microsoft Learn) |
| 2 | Paging loop using @odata.nextLink as-is | No missing records (Microsoft Learn) |
| 3 | Delta sync with persisted deltaLink | Fast incremental sync (Microsoft Learn) |
| 4 | Dedupe delta changes by ID (keep last) | Correct “latest state” (Microsoft Learn) |
| 5 | Batch fan-out reads with /$batch | Fewer round trips (Microsoft Learn) |
| 6 | Upload sessions for big files | Reliable large uploads (Microsoft Learn) |
| 7 | Version API usage where needed | Auditable file history (Microsoft Learn) |
B) Technical decision matrix (advanced operations)
| Requirement | Preferred technique | Endpoint family |
|---|---|---|
| Tenant-scale reads | Paging + batching | @odata.nextLink, /$batch (Microsoft Learn) |
| Daily sync of a library | Delta + dedupe | driveItem: delta (Microsoft Learn) |
| Sync including custom columns | listItem delta | listItem: delta (Microsoft Learn) |
| High-throughput resilience | Retry-After + backoff | Throttling guidance (Microsoft Learn) |
| Upload > small file size | Upload session / SDK task | createUploadSession (Microsoft Learn) |
| Preserve/inspect versions | Versions API | /versions (Microsoft Learn) |
