Updating a thousand SharePoint list items one request at a time is slow and burns through API throttling limits fast. PnPjs batching bundles multiple operations into a single HTTP call instead — here’s the syntax for both the older and current versions of the library, and the real limit worth knowing before you rely on it.
In this post: Old vs. new PnPjs batching syntax · A React example · The real batch size limit · Tradeoffs · Handling per-item failures · What a batch actually looks like on the wire · When would you actually use this? · Related reading
Old vs. new PnPjs batching syntax
| Feature | Older PnPjs (v2 and below) | Current PnPjs (v3+) |
|---|---|---|
| Batching syntax | sp.createBatch() | spfi().usingBatch() |
| Execution style | Manual .execute() call required | Automatic once the batched function’s promises resolve |
| Adding to the batch | Explicit .inBatch(batch) per call | Implicit — everything inside the batched instance is included |
Older PnPjs (v2 and below):
import { sp } from "@pnp/sp";
async function batchUpdateOld() {
const batch = sp.createBatch();
const list = sp.web.lists.getByTitle("MyList");
for (let i = 1; i <= 5; i++) {
list.items.getById(i).inBatch(batch).update({ Title: `Updated Item ${i}` });
}
await batch.execute();
console.log("Batch update complete!");
}
Current PnPjs (v3+):
import { spfi } from "@pnp/sp";
import "@pnp/sp/items";
async function batchUpdateNew() {
const sp = spfi().usingBatch();
const list = sp.web.lists.getByTitle("MyList");
for (let i = 1; i <= 5; i++) {
await list.items.getById(i).update({ Title: `Updated Item ${i}` });
}
console.log("Batch update complete!");
}
The v3 version reads like it's making 5 sequential awaited calls, but it isn't -- everything issued against a .usingBatch() instance gets queued and sent together, with the individual awaits resolving once the batch response comes back. It's cleaner to read but easy to misjudge if you're not aware of what's happening underneath.
A React example
import React, { useEffect } from "react";
import { spfi } from "@pnp/sp";
import "@pnp/sp/items";
const BatchUpdateComponent = () => {
useEffect(() => {
async function updateItems() {
const sp = spfi().usingBatch();
const list = sp.web.lists.getByTitle("MyList");
for (let i = 1; i <= 5; i++) {
await list.items.getById(i).update({ Title: `Updated Item ${i}` });
}
console.log("Batch update completed in React!");
}
updateItems();
}, []);
return Updating SharePoint List...;
};
export default BatchUpdateComponent;
The real batch size limit
SharePoint's REST $batch endpoint caps out at 100 requests per batch -- worth knowing as an actual number rather than a vague "you might hit limits." You don't have to manage this split yourself, though: PnPjs handles it internally. Queue 1,000 operations into one batch instance and execute it, and the SDK automatically breaks it into 10 sub-batches of 100, sent sequentially as separate network calls -- not one giant request, and not 1,000 individual ones either. That sequential (not parallel) execution of sub-batches is the real performance ceiling worth knowing about: batching helps enormously going from 1,000 requests to 10, but it isn't free past that point.
SharePoint's REST $batch endpoint caps out at 100 requests per batch -- PnPjs auto-splits anything larger into sequential sub-batches, not one giant request.
Tradeoffs
Batching genuinely reduces network round-trips and helps you stay under SharePoint's API throttling thresholds -- real, meaningful wins for bulk operations. The cost: when one operation inside a batch fails, tracing exactly which one and why is harder than debugging a single isolated request, since the response comes back as one bundled payload covering everything in the batch. Build in per-item error checking on the response rather than assuming an overall success means every individual operation succeeded.
Handling per-item failures
The "build in per-item error checking" advice above needs actual code to be useful. Since each await inside a batched call either resolves or throws on its own, wrapping each one individually still works the same way it would outside a batch -- PnPjs surfaces per-request failures as an HttpRequestError, so a plain try/catch around each item catches exactly the item that failed without aborting the ones that didn't:
import { spfi } from "@pnp/sp";
import "@pnp/sp/items";
async function batchUpdateWithErrorTracking(itemIds: number[]) {
const sp = spfi().usingBatch();
const list = sp.web.lists.getByTitle("MyList");
const failures: { id: number; error: unknown }[] = [];
for (const id of itemIds) {
try {
await list.items.getById(id).update({ Title: `Updated Item ${id}` });
} catch (err) {
failures.push({ id, error: err });
}
}
if (failures.length > 0) {
console.error(`${failures.length} of ${itemIds.length} updates failed:`, failures);
}
return failures;
}
Returning the failure list (instead of just logging it) means a caller can retry just the failed IDs, rather than re-running the whole batch and redoing work that already succeeded -- worth doing for anything migration- or import-sized, where re-processing thousands of already-correct items just to retry a handful of failures wastes both time and throttling budget.
What a batch actually looks like on the wire
PnPjs hides this entirely, which is exactly why a batch that fails with an unhelpful error is confusing -- there's no obvious place to look. Underneath, SharePoint's REST $batch endpoint expects the request body as MIME type multipart/mixed, split into parts by a boundary string, where each write operation is itself wrapped in a nested changeset -- also multipart/mixed, with its own separate boundary. Two of the most common causes of an opaque 400 on a batch that looks correct at the PnPjs level both live in that structure: a changeset's Content-Type header has to read exactly multipart/mixed, not application/http (that value is only correct for the outer batch, not the inner changeset), and each part inside a changeset needs its own Content-Transfer-Encoding: binary header or the request gets rejected before SharePoint even evaluates the actual operations inside it.
None of this needs to be hand-built when using PnPjs -- the library generates it correctly. It's worth knowing when a batch call fails with a generic 400 and no per-item detail, since that's a structural rejection of the whole envelope, not a failure of any individual operation inside it -- the per-item try/catch pattern above won't catch it, because the request never got far enough to process individual items at all.
When would you actually use this?
- A bulk update or import needs to touch hundreds or thousands of list items -- batching is the difference between a script that finishes in seconds and one that gets throttled partway through.
- You're migrating data into SharePoint and want to minimize the number of round-trips during the transfer.
- A React component needs to update several related items on load or on a user action, without firing off a pile of uncoordinated individual requests.
Related reading
- Microsoft Graph: Everything You Need to Know -- covers Graph's own
$batchendpoint, capped at 20 requests instead of SharePoint REST's 100. - SharePoint REST API File Uploads Using JavaScript and PnPjs -- another PnPjs use case, including chunked uploads for large files.
That covers both syntax versions and the real limit behind them. Got a batching edge case? Drop it in the comments.
App Catalog Authentication Automation Backup Compliance Content Type CSS Flows Google Javascript Limitations List Metadata MFA Microsoft Node NodeJs O365 OneDrive Permissions PnP PnPJS Policy PowerApps Power Automate PowerAutomate PowerPlatform PowerShell React ReactJs Rest API Rest Endpoint Security Send an HTTP Request to SharePoint SharePoint SharePoint List SharePoint Modern SharePoint Online SPFX SPO Sync Tags Teams Termstore Versioning


