% ios-system-design-deep-dives.tex — the mechanisms interviewers push on after the
% high-level design: image cache, chat/realtime, offline-first sync, pagination,
% feature flags + remote config, analytics/logging pipeline. One page.
% The framework + a feed overview live in architecture/ios-client-system-design.tex — not repeated.
% Sources: docs/memos/{system-design-image-loading,system-design-chat,
%   system-design-offline-first,ios-feature-flags-remote-config}.md
%   (analytics pipeline: no memo — general practice, see the agent report)
% Build: tools/print/print-sheet.py docs/school/sheets/interview/ios-system-design-deep-dives.tex --dry-run
% @source: hiot monorepo, docs/school/sheets/interview/ios-system-design-deep-dives.tex — the SOURCE OF TRUTH; a copy anywhere else (e.g. artur.gurgul.pro) is regenerated from it, never edited
% @labels: area=interview kind=architecture level=senior platform=ios new=no round=round3-2026-09-24 topic=system-design,networking,data
% @tags: image-cache, downsampling, websocket, exponential-backoff, idempotency-key, outbox, offline-first-sync, conflict-resolution, tombstones, cursor-pagination, feature-flags, analytics-pipeline
\documentclass[8pt]{extarticle}
\usepackage{printup-sheet}

\lstdefinelanguage{SwiftSheet}{
  morekeywords={protocol,class,final,struct,enum,func,var,let,weak,init,
    if,else,return,guard,self,nil,try,await,async,throws,private,actor,defer,
    true,false,in},
  sensitive=true, morecomment=[l]{//}, morestring=[b]"}
\lstset{basicstyle=\ttfamily\scriptsize, aboveskip=2pt, belowskip=2pt}
% deep-dive box: \dd[colour]{Title}{body}
\newcommand\dd[3][sheetBlue]{\par\noindent\fcolorbox{#1}{#1!4}{\parbox{\dimexpr\linewidth-2\fboxsep-2\fboxrule\relax}{\raggedright{\bfseries\color{#1!85!black}#2}\par\vspace{1pt}#3}}\par\vspace{3pt}}
\newcommand\ct[1]{\texttt{#1}}
\newcommand\say{\textcolor{sheetGreen!60!black}{\textbf{say:}}~}
\newcommand\trapin{\textcolor{sheetRed}{\textbf{trap:}}~}

\begin{document}

\sheettitle{iOS system design — the deep dives}{interview · memo}

\oneliner{After the boxes-and-arrows, the interviewer picks one box and asks \emph{``how exactly?''}.
Score by naming the \textbf{mechanism}: the cache \textbf{key} and \textbf{eviction}, the reconnect \textbf{state machine},
the \textbf{idempotency key}, the \textbf{ordering authority}, the \textbf{conflict policy} and how you \emph{detect} a conflict,
the \textbf{cursor}, the flag's \textbf{default when nothing was fetched}, and what leaves the device \textbf{privately}.}

\vspace{3pt}
\noindent\begin{tikzpicture}[sheet,
    s/.style={box, font=\scriptsize, minimum height=6mm, inner sep=2pt},
    g/.style={s, draw=sheetGreen, fill=sheetGreen!8},
    r/.style={s, draw=sheetRed, fill=sheetRed!6},
    o/.style={s, draw=sheetOrange, fill=sheetOrange!8},
    l/.style={font=\tiny, text=black!75, align=center, inner sep=1pt}]
  % ---- WebSocket connection state machine
  \node[font=\bfseries\small, text=sheetBlue, anchor=west] at (-0.2,2.05) {WebSocket lifecycle + reconnect};
  \node[s] (idle) at (0.5,1.1) {idle};
  \node[s] (conn) at (2.9,1.1) {connecting};
  \node[g] (live) at (5.6,1.1) {\textbf{live}};
  \node[o] (wait) at (2.9,-0.7) {waiting $d_n$};
  \node[s, draw=sheetGrey, fill=black!4] (bg) at (5.6,-0.7) {suspended};
  \draw[hot] (idle) -- node[l, above]{foreground\\+ network} (conn);
  \draw[hot] (conn) -- node[l, above]{open + auth} (live);
  \draw[hot, sheetRed] ([xshift=-3mm]conn.south) -- node[l, left]{fail} ([xshift=-3mm]wait.north);
  \draw[hot] ([xshift=3mm]wait.north) -- node[l, right]{timer ·\\path up} ([xshift=3mm]conn.south);
  \draw[hot, sheetRed] (live.south west) -- node[l, pos=0.45, below, sloped]{drop / 2 pings missed} (wait.north east);
  \draw[flow] (live) -- node[l, right]{background} (bg);
  \draw[flow] (bg) -- node[l, below]{APNs / fg} (wait);
  \node[l, anchor=north west, text=sheetGreen!50!black, align=left] at (6.35,1.45) {on \textbf{live}:\\1 resume \ct{since: lastSeq}\\2 flush outbox (in order)\\3 reset $n$ after $\sim$30\,s stable};
  \node[l, anchor=north east, text=sheetOrange, align=right] at (2.1,-0.45) {$d_n = \mathrm{rand}(0,\ \min(cap,\ base\cdot 2^n))$\\full jitter: no thundering herd};
  % ---- message delivery states
  \node[font=\bfseries\small, text=sheetBlue, anchor=west] at (9.0,2.05) {Message delivery + outbox};
  \foreach \n/\t/\x/\st in {c/composed/9.4/s, se/{sending\\\tiny local row}/11.15/o, sn/{sent\\\tiny serverSeq}/12.9/g, dl/delivered/14.55/g, rd/read/16.0/g}
    \node[\st] (\n) at (\x,1.1) {\t};
  \draw[flow] (c) -- (se); \draw[hot] (se) -- node[l, above]{ack} (sn);
  \draw[flow] (sn) -- (dl); \draw[flow] (dl) -- (rd);
  \node[r] (fl) at (11.15,-0.7) {failed};
  \draw[hot, sheetRed] ([xshift=-2.5mm]se.south) -- node[l, left]{no ack in T} ([xshift=-2.5mm]fl.north);
  \draw[hot] ([xshift=2.5mm]fl.north) -- node[l, right, align=left]{retry, backoff,\\\textbf{same} client UUID} ([xshift=2.5mm]se.south);
  \node[l, anchor=west, align=left, text=sheetGrey] at (13.2,-0.6) {persisted \textbf{outbox}: FIFO,\\survives a kill, flushed on \textbf{live}};
  \node[l, anchor=west, align=left] at (9.0,-1.4) {client UUID = idempotency key → server dedupes a retried send;\\order by \textbf{server seq}, never the device clock; seq gap → fetch the gap};
  \draw[sheetGrey!50] (8.75,2.2) -- (8.75,-1.7);
\end{tikzpicture}

\begin{multicols}{2}
\footnotesize\setstretch{1.0}

\dd{1 · Image cache}{%
\textbf{Tiers}: memory \ct{NSCache} of \emph{decoded, downsampled} images → disk (bytes in \ct{Caches/}) → network. Hits are hoisted up.
\textbf{Key}: URL + target \emph{pixel} size (+ transform); disk filename = SHA-256(URL) — never \ct{hashValue} (seeded per run).
\textbf{Eviction}: \ct{totalCostLimit} with cost = w·h·4 bytes; NSCache also purges under memory pressure but is \emph{not} strict LRU. Disk: byte cap + age, LRU sweep off-main; never \ct{Documents/} (backed up).
\textbf{Downsample} with ImageIO to points × scale: a 4000×3000 photo is \textbf{48\,MB} decoded, whatever its JPEG size.
\textbf{Reuse}: cancel in \ct{prepareForReuse} \emph{and} check the cell still wants that URL. \textbf{Dedupe} in-flight by key; cancel the download only when its last observer leaves. Cap decode concurrency; prefetch at low priority, cancellable.\par
\trapin \ct{URLCache} alone caches encoded bytes, not the decoded bitmap — decode still hits every scroll.}
\begin{lstlisting}[language=SwiftSheet]
actor ImageLoader {
  private let memory = NSCache<NSString, UIImage>()
  private var inflight: [String: Task<UIImage, Error>] = [:]
  func image(_ url: URL, px: Int) async throws -> UIImage {
    let key = "\(url.absoluteString)@\(px)"
    if let hit = memory.object(forKey: key as NSString) { return hit }
    if let running = inflight[key] { return try await running.value }
    let task = Task { try await diskOrNetwork(url, downsampleTo: px) }
    inflight[key] = task; defer { inflight[key] = nil }
    let img = try await task.value
    memory.setObject(img, forKey: key as NSString, cost: px * px * 4)
    return img
  }
}
\end{lstlisting}

\dd{2 · Chat / realtime}{%
\textbf{Transport}: \ct{URLSessionWebSocketTask} in the foreground — re-arm \ct{receive()} after every message, \ct{sendPing} every ~25\,s (cellular leaves half-open TCP), reconnect on \ct{NWPathMonitor}. iOS kills sockets in the background → \textbf{APNs} wakes / notifies; reconnect + catch up on foreground.
\textbf{Ordering}: per-conversation \textbf{server seq}; pending rows sort locally until acked. \textbf{Idempotency}: client UUID per message; server returns the existing row on a repeat; client dedupes incoming by id.
\textbf{Outbox}: persisted FIFO (survives kill), flushed in order on \textbf{live}; after N failures → \emph{failed} + tap to retry (no poison-pill blocking the queue).
\textbf{Receipts}: events keyed by message id, batched per screen. \textbf{Typing}: ephemeral, debounced start/stop, TTL ~5\,s, never stored.
\trapin ``sent'' only proves the ack — a missing \ct{since} cursor on reconnect is the usual lost-message bug.}

\section{Interview traps}
\begin{itemize}
  \trap{Reconnect with a fixed delay — a server blip becomes a synchronized reconnect storm.}
  \trap{Ordering, or LWW, on device timestamps.}
  \trap{Batch sync all-or-nothing — one bad record blocks the queue forever.}
  \trap{Deleting rows locally instead of tombstoning — the delete never syncs.}
  \trap{Offset pagination on a live list; loading only when the last row is visible.}
\end{itemize}

\vspace{2pt}\noindent\colorbox{sheetOrange!12}{\parbox{\dimexpr\linewidth-2\fboxsep}{\textbf{Remember:} key + eviction · state machine + jittered backoff · UUID idempotency · server seq · cursor · version \emph{detects}, policy \emph{resolves} · safe default.}}

\section{Likely questions}
\begin{enumerate}
  \item Wrong image in a cell? — late completion after reuse: cancel + URL check.
  \item Message sent twice after a timeout? — lost ack; client UUID, server dedupe.
  \item Two devices edit the same note offline? — version mismatch → named policy → both converge.
  \item Flag value at first launch? — the baked default (then the cached one).
\end{enumerate}

\columnbreak

\dd[sheetGreen]{3 · Offline-first sync}{%
\textbf{Local store is truth}; UI observes it; writes are local + optimistic. Client-generated UUIDs → create offline.
\textbf{Change tracking}: dirty flag + \ct{updatedAt} (simple, loses intent) or an \textbf{oplog} (ordered ops, richer merges).
\textbf{Cycle}: push dirty ops (idempotent: \emph{set} with expected version, not \emph{increment}) → clear dirty per \textbf{acked record} → pull \ct{changes?since=cursor} → apply → advance cursor in the same transaction.
Apply rule: absent → insert · local clean → fast-forward · \textbf{local dirty → conflict}.
\textbf{Detect} with a server version / ETag (\ct{If-Match} → 412), or version vectors — not a clock.
\textbf{Resolve}: \emph{server-wins} (simplest, drops local edit) · \emph{LWW} (silently drops the loser; skewed clock always wins) · \emph{field-level merge} · \emph{keep both + prompt} · \emph{CRDT} (collab text, converges in any order).
\textbf{Deletes = tombstones}, GC after a window (a device offline longer can resurrect).
\textbf{Clock skew}: device time is a display hint; order by server revision or a hybrid logical clock.
Triggers: foreground, path restored, \ct{BGAppRefreshTask}, silent push; short + resumable.}

\noindent\begin{tikzpicture}[sheet,
    n/.style={box, draw=sheetGreen, fill=sheetGreen!8, font=\scriptsize, minimum height=6mm, inner sep=2pt},
    l/.style={font=\tiny, text=black!75, align=center, inner sep=1pt}]
  \node[n, very thick, draw=sheetOrange, fill=sheetOrange!10] (db) at (0,0) {\textbf{local store}\\dirty · tombstones · cursor};
  \node[n] (push) at (3.1,0.55) {push dirty (key, ver)};
  \node[n] (pull) at (6.3,0.55) {pull \ct{since cursor}};
  \node[n] (res) at (6.3,-0.55) {dirty? → \textbf{resolve}};
  \node[n] (adv) at (3.1,-0.55) {apply + advance cursor};
  \draw[hot] (db.north east) |- (push);
  \draw[hot] (push) -- node[l, above]{acked → clean} (pull);
  \draw[hot] (pull) -- node[l, left, text=sheetRed]{local dirty +\\version mismatch} (res);
  \draw[hot] (res) -- node[l, below]{one transaction} (adv);
  \draw[hot] (adv) -| (db.south east);
\end{tikzpicture}
\vspace{2pt}

\dd[sheetGreen]{4 · Pagination}{%
\begin{tabular}{@{}p{0.16\linewidth}p{0.36\linewidth}p{0.4\linewidth}@{}}
& \textbf{offset} \ct{?page=3} & \textbf{cursor} \ct{?after=opaque}\\
live inserts & shift rows → \textbf{dupes / gaps} & stable (keyset \ct{WHERE id < c})\\
jump to page & yes & no — sequential only\\
DB cost & scans \ct{OFFSET} rows & index seek\\
\end{tabular}\par
\textbf{Prefetch threshold}: fetch when a row \textasciitilde5--10 from the end appears (or \ct{prefetchItemsAt}); \ct{guard !isLoading, nextCursor != nil}; dedupe by id; \ct{nextCursor == nil} = end. Chat needs \emph{both} directions (\ct{before:} history, \ct{after:} catch-up). Expired cursor → refetch from the top.}

\dd[sheetOrange]{5 · Feature flags + remote config}{%
\textbf{Resolve in one provider}: debug override → targeting → remote (\textbf{cached last-known-good} on disk) → \textbf{baked default}. First launch / offline = the default, so every flag's default must be safe. Fetch on launch but \textbf{activate at a safe point} (next launch / screen entry) — no mid-screen flips.
\textbf{Kill switch}: remote, no App Review; its cached value must persist so an unreachable server does not revive the broken path.
\textbf{Rollout}: \ct{hash(userID + flagKey) \% 100 < pct} — stable bucket, ramp 1→10→100\,\% watching crash-free rate.
Client flag = UX, \textbf{not} security: entitlements are enforced server-side. Test \emph{both} branches; every flag gets an owner + expiry.}

\dd[sheetBrown]{6 · Analytics / logging pipeline}{%
Typed events (\ct{enum}, schema-versioned) + context (session, app version, event UUID) → in-memory buffer → \textbf{persisted queue} (SQLite / file) → \textbf{batch} upload every N events or T seconds and on background (\ct{beginBackgroundTask}), gzip, backoff; server dedupes by event UUID. Cap the queue, drop oldest; sample high-volume events.
\textbf{Privacy}: no PII in events (hash / drop), consent gate before sending, ATT only for cross-app tracking, declare data in \ct{PrivacyInfo.xcprivacy}; \ct{Logger} interpolations are \ct{.private} by default for dynamic strings. Crashes + hangs: MetricKit.}

\end{multicols}

\noindent{\footnotesize\color{sheetGrey}\textit{Related:} ios-client-system-design (framework + feed) · concurrency (actors, cancellation) · persistence · urlsession-networking · instruments-performance · live-coding-strategy}

\end{document}
