What One Deleted Account Taught Us About Our iPhone Sync
A new user deleted their account 19 minutes after signing up. When we asked why they were leaving, they picked "too slow or buggy."
My first reaction was simple. It's not slow, so it's buggy. But which bug? My gut said a recurring event was landing on the wrong date. I was half right, and the half I got wrong turned out to matter more.
This post walks through how we reconstructed what that person did without knowing who they were, what we found in the code behind it, and what we changed. One user's 19 minutes led us to a sync bug that affected every iPhone user with repeating events.
Two systems that deliberately don't talk to each other
SoKal is a social calendar. You add events or connect the calendars you already use, and friends see what you choose to share. Privacy works by event type: travel, outing, exercise, online, errand, visitor, work, blocked, or no type at all. Anything you don't share shows to friends as a grey "Busy" block.
Because the product is about sharing, we are strict about what we collect. Two tools made this investigation possible, and we keep them apart on purpose.
The "Why are you leaving?" answer
We ask this during account deletion. The answer travels inside the delete request and lands in its own anonymous table. Free text is scrubbed before it is stored: handles, links, long runs of digits, and the person's own name, username and email are all replaced with [removed]. Our privacy policy promises the answer is kept with nothing that links it to you.
The deletion timeline email
When someone permanently deletes their own account, the backend emails us a timeline of what they did in the app over the previous 24 hours. Nothing is stored. It exists only as that one email. The rules it follows:
- No name, email, phone number, username, user id or entity ids. Ever.
- No leaving answer. Keeping that out is what keeps our "nothing that links it to you" promise true.
- Times are shown in the user's own timezone, so the sequence reads the way they lived it.
- Metadata is shown only if it is code-shaped: a short lowercase token like
event_type=workorview=month, and nothing that looks like a UUID. Some keys are denied outright: anything containing title, name, email or phone, plusslug(which holds usernames) and any*_idkey. - Repeated identical rows are folded into one line, like "x3, until 17:43". At most 500 rows.
- A short summary sits on top, counts and codes only: how they signed up, friend counts, events created versus synced, connected calendars, and whether they enabled things like push or a profile photo.
- It can never slow down or break a deletion. The read has a 500 ms timeout and runs alongside the delete, and the email goes out in the background after the delete commits.
- The body is never written to server logs. If email isn't configured, it simply isn't sent.
The data underneath is our own analytics table. Each row is an action name, a platform, an entity type and a little metadata. Event titles are never logged. That has been a standing rule since day one.
How the two got matched anyway
I want to be straight about this part. No system links the leaving answer to the timeline. But we are small. Only five leaving answers had ever been recorded. When the timeline email arrived, I opened the leaving reasons dashboard and saw the count go from five to six, with our first ever "too slow or buggy" answer.
Timing lined them up, not code. That is a real privacy nuance at small scale, and it's worth naming. The safeguards still hold where they count: the answer carries no identity, and the timeline never names the person. Even with both in front of me, I have no idea who this was. I only know what they tried to do.
What 19 minutes looked like
A new user in Europe, using the app in Polish, on an iPhone. Here is what the timeline showed.
- They signed up with Sign in with Apple.
- During onboarding they turned travel sharing off, so only outings would be shared with friends.
- They connected their iPhone calendar. 171 events came in, and none were recognised as travel or outings. Every single one arrived with no type.
- They never created an event in SoKal. Everything they touched was imported.
- They retyped some imported events: no type to errand to blocked, no type to work, then work back to no type.
- They deleted 6 imported events in about 45 seconds.
- For the last 12 minutes the pattern was the same: change a sharing setting, then open "View as: connection" on their own profile, which previews what friends see. They did this four times, sharing one type, then five, changing other privacy options in between.
- Their last action was opening a day next month inside that preview. 37 seconds later, they tapped Delete account.
Nothing in there says "crash." Nothing mentions recurrence or wrong dates. What stands out is a loop: change a setting, check the result, change it again.
How we investigated
We read the timeline first and formed hypotheses second. Then we split the work into three read-only questions and ran them in parallel as separate code investigations, using Claude Code sub-agents:
- How does Apple Calendar sync handle edits, deletes and repeating events?
- What exactly does each analytics field mean? Some of our own log fields were ambiguous.
- What does the "View as connection" preview actually render, and does it match what friends really see?
We treated every report as a lead, not a fact, and verified each headline claim in the code before acting on it. That mattered, because we misread our own logs three times.
- The phantom invite. The log said the user invited one SoKal user to an event, despite having zero friends. The count includes the event's owner. They had invited nobody.
- The phantom crash. A
session_endright next to a freshapp_openlooked like a crash. It was iOS killing the app in the background. - The wrong screen. We first concluded their 6 deletes had also deleted those events from their iPhone, because deleting from our event form does that. Then we traced the exact screen they used. It was the event details screen, which never touches the iPhone. Their phone calendar was most likely untouched. Two screens doing the same job in different ways nearly sent us to the wrong conclusion. Check the path the user actually took, not the one you assume.
What we found
Problem 1: repeating iPhone events collapsed into one event, on a random date
This one is confirmed in code and affected every iPhone user with repeating events. We can't prove from the log that this user hit it, since sync left no trace at the time. But most real calendars have repeats in them, and theirs had 171 events.
Apple's EventKit gives every occurrence of a repeating event the same eventIdentifier. Apple's own docs say to use the start date to tell occurrences apart. We keyed synced events on that identifier alone, so a weekly meeting became one SoKal event. It landed on whichever occurrence was processed last, often months away.
It got worse the deeper we looked:
- The intended key never arrived. The app meant to use
calendarItemExternalIdentifier, but our native iPhone code sent it asexternalIdentifier. The lookup always missed and fell back toeventIdentifier. Fixing the name alone wouldn't have helped, though, because that field is also shared across occurrences. - The server collapsed them a second time. A dedupe guard that asks "is this an event we already pushed to Apple?" matched on the same shared identifier. Even distinct keys would have been folded back into one.
- Write-back hit the wrong week. Editing or deleting that event in SoKal called
event(withIdentifier:)on the phone, which returns the series' first occurrence. So SoKal could change a different week's event on your iPhone.
Problem 2: deleting imported events was inconsistent and silent
- Deleting from the event form also deleted the event from the iPhone. Deleting from the event details screen didn't. Neither one told you which would happen.
- The confirmation only said the event would be removed from your calendar. Which calendar?
- On the details screen, iPhone events got a hard-coded English dialog about cancelling the event and notifying all participants. A Polish-speaking user with no friends on SoKal saw it six times.
- An event deleted in SoKal could come back on the next Apple sync. Our Google sync already had a "stay deleted" guard. Our Apple sync didn't.
Problem 3: the friend preview could never change
This is the most likely trigger. Only travel, outing, exercise, online, errand and visitor could be shared. Untyped, work and blocked events always showed as Busy, no matter what.
Every one of this user's events was untyped, because that is how imported events arrive. So every sharing switch they flipped left the preview exactly the same. Four rounds of changing settings and checking, and nothing moved. Nothing on screen explained why.
The wording didn't help. The event form said those events were "Not visible to friends," while the preview showed them as visible Busy blocks. Both were half true. Side by side, they read as a contradiction.
The preview had its own gap too. It ignored two settings that friends' real view respects: an event's own "hide from" list, and the owner's choice of showing hidden events as Busy or not at all. So the preview could differ from what friends actually saw.
Our reading, and it is an inference: this person was trying to control what friends would see, and the controls appeared to do nothing. That fits "buggy" better than any crash would. A lot of the time, "buggy" means "I did something and nothing changed."
What we fixed
Repeating events
Each occurrence of a repeating event now gets its own key. One-off events keep their old key, so nobody's existing events get rebuilt.
if (isRepeating(raw) && raw.startDate) return `${raw.eventIdentifier}::${raw.startDate}`;
return raw.eventIdentifier;
- The backend no longer matches occurrences by the shared id, and occurrences get no write-back id. SoKal can't edit or delete the wrong week on your phone anymore.
- Deleted events stay deleted.
- On the first sync after the update, every collapsed series is replaced with its real occurrences automatically.
- An integration test covers it: a weekly series becomes three events, a deleted occurrence stays deleted, and an old collapsed series is replaced cleanly.
One known limit: changing a whole series on your iPhone rebuilds its occurrences in SoKal. The clean fix needs our native code to send each occurrence's original date, which means a new App Store build. The key format already has room for it.
A real choice when deleting
Deleting an event that came from Apple Calendar now asks whether to remove it from SoKal only, which is the default, or delete it from Apple Calendar too. The choice appears on every delete path: phone, tablet, foldable and the details screen. Events you created in SoKal still delete their phone copy, as before.
Sharing that actually responds
Work and untyped events can now be shared. Both are off by default for everyone. Blocked can never be shared.
| Event type | Can be shared | Where friends see it |
|---|---|---|
| Travel, outing, exercise, online, errand, visitor | Yes | Calendar, feed, notifications |
| Work, Other (no type) | Yes, off by default | Calendar only |
| Blocked | Never | Busy block only |
Calendar-only means friends see the event on your calendar, but it never enters the feed, notifications or alerts. We enforce that in SQL on every feed query, with a test that proves it:
array_remove(array_remove(COALESCE(<approved types>), 'default'), 'work')
The confusing label is gone too. Unshared events now say "Friends see you're busy, not what it is." The preview now follows the same rules as the server, including per-event hiding. All new text ships in all nine languages on app and web.
Logging we added
Answering "what was this person doing?" took inference in places where better logs would have given us facts. So we closed the gaps:
- Deletes now record the event type, where it came from (SoKal or a connected calendar), which screen, and for iPhone events whether the phone copy was kept. Before, a delete was a bare id.
- Edits now record whether the event was made in SoKal or imported.
- iPhone sync now logs one row when something changes, counts only: events read, how many were repeating, sent, created, updated, removed, and duration. Failures record which step failed and the HTTP status. Before this, sync left no trace at all, which is exactly why we can't confirm this user hit Problem 1.
Still no titles. The leaving answer still never goes into the timeline.
What we took away
Privacy and debuggability aren't opposites. Code-shaped metadata, no ids, no titles, local times, and an email that is never stored. That was enough to reconstruct what someone was trying to do.
Read the code behind what the user touched. The log can't prove this person hit the repeating-event bug. We found it anyway, because we followed their path into the sync code.
Dead controls feel like bugs. The likely trigger wasn't a crash. It was a switch that looked like it did nothing.
Check the exact path. Two screens doing the same job differently almost gave us the wrong answer.
Log for the next investigation, not just the dashboard.
The part that still bothers me is everything before this. As I put it to the team: "I don't know how many users we've lost because of that, and people just didn't answer. Or we didn't have logs in place." This person left us a reason and a trail. Most people who leave don't. Building so that the ones who do can teach you something is worth it.
