A REST write over 2 MB fails with "The request message is too big"
SharePoint Online caps the body of one REST request at 2,097,152 bytes. Anything that grows with the tenant (snapshots, imports, JSON blobs in a list item) must be measured in body bytes and split into parts before it hits the cap.
SharePoint Online přijme v jednom REST požadavku nejvýš 2 097 152 bajtů těla. Co roste s velikostí tenantu (snímky, importy, JSON v položce), měř v bajtech těla a před stropem rozlož do částí.
Symptom
Creating or updating a list item with a large multi-line text value returns HTTP 400:
{"odata.error":{"code":"-1, Microsoft.SharePoint.Client.InvalidClientQueryException",
"message":{"lang":"en-US","value":"The request message is too big. The server does not allow messages larger than 2097152 bytes."}}}
It works in development and breaks only on a big tenant, because the payload (an inventory snapshot, a pasted PowerShell export) grows with the number of sites.
Cause
The limit is on the HTTP request body, not on the field. Measured on SharePoint Online (October 2026):
| Value written to a multi-line text field in a list | Result |
|---|---|
| 1.5 M characters (ASCII) | 201 Created, read back 1,536,000 characters |
| 2 MB / 8 MB | 400 The request message is too big |
So the often-quoted 63,999-character limit is not what stops you in a list – the 2 MB request cap is. The cap counts bytes after serialization: the value is JSON-escaped inside the request body (every " becomes \") and encoded as UTF-8 (accented letters take 2 bytes, many symbols 3). A 1 M-character JSON string with diacritics can easily exceed 2 MB.
Fix
Measure the real body size, and above a safe threshold store a header item plus parts in a separate list:
const SINGLE_MAX_BYTES = 1500000; // safely under 2,097,152
const PART_CHARS = 300000; // one part stays under the cap even at 3 bytes/char + escaping
function bodyBytes(json: string): number {
return new Blob([JSON.stringify(json)]).size; // size of the value as it travels in the body
}
if (bodyBytes(json) <= SINGLE_MAX_BYTES) {
await post(listUrl, { Title, Payload: json, Status: 'done' });
} else {
const head = await post(listUrl, { Title, Payload: '', Status: 'running' }); // hidden from readers
try {
const parts = split(json, PART_CHARS); // don't split a surrogate pair
for (let i = 0; i < parts.length; i++) await post(partsUrl, { ParentId: head.Id, PartIndex: i + 1, Payload: parts[i] });
await merge(itemUrl(head.Id), { Parts: parts.length, Status: 'done' }); // confirm only at the end
} catch (e) {
await deletePartsAndHeader(head.Id); // no half-written record
throw e;
}
}
Reading is strict: load all parts for the header, and if their count differs from Parts, fail instead of concatenating what you got – half a JSON either fails to parse (the lucky case) or parses into a record that silently lacks data.
Notes
- A separate parts list (rather than extra rows in the main list) keeps parts out of every view and query of the main list – nobody can forget to filter them out.
MERGEhas the same cap: rewriting a large record in place fails the same way, so updates need the same split path (and must delete parts left over from a previous, larger version).- Don't store raw user input you only parse later (for example a pasted
Get-SPOSite | ConvertTo-Jsonexport) – keep only the fields you read. It keeps configuration items far below the cap. - Split on a character boundary that does not cut a UTF-16 surrogate pair in half.