Event receivers are the backbone of business logic in classic, on-premises SharePoint. They let you react to list and site changes — adding an item, updating a document, deleting a site — and run your own code in response. They are also one of the easiest places to introduce subtle, hard-to-debug bugs. This article covers how the event model actually works and the patterns that keep receivers reliable in production.
Synchronous vs. asynchronous events
Every SharePoint event comes in one of two flavours, and the distinction matters enormously:
- Synchronous "-ing" events (
ItemAdding,ItemUpdating,ItemDeleting) fire before the change is committed. They run on the same thread as the user's request, block the operation until they return, and can cancel the action. Use them for validation. - Asynchronous "-ed" events (
ItemAdded,ItemUpdated,ItemDeleted) fire after the change is committed, on a background thread. They cannot cancel anything. Use them for follow-up work like sending notifications or stamping related items.
A receiver is just a class deriving from SPItemEventReceiver with the relevant methods overridden:
public class ContractEventReceiver : SPItemEventReceiver
{
// Synchronous: validate before the item is saved
public override void ItemAdding(SPItemEventProperties properties)
{
base.ItemAdding(properties);
var amount = properties.AfterProperties["ContractValue"];
if (amount != null && Convert.ToDecimal(amount) < 0)
{
properties.Cancel = true;
properties.ErrorMessage = "Contract value cannot be negative.";
}
}
// Asynchronous: react after the item is saved
public override void ItemAdded(SPItemEventProperties properties)
{
base.ItemAdded(properties);
// post-commit work here
}
}
properties.AfterProperties (the incoming values) and the existing values from properties.ListItem. In asynchronous events the item is already saved, so properties.ListItem holds the final state.
The recursion trap
The single most common bug in event receivers: an ItemUpdated handler that updates the same item, which fires ItemUpdated again, which updates the item again… The result is an infinite loop, a spike in CPU, and sometimes a stack overflow in the worker process.
The fix is to suppress event firing while you make your own changes by toggling the inherited EventFiringEnabled property:
public override void ItemUpdated(SPItemEventProperties properties)
{
base.ItemUpdated(properties);
try
{
// Stop our own update from re-triggering this handler
this.EventFiringEnabled = false;
SPListItem item = properties.ListItem;
item["LastReviewed"] = DateTime.UtcNow;
item.SystemUpdate(false); // false = don't increment the version
}
finally
{
// ALWAYS restore in a finally block
this.EventFiringEnabled = true;
}
}
EventFiringEnabled in a finally block. If an exception is thrown while events are disabled and you don't restore the flag, you can silently suppress events for the remainder of that worker-process thread.
SystemUpdate vs. Update
When writing back to the item, pick the right method:
item.Update()— increments the version and changes Modified / Modified By.item.SystemUpdate(false)— writes without incrementing the version or changing the modified metadata. This is usually what you want in a receiver, so your automated change doesn't look like a user edit.
Registering the receiver
For a feature-deployed list, receivers are declared in elements.xml. The Synchronization attribute lets you force an "-ed" event to run synchronously when ordering matters:
<Elements xmlns="http://schemas.microsoft.com/sharepoint/">
<Receivers ListTemplateId="100">
<Receiver>
<Name>ContractItemAdded</Name>
<Type>ItemAdded</Type>
<Assembly>Contoso.Contracts, Version=1.0.0.0, Culture=neutral,
PublicKeyToken=abc123def456</Assembly>
<Class>Contoso.Contracts.ContractEventReceiver</Class>
<SequenceNumber>10000</SequenceNumber>
<Synchronization>Synchronous</Synchronization>
</Receiver>
</Receivers>
</Elements>
You can also bind receivers programmatically — handy when attaching to an existing list at runtime:
SPList list = web.Lists["Contracts"];
list.EventReceivers.Add(
SPEventReceiverType.ItemAdded,
"Contoso.Contracts, Version=1.0.0.0, Culture=neutral, PublicKeyToken=abc123def456",
"Contoso.Contracts.ContractEventReceiver");
Error handling and performance
Receivers run inside the SharePoint request pipeline, so misbehaving code degrades the whole site. A few hard-won rules:
- Keep synchronous events fast. Anything you do in an "-ing" event adds latency to every save. Never call external web services synchronously.
- Never swallow exceptions silently in synchronous events. If you must cancel, set
properties.Cancel = trueand a clearErrorMessage. An unhandled exception in a synchronous receiver rolls back the operation and shows the user an ugly error page. - Dispose your objects. Do not dispose
properties.Weborproperties.ListItem.Web(SharePoint owns them), but do dispose anySPSite/SPWebyou open yourself. - Log defensively. Wrap async receivers in try/catch and write to the ULS log via
SPDiagnosticsServiceso failures are traceable.
public override void ItemAdded(SPItemEventProperties properties)
{
base.ItemAdded(properties);
try
{
// post-commit logic
}
catch (Exception ex)
{
SPDiagnosticsService.Local.WriteTrace(0,
new SPDiagnosticsCategory("Contoso.Contracts",
TraceSeverity.Unexpected, EventSeverity.Error),
TraceSeverity.Unexpected,
"ItemAdded failed: {0}", new object[] { ex });
}
}
SPSecurity.RunWithElevatedPrivileges creates a brand-new SPSite in app-pool context — you must open and dispose your own objects inside that delegate, never reuse the ones from properties.
Where this fits today
Server-side event receivers are a full-trust, on-premises (and SharePoint 2013/2016/2019) concept. In SharePoint Online you cannot deploy server-side code at all — the equivalent capabilities moved first to remote event receivers in the add-in model, then to webhooks and, increasingly, Power Automate and Microsoft Graph change notifications. The mental model, though, carries forward: decide whether you need to validate before a change or react after it, and design accordingly.
If you are maintaining a classic farm, the patterns above — synchronous validation, the EventFiringEnabled guard, SystemUpdate, and disciplined logging — will save you most of the production incidents I have seen over the years.