Optimizely CMS 13.3: Graph Full Sync Is Up to 3.5× Faster
A full content synchronization to Optimizely Graph is the kind of job you schedule for the night and check in the morning. On a large installation it is measured in hours, and the usual way to make it faster (more batch concurrency, bigger batches) stops helping surprisingly early.
CMS / Graph 13.3.0, published on the Optimizely NuGet feed on 5 October 2026, changes that. I measured the new synchronization pipeline on an early build that Optimizely shared before the release, against 13.2.0, on the same copy of a large production database. Then I compared the shipped 13.3.0 assemblies with that build, type by type: the full-sync producer, loading and mapping code is identical, and the only addition on that path in 13.3.0 is the access-rights check described in section 4.
The results:
- up to 3.5× faster full sync: 3.4× on the means for a large site section, 2.1× on a smaller site.
- On an image-heavy DAM section: from roughly 250-300 documents per minute to roughly 2,000.
- SQL: more than a third less of everything, queries, reads and CPU.
This post is the measurement, the method, what changed in Graph and what else the package brings.
1. What a full sync does
For every indexing root, a full synchronization roughly:
- walks the content tree: children of every node, recursively,
- for each node it finds, lists the versions and filters them by the configured version mode,
- loads and maps each version to a Graph document (properties, content areas, embedded blocks, metadata),
- sends documents to Graph in batches.
In 13.2.0 steps 1 and 2 ran in a single producer: one node after another. The sending side could be made as concurrent as you liked. It didn’t help. It was waiting for the producer. 13.3.0 parallelizes exactly that part.
2. Method
Data. A restored copy of a large production database: roughly 700,000 content items and several million content versions. Before every run I restored a database snapshot, cleared the SQL Server buffer pool with DBCC DROPCLEANBUFFERS, cleared the plan cache and Query Store. Same bytes and a cold cache, every time.
Builds. A = 13.2.0, B = the early build of the new pipeline. Both Release, published to clean folders. After aligning everything outside source control, the two output folders differed only in the Optimizely assemblies. I checked that with a script before each series.
Two Graph targets.
- A local Graph stand-in: an HTTP endpoint that accepts batches and records every document. It measures the CMS side without the network and lets me compare A and B document by document.
- For end-to-end times, a real Optimizely Graph test tenant.
Configuration. DraftAndPublishedOnly version mode unless stated otherwise, text extraction from media off, event indexing off, BatchMaxCount at the default 100. The new build reported its effective concurrency at startup: batch concurrency 18, content listing concurrency 4, on a 12-core machine.
Machine. 12 logical cores, 32 GB RAM, SQL Server 2022 on the same machine.
Recorded. Job duration from the job’s own log, documents sent, Query Store executions, logical reads and CPU per query, machine and application CPU.
Your numbers will depend on database latency, on the shape of your tree and on the cores you have, so the method is above and you can repeat it on your own copy of your own database.
3. Results
Large site section, about 90,000 content items, real Graph (3 + 3 runs)
| run 1 | run 2 | run 3 | mean | |
|---|---|---|---|---|
| A (13.2.0) | 3,428.6 s | 4,725.4 s | 4,861.9 s | 4,338.6 s (72 min) |
| B (new pipeline) | 1,481.7 s | 1,165.1 s | 1,138.7 s | 1,261.8 s (21 min) |
The mean is 3.4× faster; individual runs ranged from 2.3× to 4.3×. Both builds sent the same number of documents.
Smaller site, about 8,000 content items
| target | A | B | speedup | runs |
|---|---|---|---|---|
| local stand-in | 575.5 s (mean) | 273.1 s (mean) | 2.1× | 3 + 3 |
| real Graph | 1,038.0 s | 503.0 s | 2.1× | 1 + 1 |
| real Graph, all versions mode | 1,294.3 s | 764.2 s | 1.7× | 1 + 1 |
Where the SQL time went (smaller site, stand-in, Query Store)
| A | B | change | |
|---|---|---|---|
| query executions | 1,257,525 | 805,518 | −36% |
| logical reads | 31.7 M | 19.8 M | −38% |
| SQL CPU | 337.8 s | 204.5 s | −39% |
| loads of properties of a specific version | 28,703 | 369 | −98% |
The last line is not the parallel producer. It is a separate change in how embedded blocks are loaded (section 4). The pipeline got faster and cheaper at the same time.
DAM section, about 86,000 images, real Graph
B synchronized the whole section in 41 minutes at a steady rate of about 2,000 documents per minute. A ran at about 250-300 documents per minute on the same content: roughly 7×. But it was one run per build, so I treat the ratio as a rough comparison rather than a stable benchmark.
CPU
The new pipeline puts the available cores to work: machine CPU during the large-site runs was 86-94% for B, against 48-50% for A. That is consistent with the new producer keeping more work in flight. If the server also serves editors, ContentListingMaxConcurrency (section 6) is the knob for how much of the machine the job may take.
4. What changed in Graph
13.3 is not only throwing more threads at the same database work. It also does less of it.
This section and the next come from comparing the 13.2.0 and 13.3.0 assemblies (decompiled) and database scripts; the numbers in them are from my measurements above. Here is the list.
Performance
- Content versions are listed in parallel. A new producer handles many nodes at once: it lists their versions on a pool of workers and feeds a bounded channel that the sending side reads from. Every node is processed in its own DI scope. This is the main source of the 2-3×.
- One dial:
ConcurrencyPerProcessor. The default is 1.5 workers per core. Unless you set them explicitly,BatchMaxConcurrency= cores × 1.5, rounded up (1-64), andContentListingMaxConcurrency=BatchMaxConcurrency/ 5, rounded up (2-16). On my 12 cores that gave 18 and 4. - Never loaded: content types excluded by convention. If your indexing conventions exclude a type, the synchronization skips it before loading any content. On the smaller site version listings dropped from 50,890 to 32,941, and the deserialization errors for types nobody wanted in Graph are gone from the log. The filter applies in the event-driven paths too.
- Embedded blocks come from published content with language fallback. No more loading a specific version by work ID. Loads of a specific version’s properties: 28,703 → 369 (yes, really).
- NDJSON batches go to Graph without an intermediate copy, straight from the stream.
Reliability and correctness
- Options are validated at startup with explicit ranges:
BatchMaxCount1-200,BatchMaxConcurrency1-64,ContentListingMaxConcurrency1-16. A misconfiguration shows up the moment the application starts. - The Graph client reports why a request was rejected.
GraphHttpException.RejectionReasontells you whether the client-side resiliency pipeline stopped the request - circuit breaker open, rate limiter saturated, timeout - orNonewhen the request reached the service. - The index follows permissions. Content goes to Graph when the
SearchIndexerrole can read it. Delta sync removes what is no longer indexable, and a change of access rights re-evaluates the whole subtree. - Also new: version numbers on embedded items in
_metadata.version, fed byIVersionable.Version.
Query SDK
- Facets on list fields: new
Facet(...)overloads for lists of integers, doubles and dates. - Synonyms in field-targeted search:
UsingField(...)accepts synonym slots. - By C# name, too:
GetFacetandGetSuggestionsaccept the CMS property or computed field name as well as the Graph field path.
5. Beyond Graph
- New package:
EPiServer.Cms.DamMigration. After a CMS 12 → 13 upgrade, a scheduled job remaps legacy DAM (CMP) asset references to the new content sources, including inside inline blocks, local blocks and block lists. It can be stopped and rerun safely and reports unresolved assets by reason. You register it withAddDamMigration(). It’s off by default. - DAM: renditions and usage tracking. Image and video renditions can be picked in the DAM selector, and their content sources are created at startup. A new tracking job keeps CMP informed about which assets your content uses; you switch it on with
DamUIOptions.TrackingEnabled. - Schema 21005: two indexes.
(LastCheckedDate, LinkProtocol)on soft links for the link checker, and(fkScheduledItemId, Exec DESC)on the scheduled job log, whose query now pages withOFFSET/FETCH. The scheduled jobs screen reads only what it shows. - A no-op content type save no longer clears the cache or publishes events.
- Validation in the new inline block dialog: it checks the form, shows server errors in a notification bar and closes only after a successful save.
6. Quick win
If you have tuned BatchMaxCount manually, make sure it is within the new 1-200 range before you upgrade. And check that the SearchIndexer role has Read wherever you want content in Graph. That’s it.
The defaults are a good start. If you tuned the old pipeline with large values, this is the shape of the configuration:
{
"Optimizely": {
"ContentGraph": {
"ContentVersionSynchronizationMode": "DraftAndPublishedOnly",
"BatchMaxCount": 100,
"BatchMaxConcurrency": 18,
"ContentListingMaxConcurrency": 4
}
}
}
In my measurements BatchMaxCount 200 performed the same as 100 and ContentListingMaxConcurrency 2 performed the same as 4 on the large section, while 1 performed about 29% slower, so values from 2 to 4 are a good place to start on a server that also serves editors.
7. The take
The full synchronization in 13.3.0 does less database work and more useful parallel work. Versions are listed in parallel, excluded types are never loaded, embedded blocks come from published content, and the SQL bill drops by more than a third. On a large site that is the difference between an hour and twenty minutes. On an image-heavy DAM section it is several times more. Not bad for a minor version.
Further reading
- Optimizely Graph: What content Optimizely Graph indexes
- Optimizely Graph: Sync content data
- Optimizely Graph: Synchronize content events to Optimizely Graph
- Optimizely Graph: Smooth rebuild
- Optimizely CMS 13: Indexing conventions
- Optimizely CMS 13: CMS 13 and CMS 12 Graph comparison
- Optimizely CMS 13: Install database schema
- Optimizely CMS 13: Scheduled jobs through the UI
- Optimizely NuGet feed: nuget.optimizely.com
- Microsoft: Parallel.ForEachAsync
- Microsoft: System.Threading.Channels
- Microsoft: OptionsBuilder ValidateOnStart
- Microsoft: Monitoring performance by using the Query Store
- Microsoft: Database snapshots
- Microsoft: DBCC DROPCLEANBUFFERS
Stack: Optimizely CMS 13.2.0 and 13.3.0, Commerce 15.2.0, Optimizely Graph (test tenant and a local stand-in), .NET 10, SQL Server 2022, Query Store, ILSpy for the assembly comparison.