Skip to content
Back to posts
On this page
~/posts/google

Google Calendar Recurring Event Operations

Implementation patterns for `all`, `this`, and `thisAndFollowing` recurring

I thought I understood recurring events after reading the Google Calendar API docs. Then I started handling real calendars and found shapes the guide never mentions — the docs describe the happy path. This post covers the implementation patterns I ended up with for all, this, and thisAndFollowing operations, including the partial invitation cases I could not find documented anywhere.

A Minimal Storage Model

To make the rest of this concrete, assume a simple storage model. Each stored event record — call it a block — has two fields that matter here:

  • originalId — points at the block this one belongs to, or null
  • recurrence — an RRULE, or null

That is the whole model. Everything below is built on it, and the relationships between blocks form a tree.

Parent Block (originalId = null)
  - Has recurrence rule (RRULE)
  - Root of the series
        |
        | originalId points to parent
        v
Exception (this)              Sub-series (thisAndAfter)
- originalId = parent.id      - originalId = parent.id
- recurrence = null           - recurrence = [RRULE]
- Single occurrence override  - New branch of series

A parent block is the root. It has an RRULE and no originalId. An exception (created by a “this” operation) points back to the parent and has no recurrence rule of its own — it overrides a single occurrence. A sub-series (created by a “thisAndFollowing” operation) also points back to the parent, but it carries its own RRULE because it generates its own occurrences.

This distinction — whether a child block has a recurrence rule — determines which operation created it and how it should be processed.

Partial Invitation Edge Cases

This is where the documentation stopped being enough. When a user is invited to only part of a recurring series, Google creates events that look like exceptions or sub-series but behave differently.

The first column below is Google’s own event id from the Events resource. The other two are the model fields from the previous section — whatever local columns you use to mirror the invitation’s parent pointer and its recurrence rule.

ShapeGoogle event idoriginalIdrecurrenceBehavior
“TA-as-parent”{parentId}_R{date}self-ref or null!= nullActs as root parent for that user
“T-as-single”{parentId}_{date}nullnullActs as regular single block

“TA-as-parent” blocks appear when a user is invited to the “this and following” portion of a series. They were common enough that ignoring them was not an option. The id looks like a sub-series id, but the originalId is self-referencing. From that user’s perspective, this block IS the root parent — other exception blocks point to it as their parent.

“T-as-single” blocks appear when a user is invited to a single occurrence. This was by far the most common of the odd shapes. The id has the exception-style format with a date suffix, but originalId and recurrence are both null. These blocks never enter the recurring event processing pipeline at all — they behave as standalone single events.

The lesson: you cannot determine block type from the id pattern alone. You must look at the field combination.

Block Type Identification

Block types are determined by field combinations, not id patterns:

TypeoriginalIdrecurrence
Parentnull!= null
TA (sub-series)-> parent!= null
T (exception)-> parentnull
“TA-as-parent”self-ref!= null
“T-as-single”nullnull

There is an important implication for instance expansion. Whatever code expands a series into instances will typically stamp each generated instance with the id of the block that generated it. For a sub-series, that id is the sub-series, not the root parent.

So when you need to match exception blocks to instances — removing cancelled occurrences from the expanded list, for example — both sides have to be resolved to the root parent id first. That needs a small chain resolver: walk originalId upward until you reach a block that has none, with cycle protection for the self-referencing case.

The distribution of those chains is the reason it has to be a loop. Nearly every exception points straight at the root parent. Self-referencing “TA-as-parent” blocks are the next largest group. Chains deeper than one hop are rare but not zero, and I found chains deeper than two. A single dereference is correct for the overwhelming majority and silently wrong for the rest — which is the worst possible failure profile, because it looks fine in every test you would think to write.

Operation Types

OperationComplexityDescription
allLowUpdate entire series
thisLowUpdate single occurrence (exception)
thisAndFollowingHighSplit series, update from point forward

Let me walk through each one in implementation detail.

All Operation

The all operation updates the parent block and cascades changes to all children. The cascading behavior varies by block type:

Block TypeChange
ParentContent/time/recurrence updated
ExceptionsContent updated (time preserved)
Sub-seriesContent updated (recurrence preserved)

Exceptions keep their custom time because the whole point of an exception is that someone moved it to a different slot. Sub-series keep their recurrence because they may follow a different pattern than the parent. Both get content updates (title, description, location) because those should stay consistent across the series.

This Operation

The this operation creates or updates an exception — a single occurrence that differs from its parent.

{
  originalId: parent.id,           // Points to parent
  recurrence: null,                // Exceptions have no recurrence
  originalStartDateTime: Date,     // Original slot in parent series
  startDateTime: Date,             // May differ if time was changed
}

The originalStartDateTime field is critical. It tells the expansion engine which slot in the parent series this exception replaces. Without it, you would end up with a duplicate: the original occurrence from the RRULE plus the exception.

If the user changes the time of a single occurrence, startDateTime differs from originalStartDateTime. If they only change the title or description, both timestamps match. Either way, the exception claims its original slot so the parent’s RRULE does not also generate an occurrence for it.

ThisAndFollowing Operation

This is the most complex operation. It comes in two flavors: divide and non-divide.

CriteriaDivideNon-Divide
Time changed?YesNo
Recurrence changed?YesNo
Creates?Fresh series (originalId=null)Linked series (originalId=parent.id)
Deletes after split?YesNo (content updated)

The decision is straightforward:

const divide = timeChanged || changeRecurrence;

If the user changes the time or recurrence pattern, the series must be divided — the old occurrences after the split point are deleted and a fresh, unlinked series is created. If only content changes (title, location, description), a linked sub-series is created and the old occurrences after the split point get their content updated.

This distinction matters for data integrity. A divide creates a clean break — no relationship between old and new. A non-divide maintains the parent-child relationship, which keeps the event tree navigable.

Case Taxonomy

The divide/non-divide decision combines with the split point location to produce four cases:

CaseDescriptionDivide?Behavior
C-1Divide from sub-series startYesDelete sub-series + create fresh
C-2Divide from middleYesSet UNTIL + delete after + create fresh
D-1Non-divide from sub-series startNoUpdate content in place
D-2Non-divide from middleNoSet UNTIL + create linked + update content

C-1 is the simplest divide case: the user picked the first occurrence of a sub-series, so you replace the entire sub-series. C-2 is a mid-series divide: you truncate the source with an UNTIL date and create a fresh series starting at the split point.

D-1 is the simplest non-divide: update the sub-series content in place since the split point is the start. D-2 truncates the source and creates a linked sub-series for the remaining occurrences.

UNTIL Rule Algorithm

Getting the UNTIL dates right is the core challenge. Here is the algorithm I implemented.

Rule 1: Identify the Source Block

if (requestedBlock has originalId AND has recurrence) {
  sourceBlock = requestedBlock;  // Is a sub-series
} else if (requestedBlock has originalId AND no recurrence) {
  sourceBlock = parent;          // Is a `this` exception
} else {
  sourceBlock = requestedBlock;  // Is the parent
}

The source block is whichever block “owns” the occurrences around the split point. If the user picked an occurrence from a sub-series, the sub-series is the source. If they picked an exception, the exception’s parent is the source.

Rule 2: Update the Source UNTIL

sourceBlock.UNTIL = splitPoint - 1 day

This truncates the source so it stops generating occurrences before the split point.

Rule 3: Find the Blocking Block

blockingBlock = relatedBlocks
  .filter(
    (block) =>
      block.recurrence !== null &&
      getBlockStart(block) > splitPoint &&
      block.deletedAt === null
  )
  .sort((a, b) => getBlockStart(a) - getBlockStart(b))[0];

A blocking block is a sub-series that starts after the split point. The new series cannot extend past it because that sub-series already owns those occurrences.

Rule 4: Set the New Block’s UNTIL

if (blockingBlock) {
  newBlock.UNTIL = blockingBlock.start - 1 day;
} else {
  newBlock.UNTIL = sourceBlock.originalUNTIL;  // Inherit
}

If a blocking block exists, the new series ends before it starts. If no blocking block exists, the new series inherits the UNTIL from the source.

Blocking Block: A Visual Example

Timeline:     1   2   3   4   5   6   7   8   9  10
Parent:       [===============]
              UNTIL=5
Sub-series:                   [===================]
                              starts at 6

Pick day 3 with thisAndFollowing:
-> New block can only extend to day 5 (blocked by sub-series)

Without this check, the new series would generate occurrences on days 6 through 10, duplicating what the existing sub-series already produces.

UNTIL Inheritance

When no blocking block exists, the new block inherits the UNTIL from its source:

Sub-series(11-15, UNTIL=15)
Pick day 13 (no blocking block after):
-> New block UNTIL = 15 (inherited from source)

This preserves the intended end date of the original series.

Common Pitfalls

1. UNTIL Inheritance from the Wrong Source

When splitting from a sub-series, inherit UNTIL from the sub-series, NOT the parent:

Parent(1-14, UNTIL=14)
Sub-series(15-24, UNTIL=24)

Pick day 20:
  WRONG: UNTIL=14 from parent -> invisible
  RIGHT: UNTIL=24 from sub-series

This one fails quietly. The new block gets created with UNTIL=14 (inherited from the parent instead of the sub-series), so it generates zero visible occurrences. The row exists, the write reports success, and the block appears nowhere in the UI.

2. Query Filtering

Blocks with UNTIL < startDate are filtered out of query results. A correctly created block can be “invisible” if the UNTIL is wrong. This makes the bug from pitfall #1 particularly insidious — the data looks correct in the database, but the block never appears in any API response.

3. TypeORM update() vs save()

When modifying blocks during a thisAndFollowing operation, use targeted updates instead of saving the full entity:

// WRONG - may save mutated entity
await repo.save(block);

// RIGHT - targeted update
await repo.update(block.id, {
  recurrence: updatedRecurrence
});

The problem with save() is that if the entity has been mutated earlier in the operation (for example, by the UNTIL calculation logic), save() writes all those mutations to the database — including ones you did not intend to persist. Using update() with explicit fields ensures you only change what you mean to change.

Takeaway

Recurring event operations look simple from the API docs but get deeply complex once real calendars are involved. The “thisAndFollowing” operation alone has four cases, a multi-step UNTIL algorithm, blocking block resolution, and exception handling. Partial invitations add two more block shapes that break assumptions about id patterns.

The implementation strategy that worked: build a block type classifier based on field combinations (not id patterns), implement the UNTIL algorithm as a deterministic pipeline with explicit rules, and use targeted database updates instead of entity saves to avoid accidental mutations. Then test against the real distribution of shapes in your own data — the rare ones, the fraction of a percent, are the ones that corrupt user calendars.

Comments

Back to posts
enko