Daniwoo· 100% Salesforce-native LMS · Available on AppExchange

LMS tracking in Salesforce: designing for scale

Guide 9 September 2026 11 min read
BM Brice MbouaniFounder, Daniwoo
A stylized diagram showing many concurrent write operations converging on a single locked Salesforce record, illustrating lock contention in LMS tracking.

Every LMS demo shows you the player. None of them shows you the writes. Yet the writes are the actual workload: every learner interaction wants to become a record. With a thousand learners mid-course at once, your org is running a concurrency problem disguised as a training program. Whether it stays invisible or starts throwing UNABLE_TO_LOCK_ROW depends on design decisions you'll only discover under load.

I build and maintain LMS tracking that runs in production Salesforce orgs at enterprise scale, and I've been on the wrong side of this problem once. This article is written for architects evaluating an LMS and for developers staring at sporadic row lock errors right now.

TL;DR: LMS tracking is a concurrency workload: thousands of learners generating writes that converge on shared parent records. Salesforce's locking model (a detail insert locks its master; a transaction waits at most 10 seconds for a lock) turns naive write-per-interaction designs into UNABLE_TO_LOCK_ROW at scale. We learned this in production. The fix wasn't tuning; it was architecture: keep granular progress lightweight, and write the reportable cascade once, at completion. This article covers the locking mechanics, the incident, and the design rules.

This article is part of the complete guide to running an LMS inside Salesforce.

The write workload nobody demos

An LMS in Salesforce is a write-heavy application; the content player is just its visible surface. For every learner mid-course, the platform is persisting progress percentage, scores, time spent, resume state, and status transitions. Most of those writes are updates to records that hang off the same small set of parents.

Walk through what one learner generates. Launch a module: a tracking record is created or reopened. Answer questions: scores accumulate. Navigate: progress and suspend data change. Close the tab: time spent and state get flushed. If the content is SCORM, the cadence is partly outside your control, because the course itself decides when to call Commit, and a chatty course calls it constantly. I covered that runtime conversation and why commit cadence matters in the SCORM guide; here, what matters is that each commit is a candidate DML statement.

Now multiply by N concurrent learners. A compliance deadline or a new-hire cohort lands, and suddenly hundreds or thousands of learners are active in the same hour, on the same courses. And here is the structural trap: tracking records are children. Each learner's progress record points at a shared enrolment, which points at a shared course, which may roll up into a shared program. The data model funnels thousands of independent write streams toward a handful of hot parent records.

The row-locking literature almost always frames contention as a data-load problem: batches, imports, integration jobs. An LMS hits the same wall organically. No Data Loader involved, just users clicking Next at the same time. That makes it an architecture question to settle at design time, not an ops question to tune later. Concurrency-safe tracking is also the entire payoff of running learning management natively in Salesforce: completions land where your reports and Flows live. But they now share limits and locks with the rest of your org.

The locking model that's waiting for you

Salesforce resolves concurrent writes with record-level locks. The rule that turns contention into errors is brutally simple: a transaction that needs a lock held by another transaction waits at most 10 seconds, then fails with UNABLE_TO_LOCK_ROW. That behavior is documented in Salesforce's knowledge article on record locking errors. Ten seconds sounds generous until you picture a queue of transactions each holding the lock for a few hundred milliseconds of trigger and Flow time. The queue only has to back up once for a learner-facing error to fire.

The part that surprises even experienced admins is how far a single write reaches. Established platform behavior, laid out in Salesforce Engineering's Record Locking Cheat Sheet, includes:

  • Master-detail: inserting or deleting a detail record locks its master. Two learners starting the same course at the same instant are both asking for a lock on the shared parent. Reparenting a detail locks the masters involved as well.
  • Roll-up summaries recalculate on the parent, and that recalculation locks the parent. A roll-up like "completed steps count" on an enrolment turns every child change that feeds it into parent contention.
  • Lookups are not automatically safe. A lookup set to "Don't allow deletion" locks its target when the child is inserted or updated. That setting is the only option for required lookups, and common on shipped data models. Swapping master-detail for lookup, on its own, does not buy you out of the problem.

So a single tracking write is rarely a single lock. It's a lock on the child plus locks on whatever parents the relationships and roll-ups drag in. Those locks are held for the full duration of the transaction: triggers, Flows, and any cascade they start.

Two per-transaction governor limits frame the other wall: 150 DML statements and 10,000 records processed through DML. A design that spends several DML statements per learner event burns that budget fast, and the more DML a transaction performs, the longer it holds its locks. Lock duration and DML volume are the same problem from two sides.

N learners progress · scores commits · status Child tracking one record per learner 🔒 Shared parent enrolment · course · roll-ups one lock, everyone waits UNABLE_TO _LOCK_ROW waiters, after 10s
Lock propagation: independent learner writes funnel into contention on the parents everyone shares.

Read those mechanics against the LMS data model above and the collision is predictable: thousands of child writes converging on shared parents. Each write holds parent locks for the length of its automation chain, and every waiter is on a 10-second clock. Nothing needs to be misconfigured. It fails by design, if the design writes too often.

Our production incident: write-per-event meets concurrent learners

Our original tracking design did the obvious thing: when a tracking event arrived, we wrote it through to the reportable records. Progress moved, we updated. A score changed, we updated. It was simple, it was always current, and in every test we ran it was fine. Then it met real concurrency.

The scale that broke it was real: a deployment with roughly fifty thousand learners active in the same window. And the symptom was the worst kind, because it was silent. The customer's own after-update Flows, the automation they had built on our tracking records, stopped firing for some completions. Meanwhile, on the learner's screen, everything looked saved. Nobody saw an error, because nobody was shown one. Under the surface, the write had hit a lock, waited out the 10-second fuse, and thrown inside Apex. The transaction rolled back, taking the "saved" progress with it. The learner's screen was reporting client-side state that the database had quietly refused.

It took us two weeks to diagnose, and the silence was why. The reported bug was "Flows not executing", so we started at the Flow layer, where nothing was wrong. The trail led backwards: no Flow fired because no record was updated; no record was updated because the transaction rolled back; the rollback traced to one string repeating in the logs: UNABLE_TO_LOCK_ROW. The real diagnosis came from what the failures had in common, and it wasn't the child records. It was the parents. Every failing write was competing for a lock on a shared record upstream of the learner's own data: the enrolment, the course-level rollup targets. Each write held those parent locks briefly, but "briefly" multiplied by every concurrent learner on the same course meant the locks were effectively never free. Waiters queued, the 10-second fuse burned down, and the unlucky ones errored. Nothing was slow. Nothing was broken. The writes were simply too frequent for records everyone shared.

The rule from the postmortem is the backbone of everything we've built since: reportable writes happen once, at the aggregate event, not per interaction. A learner clicking through a module generates dozens of tracking events, but exactly one event your dashboards care about: the module got completed. That's the write that deserves to touch shared parents. Everything before it is working state, and working state has no business acquiring locks on records the whole cohort shares.

The write-at-completion architecture

The redesign splits tracking into two tiers with opposite characteristics. Granular progress is frequent, lightweight, and isolated. Reportable state is rare, heavier, and allowed to touch shared parents. The boundary between them is the terminal transition, when a module's status becomes final.

WRITE-PER-EVENT progressscorecommitsuspend 🔒 Parent contention, continuous WRITE-AT-COMPLETION progress · scorecommit · suspend(isolated, no parents) one cascade, at completion Parent occasional, worth its lock
The redesign in one picture: interactions stay isolated; only the terminal transition earns a write to shared parents.

The math of the incident. Model it on the incident's own parameters: 50,000 concurrent learners, at ten tracking writes per module for a chatty SCORM course. Write-per-event turns one module pass into 500,000 DML statements, every one reaching for shared parent locks. Spread across a ten-course catalog and a half-hour session window, that is a sustained queue of roughly 28 lock acquisitions per second on a single parent. Each waiter is on the 10-second fuse. Write-at-completion changes the arithmetic, not the traffic: the same cohort produces 50,000 parent-touching cascades, one per learner per module, a 90% cut in shared-lock pressure. The granular writes still happen, but they land on isolated records, and the debounce collapses most of them before they ever become DML.

WRITE-PER-EVENT WRITE-AT-COMPLETION 500,000 50,000 (−90%)
Parent-touching writes for one module pass, modeled on the incident's parameters: 50,000 learners at ten writes per module, versus one completion cascade per learner.

On the granular tier, in-flight progress lives close to the learner. It is buffered client-side and written at a few deliberate moments: when something reportable changes, like the status or the score, and at every exit. Exits matter more than they look, because learners rarely finish a module in one sitting. They switch to another item in the course, change the content's language, jump to another page of the site, or open the mobile menu. Each of those exits persists the resume state (suspend data, location, and time spent) so the next session picks up exactly where this one stopped. What these writes never do is fire the completion cascade: certificates, badges, and completion roll-ups wait for the terminal transition. The interaction stream itself never reaches the database; only its snapshots do.

The reportable tier fires once, when the module reaches a terminal state. That single transaction performs the full cascade: the completion record, the enrolment progress, whatever downstream state reporting depends on. Because it runs once per learner per module instead of once per interaction, contention on shared parents drops from continuous to occasional, and each occurrence is worth its lock. The cascade is bulk-safe by construction: one write path whether it handles one learner or two hundred. It is also idempotent. If the same completion is signaled twice (a retry, a replay), the second pass detects the terminal state and changes nothing. This is the architecture Daniwoo runs in production today, and it exists because the write-per-event version didn't survive contact with scale.

The incident also left a second rule, about failure isolation. The learner's own write now commits on its own terms: downstream rollups are best-effort, and heavier downstream work runs asynchronously, in its own transaction. If anything later in the chain fails, it fails alone. It cannot roll back the progress the learner just watched save. Silent data loss was the truly expensive part of the incident, and these guardrails exist so that class of failure is structurally impossible, not just unlikely.

If you've read this series, you've met the principle twice already, at different layers. The completion lock in the SCORM guide refuses to let a replayed session downgrade a terminal status: act on terminal transitions, ignore the noise after them. The strict entry conditions and idempotence rules in the automation guide make Flows fire on the transition into a state, not on every edit that touches a record. The lesson is the same: act on terminal transitions, ignore the noise before them. Write-at-completion is the same principle applied to the write path itself. Interactions are noise; transitions are signal; only signal earns a lock on shared records.

Design rules for your own org

The vendor's architecture is only half the story: the moment an LMS lands in your org, your admins start pointing automation at its objects. These rules keep your side from reintroducing the contention the vendor designed out.

  1. Never attach automation to high-frequency tracking objects. A record-triggered Flow on a progress object runs on every flush from every learner and stretches every lock window. If you need to react to learning activity, react to the completion object, which changes once per learner per module.
  2. Target terminal transitions, not edits. Use the "only when a record is updated to meet the condition requirements" option. Entry conditions should test that status became complete, not that a record containing a complete status was touched. The difference is invisible in a demo and decisive at scale.
  3. Sort by parent ID when you data-load. For bulk operations on child objects (enrolment imports, migrations), the KB's recommendations apply directly. Keep trigger time short. Use smaller batches or serial mode when contention appears. Order records by parent so each batch hits one parent's lock instead of interleaving across many. Spread children across parents where the model allows.
  4. Audit roll-ups on hot objects. A roll-up summary on an enrolment or course converts child writes into parent locks. Before adding one, ask whether a scheduled or on-completion calculation serves the report just as well.
  5. Test with realistic concurrency, not sequential scripts. One tester completing modules one at a time will never surface lock contention. Simulate the launch-day shape: many learners, few courses, same hour. If your load test doesn't create parent-record contention, it isn't testing the thing that fails.

None of this is LMS-exotic. It's the same lock hygiene you'd apply to any high-volume object in your org. The only LMS-specific twist is knowing which objects are the hot ones before the vendor's data model teaches you the hard way.

What to ask an LMS vendor about tracking architecture

Ask about writes, not features. Any vendor can demo a player; very few can describe their locking behavior, and the ones who can are the ones who've been burned and fixed it. Four questions separate them:

  1. "What gets written, and to which objects, while a learner is mid-course?" You're listening for a two-tier answer: lightweight granular state, plus an aggregate write at completion. If every interaction writes to objects with roll-ups or master-detail parents, you've found the incident from this article, pre-installed.
  2. "What happens at 1,000 simultaneous completions?" A credible answer names the shared parents, explains how the cascade is bulked, and describes idempotence on retry. "The platform handles it" is not an answer; the platform's documented behavior is precisely what fails.
  3. "Which automations does your package trigger from tracking objects?" Every packaged trigger and Flow on the tracking path runs inside the lock window of every learner write. You're entitled to know what's in that window before it runs in your org.
  4. "Can we get your locking and write-cadence patterns in writing?" If the architecture is sound, writing it down costs the vendor nothing. If they won't, weigh that.

These four belong alongside the standards, security, and reporting questions in any serious evaluation. They're all in the LMS RFP question kit, formatted to lift straight into your procurement doc.

FAQ

What causes UNABLE_TO_LOCK_ROW in Salesforce?

A transaction tried to acquire a lock on a record another transaction was still holding, and the wait exceeded the platform's 10-second cap, per Salesforce's knowledge article. The contested lock is often not the record being edited but a related parent. Detail inserts lock masters, roll-ups lock parents, and lookups set to "Don't allow deletion" lock their targets on child insert and update. High-frequency writes converging on shared parents, as in LMS tracking under concurrent learners, are a classic cause.

How do you avoid row locking errors in Salesforce?

Reduce how often shared records get locked and how long each lock is held. Architecturally, write to contended parents rarely: at aggregate events like completions, not per interaction. Keep automation off high-frequency objects, and audit roll-ups on hot parents. Operationally, follow the KB's recommendations. Keep trigger execution short, use smaller or serial batches for loads, sort loaded records by parent ID, and spread children across more parents where the model allows.

Does updating a child record lock the parent in Salesforce?

It depends on the relationship and the operation. In master-detail, inserting or deleting a detail locks the master, reparenting locks the masters involved, and roll-up summary recalculation locks the parent. With a lookup set to "Don't allow deletion" (forced for required lookups), inserting or updating the child locks the lookup target, per the Record Locking Cheat Sheet. The practical takeaway: assume child writes reach the parent unless you've verified otherwise.

How does an LMS track learner progress in Salesforce?

A native LMS persists learner activity as Salesforce records: typically a tracking record per learner per module carrying status, score, progress, and time, related to enrolment and course records. The content runtime (a SCORM API, a quiz engine, a video player) reports events and the platform writes them through. Architectures differ in when they write: per interaction, or once at completion. That choice, invisible in a demo, decides whether tracking scales, and it's why native tracking done right beats a synced external LMS, while done wrong it buckles under load.

What is bulkification in Salesforce?

Bulkification is designing code and automation to process many records in one transaction instead of one at a time: collect, then query once, then perform DML once. It exists because per-transaction limits (150 DML statements, 10,000 DML rows, per the Apex governor limits) punish per-record operations. It also exists because every extra DML statement extends the time a transaction holds its locks. For write-heavy workloads like LMS tracking, bulkification and lock management are two halves of the same discipline.

Conclusion

LMS tracking in Salesforce is a concurrency workload wearing a training program's clothes. The locking model is documented, deterministic, and unforgiving: parents get locked by child activity, waiters get 10 seconds, and a write-per-interaction design converts learner enthusiasm into UNABLE_TO_LOCK_ROW. The fix is not retries or batch tuning. It's one rule applied consistently from the SCORM runtime to the Flow layer to the write path itself: act on terminal transitions. Granular state stays light and isolated; the reportable cascade fires once, at completion, bulk-safe and idempotent.

Bring your concurrency questions

See what write-at-completion tracking looks like under a real cohort, on a real Salesforce org, from the team that learned it in production.

Book a demo

About the author. Brice Mbouani is the founder of Daniwoo and a Salesforce engineer. He designed and built the tracking architecture described in this article, which runs in production Salesforce orgs at enterprise scale. The incident recounted here is from his own postmortem.

Get new articles by email

Practical guidance on running learning inside Salesforce. No spam, unsubscribe anytime.

By subscribing you agree to receive emails from Daniwoo about running learning inside Salesforce. Unsubscribe at any time from any email.