RADIO = Requirements → Architecture → Data Model → Interface (API) → Optimizations
| Step | % Time | Minutes |
|---|---|---|
| Requirements | <15% | ~8 min |
| Architecture | ~20% | ~12 min |
| Data Model | ~10% | ~6 min |
| Interface/API | ~15% | ~9 min |
| Optimizations | ~40% | ~25 min |
- Should we support multi-user real-time collaboration (like Google Docs)?
- Which cell data types: text, numbers, formulas, dates, booleans?
- Do we need formula evaluation (SUM, VLOOKUP, etc.)?
- Should we support formatting (bold, colors, borders)?
- Do we need charts, conditional formatting, data validation?
- What is the max sheet size? (e.g., 10M cells)
- Import/export (CSV, XLSX)?
- Render a large 2D grid of rows and columns
- Cell selection (single, range, multi-range)
- Inline cell editing
- Formula support:
=SUM(A1:A10),=IF(), cell references - Copy/paste, undo/redo
- Column/row resize, freeze rows/columns
- Basic formatting: bold, italic, font size, background color
- Auto-save
- Handle spreadsheets with up to 1 million rows smoothly
- Formula recalculation < 100ms for simple sheets
- Collaborative edits with < 300ms conflict resolution
- Offline support with sync on reconnect
┌──────────────────────────────────────────────────────────────┐
│ Browser Client │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Sheet UI Layer │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ Canvas/Grid │ │ Formula Bar │ │ Toolbar │ │ │
│ │ │ Renderer │ │ Component │ │ (Format UI) │ │ │
│ │ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ │
│ └─────────┼─────────────────┼─────────────────┼──────────┘ │
│ │ │ │ │
│ ┌─────────▼─────────────────▼─────────────────▼──────────┐ │
│ │ Sheet Controller │ │
│ │ (Handles input events, selection, clipboard, undo/redo)│ │
│ └────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌────────────────────────▼───────────────────────────────┐ │
│ │ Sheet Store (Client) │ │
│ │ ┌───────────────┐ ┌────────────────┐ │ │
│ │ │ Cell Data │ │ Formula Engine│ │ │
│ │ │ (sparse map) │ │ (dependency │ │ │
│ │ │ │ │ graph + eval)│ │ │
│ │ └───────────────┘ └────────────────┘ │ │
│ │ ┌───────────────┐ ┌────────────────┐ │ │
│ │ │ Undo/Redo │ │ Selection │ │ │
│ │ │ Stack │ │ State │ │ │
│ │ └───────────────┘ └────────────────┘ │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Collaboration Layer (OT/CRDT) │ │
│ │ WebSocket client + operation queue │ │
│ └───────────────────────┬────────────────────────────────┘ │
└──────────────────────────┼───────────────────────────────────┘
│ WebSocket / HTTP
┌────────────▼────────────┐
│ Server │
│ (REST + WS endpoints) │
└─────────────────────────┘
| Component | Responsibility |
|---|---|
| Canvas/Grid Renderer | Renders visible cells only (virtualization). Uses <canvas> or virtual DOM rows. |
| Formula Bar | Shows raw formula/value of active cell. Supports formula editing with autocomplete. |
| Toolbar | Formatting controls: bold, font, color, alignment. |
| Sheet Controller | Processes keyboard/mouse events, manages selection, delegates to store. |
| Sheet Store | Source of truth for all cell data, formats, metadata. Sparse Map: {row}:{col} → Cell. |
| Formula Engine | Parses formulas into ASTs, builds dependency graph (DAG), evaluates cells in topological order. |
| Collaboration Layer | Transforms concurrent ops (OT) or merges CRDTs. Maintains op log for sync. |
// Core cell object (stored sparse - only non-empty cells)
interface Cell {
row: number;
col: number;
rawValue: string; // What user typed: "=SUM(A1:A3)" or "hello" or "42"
computedValue: string | number | boolean | null; // Evaluated result
format?: CellFormat;
dataType: 'string' | 'number' | 'boolean' | 'formula' | 'error';
}
interface CellFormat {
bold?: boolean;
italic?: boolean;
underline?: boolean;
fontSize?: number;
fontColor?: string; // hex
backgroundColor?: string; // hex
textAlign?: 'left' | 'center' | 'right';
numberFormat?: string; // e.g. "0.00", "$#,##0"
borders?: BorderConfig;
}
interface Sheet {
id: string;
name: string;
cells: Map<string, Cell>; // key = "row:col", sparse
rowMetadata: Map<number, RowMeta>; // height, hidden
colMetadata: Map<number, ColMeta>; // width, hidden
frozenRows: number;
frozenCols: number;
}
interface Spreadsheet {
id: string;
title: string;
sheets: Sheet[];
collaborators: Collaborator[];
lastModified: number;
}interface SelectionState {
activeCell: { row: number; col: number };
selectionRange: Range | null; // { startRow, startCol, endRow, endCol }
multiSelections: Range[];
}
interface UndoRedoStack {
undoStack: Operation[];
redoStack: Operation[];
}
interface ViewportState {
scrollTop: number;
scrollLeft: number;
visibleRowStart: number;
visibleRowEnd: number;
visibleColStart: number;
visibleColEnd: number;
}
interface EditingState {
isEditing: boolean;
editCell: { row: number; col: number } | null;
draftValue: string;
}interface FormulaNode {
type: 'literal' | 'cell_ref' | 'range_ref' | 'function' | 'operator';
value?: any;
args?: FormulaNode[];
}
interface DependencyGraph {
// Cell A depends on cells B, C — if B changes, re-eval A
dependents: Map<string, Set<string>>; // "B1" → {"A1", "C3"}
dependencies: Map<string, Set<string>>; // "A1" → {"B1", "C1"}
}GET /api/spreadsheets/:id
→ { spreadsheet: Spreadsheet }
GET /api/spreadsheets/:id/sheets/:sheetId/cells?rowStart=0&rowEnd=100&colStart=0&colEnd=26
→ { cells: Cell[] } // paginated/windowed fetch
PATCH /api/spreadsheets/:id/sheets/:sheetId/cells
Body: { operations: CellOperation[] }
→ { version: number, applied: Operation[] }
POST /api/spreadsheets/:id/sheets
Body: { name: string }
→ { sheet: Sheet }
// Client → Server
{ type: 'op', sheetId: string, op: Operation, baseVersion: number }
// Server → Client (broadcast to collaborators)
{ type: 'op', op: Operation, version: number, authorId: string }
{ type: 'presence', userId: string, cell: {row: number, col: number} }
// Operation types
type Operation =
| { type: 'SET_CELL'; row: number; col: number; value: string; format?: CellFormat }
| { type: 'DELETE_CELLS'; range: Range }
| { type: 'INSERT_ROW'; afterRow: number; count: number }
| { type: 'DELETE_ROW'; row: number; count: number }
| { type: 'MOVE_CELLS'; from: Range; to: { row: number; col: number } }// Sheet Store public interface
interface SheetStore {
getCell(row: number, col: number): Cell | undefined;
setCellValue(row: number, col: number, value: string): void;
getCellsInRange(range: Range): Cell[];
applyFormat(range: Range, format: Partial<CellFormat>): void;
undo(): void;
redo(): void;
pasteFromClipboard(targetCell: { row: number; col: number }): void;
}Rendering millions of cells with real DOM nodes is impossible. Use windowed rendering — only render visible rows/cols + overscan buffer.
Total rows: 1,000,000
Visible rows at once: ~30
Row height: 25px
Rendered DOM nodes: ~40 rows × ~20 cols = 800 nodes
(vs 1M × 20 = 20M without virtualization)
Canvas rendering (used by Google Sheets) is even faster:
- Draw cell backgrounds, borders, text directly to
<canvas> - Single repaint for entire viewport
- Overlay
<input>only on active cell for editing
Scroll handling:
// Translate canvas origin based on scroll position
// Only re-render if visible rows/cols change
onScroll(scrollTop, scrollLeft) {
const newRowStart = Math.floor(scrollTop / ROW_HEIGHT);
if (newRowStart !== this.visibleRowStart) {
this.visibleRowStart = newRowStart;
this.render(); // Only re-render when viewport changes
}
}Input: "=SUM(A1:B3) + C1*2"
1. Tokenize → [FUNC:SUM, LPAREN, RANGE:A1:B3, RPAREN, PLUS, REF:C1, MULT, NUM:2]
2. Parse → AST
3. Build dependency edges: this cell depends on A1,A2,A3,B1,B2,B3,C1
4. Evaluate: DFS on AST, resolve cell refs from store
5. Store computed value
6. On change to A1: look up dependents, re-evaluate in topological order
Circular dependency detection: DFS with visited set — throw #CIRC! error.
Web Worker offloading: Run formula evaluation in a Web Worker to avoid blocking UI thread during heavy recalculation.
Operational Transformation ensures consistency when two users edit simultaneously:
User A (local): SET_CELL(B2, "100") version=5
User B (remote): INSERT_ROW(after=1) version=5
After OT transform:
A's op on B's state: SET_CELL(B3, "100") ← row shifted down
B's op on A's state: INSERT_ROW(after=1) ← unchanged
Or use CRDTs (Conflict-free Replicated Data Types) for simpler conflict resolution at the cost of more memory.
| Technique | Detail |
|---|---|
| Sparse data structure | Use Map<string, Cell> not 2D array — only store non-empty cells |
| Dirty cell tracking | Only recompute cells whose inputs changed |
| Batch updates | Debounce PATCH requests (300ms), send delta ops not full sheet |
| Incremental rendering | Re-render only changed cells via requestAnimationFrame |
| Web Workers | Formula recalc, CSV import/export off main thread |
| IndexedDB | Cache sheet data locally for offline and fast cold start |
interface Operation {
do(): void;
undo(): void;
}
class SetCellOperation implements Operation {
constructor(
private store: SheetStore,
private row: number, private col: number,
private newValue: string,
private oldValue: string
) {}
do() { this.store.setCellRaw(this.row, this.col, this.newValue); }
undo() { this.store.setCellRaw(this.row, this.col, this.oldValue); }
}Collaborative undo is hard — in Google Sheets, undo is local-first (undoes your own operations only, not other users').
- Formula injection: sanitize/escape cell values rendered as HTML
- CSV injection: when exporting, prefix formulas with apostrophe
- Server-side formula evaluation for untrusted inputs
- CSRF protection on all mutation endpoints
- What core flows: send money, receive money, transaction history, wallet/balance?
- Do we need to handle multiple currencies and exchange rates?
- What payment methods: bank accounts, credit cards, PayPal balance?
- Should we design the checkout/payment button embed (SDK)?
- KYC/identity verification needed?
- Mobile-first or responsive web?
- Two-factor authentication flows?
- User dashboard: balance, recent transactions
- Send money to another user (email/phone)
- Request money from another user
- Transaction history with filters (date, amount, type)
- Add/manage payment methods (bank, card)
- PayPal.me personal payment link
- Notifications for payment received/sent
- Security-first: PCI-DSS compliance, end-to-end encryption
- Transaction confirmation < 2s
- 99.99% uptime (financial system)
- Idempotent payment APIs (no double-charges)
- Full audit trail for all financial operations
┌──────────────────────────────────────────────────────────────────┐
│ Browser Client │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ View Layer │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌──────────────────┐ │ │
│ │ │ Dashboard │ │ Send Money │ │ Transaction │ │ │
│ │ │ (Balance + │ │ Flow │ │ History │ │ │
│ │ │ Recent TX) │ │ (Stepper) │ │ (Paginated) │ │ │
│ │ └─────────────┘ └─────────────┘ └──────────────────┘ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌──────────────────┐ │ │
│ │ │ Wallet / │ │ Request │ │ Notifications │ │ │
│ │ │ Payment │ │ Money │ │ Panel │ │ │
│ │ │ Methods │ │ Flow │ │ │ │ │
│ │ └─────────────┘ └─────────────┘ └──────────────────┘ │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────▼──────────────────────────────┐ │
│ │ Payment Controller │ │
│ │ (Multi-step form orchestration, validation) │ │
│ └─────────────────────────────┬──────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────▼──────────────────────────────┐ │
│ │ Client Store │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │ │
│ │ │ User/Auth │ │ Transactions│ │ Payment │ │ │
│ │ │ State │ │ Cache │ │ Methods Cache │ │ │
│ │ └──────────────┘ └──────────────┘ └─────────────────┘ │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────▼──────────────────────────────┐ │
│ │ Security Layer: HTTPS-only, CSP, token rotation, 2FA │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
│ HTTPS (TLS 1.3)
┌──────────────────▼──────────────────────┐
│ API Gateway │
│ Auth ─── Payment Service ─── Notif. │
└─────────────────────────────────────────┘
- No client-side balance modification: All balance reads are fresh from server; never trust client-computed balance.
- Idempotency keys: Every payment request includes a client-generated idempotency key to prevent duplicate charges on retry.
- Multi-step send flow: Amount → Recipient → Review → Confirm → Success — never a single form submit.
interface User {
id: string;
email: string;
fullName: string;
profilePhotoUrl?: string;
balance: {
amount: number;
currency: string;
};
isVerified: boolean;
}
interface Transaction {
id: string;
type: 'SEND' | 'RECEIVE' | 'REQUEST' | 'REFUND' | 'WITHDRAWAL' | 'DEPOSIT';
status: 'PENDING' | 'COMPLETED' | 'FAILED' | 'CANCELLED';
amount: number;
currency: string;
fromUser: UserSummary;
toUser: UserSummary;
note?: string;
createdAt: number;
completedAt?: number;
paymentMethodId?: string;
idempotencyKey: string;
}
interface PaymentMethod {
id: string;
type: 'BANK_ACCOUNT' | 'CREDIT_CARD' | 'DEBIT_CARD' | 'PAYPAL_BALANCE';
displayName: string; // "Visa ending in 4242"
isDefault: boolean;
isVerified: boolean;
// Never store raw card numbers client-side
}
interface UserSummary {
id: string;
name: string;
email: string;
profilePhotoUrl?: string;
}interface SendMoneyDraft {
step: 'amount' | 'recipient' | 'review' | 'confirm';
amount: number | null;
currency: string;
recipientQuery: string;
resolvedRecipient: UserSummary | null;
selectedPaymentMethodId: string | null;
note: string;
idempotencyKey: string; // generated on flow start, persisted across retries
}
interface TransactionFilters {
dateRange: { from: Date; to: Date } | null;
type: Transaction['type'] | 'ALL';
amountMin: number | null;
amountMax: number | null;
}GET /api/v1/me
→ { user: User }
GET /api/v1/me/balance
→ { balance: { amount: number, currency: string }, asOf: timestamp }
GET /api/v1/transactions
?cursor=xxx&size=20&type=SEND&dateFrom=2024-01-01&dateTo=2024-12-31
→ {
transactions: Transaction[],
pagination: { nextCursor: string | null, hasMore: boolean }
}
GET /api/v1/transactions/:id
→ { transaction: Transaction }
# Step 1: Look up recipient
GET /api/v1/users/search?q=john@example.com
→ { users: UserSummary[] }
# Step 2: Initiate payment
POST /api/v1/payments
Headers: { 'Idempotency-Key': 'client-uuid-v4' }
Body: {
toUserId: string,
amount: number,
currency: string,
paymentMethodId: string,
note?: string
}
→ {
transaction: Transaction,
requiresVerification: boolean, // triggers 2FA if true
verificationToken?: string
}
# Step 3: Confirm with 2FA (if required)
POST /api/v1/payments/:transactionId/verify
Body: { code: string, verificationToken: string }
→ { transaction: Transaction } // status: COMPLETED
GET /api/v1/payment-methods
→ { methods: PaymentMethod[] }
POST /api/v1/payment-methods
Body: { type: string, token: string } // token from payment tokenization service
→ { method: PaymentMethod }
DELETE /api/v1/payment-methods/:id
→ { success: true }
┌─────────────────────────────────────────────────┐
│ Security Layers │
│ │
│ Transport: TLS 1.3, HSTS, Certificate Pinning │
│ Auth: JWT (short-lived 15min) + refresh tokens │
│ 2FA: TOTP (authenticator app) or SMS OTP │
│ CSRF: SameSite=Strict cookies + CSRF tokens │
│ CSP: Strict Content-Security-Policy headers │
│ Sensitive Data: Never in localStorage │
│ Use httpOnly cookies only │
└─────────────────────────────────────────────────┘
PCI DSS compliance: Never touch raw card numbers. Use a PCI-compliant JS library (Braintree.js, Stripe.js) that tokenizes cards in an iframe from the payment provider's domain. Only the token reaches your server.
// Generate UUID once at start of send flow
const idempotencyKey = crypto.randomUUID();
// On retry (network failure), send the SAME key
async function sendPayment(payload) {
return fetch('/api/v1/payments', {
method: 'POST',
headers: { 'Idempotency-Key': idempotencyKey },
body: JSON.stringify(payload)
});
// Server returns same transaction if key already processed — no double charge
}For financial operations, pessimistic UI is correct:
- Do NOT show success before server confirms
- Show loading spinner during payment processing
- Only update transaction list after server 200 response
- Show full error details on failure with retry option
Exception: You can optimistically add a "Pending" transaction to the list while waiting for server, but clearly label it as pending.
Infinite scroll with cursor-based pagination:
- Cursor: opaque token encoding last TX id + timestamp
- 20 items per page
- Cache fetched pages in client store
- Stale-while-revalidate: show cached data immediately, refresh in background
- Virtual list for long histories (react-window)
States: idle → amount → recipient → review → confirming → success | error
Transitions:
amount + NEXT → recipient
recipient + NEXT → review
review + SUBMIT → confirming
confirming + API_SUCCESS → success
confirming + API_ERROR → error (retry available, same idempotency key)
any + CANCEL → idle (clear draft)
Key: Lock "Back" button during confirming state
Show session timeout warning if user idle > 10 min
- All form fields with proper
<label>associations - Error messages linked to inputs via
aria-describedby - Currency amounts announced correctly by screen reader: "One hundred twenty-three dollars and 45 cents"
- Focus management through multi-step flow: auto-focus first field of each step
- Sufficient color contrast for all transaction status badges
- Core features: map display, search, directions, or all three?
- What travel modes: driving, walking, transit, cycling?
- Do we need real-time traffic/live ETAs?
- Should we support offline maps?
- Street View? Satellite/terrain views?
- Location sharing between users?
- Custom map markers / business listings / reviews?
- Mobile web vs. desktop?
- Interactive map: pan, zoom (pinch/scroll), map style toggle (road/satellite)
- Search for places (autocomplete + results)
- Place detail panel (name, address, hours, photos, reviews)
- Get directions: A → B, multi-stop routes
- Turn-by-turn navigation mode
- Current location (GPS)
- Save places (favorites)
- Map tiles load within 500ms on 4G
- Autocomplete results < 100ms
- Smooth 60fps pan/zoom
- Offline capability for saved maps
- Map loads progressively (tiles appear as they arrive)
┌──────────────────────────────────────────────────────────────────┐
│ Browser Client │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ View Layer │ │
│ │ ┌──────────────────────────────────────────────────────┐ │ │
│ │ │ Map Canvas │ │ │
│ │ │ (WebGL/Canvas tile renderer + marker overlay layer) │ │ │
│ │ └──────────────────────────────────────────────────────┘ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │ │
│ │ │ Search Box │ │ Place Detail│ │ Directions │ │ │
│ │ │ (Autocomplete│ │ Panel │ │ Panel │ │ │
│ │ │ overlay) │ │ (sidebar) │ │ (route steps) │ │ │
│ │ └──────────────┘ └──────────────┘ └─────────────────┘ │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────▼──────────────────────────────┐ │
│ │ Map Controller │ │
│ │ (Pan/zoom events, URL state sync, mode switching) │ │
│ └──────────────┬────────────────────────┬────────────────────┘ │
│ │ │ │
│ ┌──────────────▼──────────┐ ┌─────────▼──────────────────┐ │
│ │ Tile Manager │ │ Map Store (Client) │ │
│ │ (fetch, cache, render) │ │ searchResults, route, │ │
│ │ LRU cache for tiles │ │ selectedPlace, viewport, │ │
│ │ IndexedDB for offline │ │ savedPlaces, userLocation │ │
│ └──────────────┬──────────┘ └────────────────────────────┘ │
└─────────────────┼────────────────────────────────────────────────┘
│ CDN (tiles) / HTTPS (API)
┌──────────▼──────────────────────────────────┐
│ Map Tile CDN │ Places API │ Routes API │
└─────────────────────────────────────────────┘
Google Maps uses WebGL (via Mapbox GL or custom) for:
- Hardware-accelerated tile rendering
- Smooth 60fps tilting and rotation
- 3D buildings layer
- Vector tiles (scalable, styleable) vs. raster tiles (pre-rendered PNG)
interface Viewport {
center: LatLng;
zoom: number; // 0-22
bearing: number; // rotation 0-360
tilt: number; // 3D tilt 0-60
bounds: LatLngBounds; // computed from center+zoom+screen size
}
interface LatLng {
lat: number;
lng: number;
}
interface LatLngBounds {
sw: LatLng;
ne: LatLng;
}
interface Place {
placeId: string;
name: string;
address: string;
location: LatLng;
types: string[]; // ['restaurant', 'food', 'establishment']
rating?: number;
reviewCount?: number;
photos?: PlacePhoto[];
openingHours?: OpeningHours;
phone?: string;
website?: string;
priceLevel?: 1 | 2 | 3 | 4;
}
interface Route {
distanceMeters: number;
durationSeconds: number;
polyline: LatLng[]; // encoded path to draw on map
steps: RouteStep[];
legs: RouteLeg[];
travelMode: 'DRIVE' | 'WALK' | 'TRANSIT' | 'BICYCLE';
trafficCondition?: 'NORMAL' | 'SLOW' | 'TRAFFIC_JAM';
}
interface RouteStep {
instruction: string; // "Turn right onto Market St"
distanceMeters: number;
durationSeconds: number;
startLocation: LatLng;
endLocation: LatLng;
maneuver?: string; // 'turn-right', 'merge', 'uturn'
polyline: LatLng[];
}
interface MapTile {
x: number; // tile grid x
y: number; // tile grid y
z: number; // zoom level
url: string;
imageData?: ImageBitmap; // cached in memory
}interface MapClientState {
viewport: Viewport;
mode: 'explore' | 'search' | 'directions' | 'navigation';
searchQuery: string;
searchResults: Place[];
selectedPlace: Place | null;
route: Route | null;
activeRouteStep: number;
userLocation: LatLng | null;
savedPlaces: Place[]; // from server, cached
isFollowingUser: boolean; // navigation mode: auto-pan to user GPS
}GET /tiles/{z}/{x}/{y}?style=road&lang=en
→ image/png or image/webp
GET /tiles/vector/{z}/{x}/{y}.mvt
→ application/vnd.mapbox-vector-tile
(Vector tiles contain raw geometry, client styles them)
Tile URL generation:
// Slippy map tiles (OpenStreetMap convention)
function tileUrl(z, x, y) {
return `https://tile.example.com/${z}/${x}/${y}.png`;
}
// Convert lat/lng to tile coordinates at zoom z
function latLngToTile(lat, lng, z) {
const n = 2 ** z;
const x = Math.floor((lng + 180) / 360 * n);
const latRad = lat * Math.PI / 180;
const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1/Math.cos(latRad)) / Math.PI) / 2 * n);
return { x, y, z };
}GET /api/places/autocomplete?q=coffee&location=37.7,-122.4&radius=5000
→ {
predictions: [{
placeId: string,
description: string, // "Blue Bottle Coffee, Market St..."
matchedSubstrings: [{offset: 0, length: 4}]
}]
}
GET /api/places/:placeId
→ { place: Place }
GET /api/places/nearby?lat=37.7&lng=-122.4&radius=1000&type=restaurant
→ { places: Place[], nextPageToken?: string }
POST /api/directions
Body: {
origin: LatLng | string,
destination: LatLng | string,
waypoints?: (LatLng | string)[],
travelMode: 'DRIVE' | 'WALK' | 'TRANSIT' | 'BICYCLE',
departureTime?: number, // for transit schedules
avoidTolls?: boolean,
avoidHighways?: boolean
}
→ {
routes: Route[], // up to 3 route alternatives
status: 'OK' | 'NO_ROUTE' | 'NOT_FOUND'
}
On viewport change (pan/zoom):
1. Compute visible tiles for new viewport
2. Check memory cache (LRU, ~200 tiles)
3. Check browser cache (Cache-Control: max-age=86400)
4. Fetch missing tiles in parallel (up to 6 concurrent, per domain limit)
5. Prefetch adjacent tiles just outside viewport (overscan)
6. On zoom: immediately show scaled parent tile while loading new zoom tiles
Tile render priority:
Center tiles > edge tiles
Current zoom > adjacent zoom levels
Speculative loading for navigation:
User is traveling north → prefetch tiles ahead of route
Fetch next 2 route segments' tiles in advance
Routes can have thousands of lat/lng points. Google uses Encoded Polyline Algorithm to compress:
Raw: 37.7749,-122.4194, 37.7750,-122.4195, ... (thousands of coords)
Encoded: "_p~iF~ps|U_ulLnnqC_mqNvxq`@" (1/5 the size)
// Decode on client:
function decodePolyline(encoded: string): LatLng[] { ... }
// Use CSS transforms for instant visual feedback, re-render after settling
onPan(dx, dy) {
// Immediately: CSS translate the tile container
tileContainer.style.transform = `translate(${dx}px, ${dy}px)`;
// Debounced: update viewport state, load new tiles
debouncedUpdateViewport(dx, dy, 150);
}
// Use requestAnimationFrame for animation frames
// Use ResizeObserver to handle window resize
// Use IntersectionObserver to pause tile loading when tab not visibleUser action: "Download area for offline"
1. Calculate all tile URLs for area at zoom 10-15
2. Batch fetch tiles (show progress bar)
3. Store in IndexedDB via Cache API
Service Worker intercepts tile requests:
match in offline store → serve from cache
no match → network request (or show "offline" tile)
Place search offline:
Store recently searched places in IndexedDB
Fuzzy search against local store
const [query, setQuery] = useState('');
const debouncedSearch = useCallback(
debounce(async (q) => {
if (q.length < 2) return;
const results = await fetchAutocomplete(q);
setResults(results);
}, 150), // 150ms debounce — fast enough to feel instant
[]
);
// Cancel in-flight request if new keystroke arrives
useEffect(() => {
const controller = new AbortController();
fetchAutocomplete(query, controller.signal);
return () => controller.abort();
}, [query]);Encode viewport in URL for shareable links and browser back/forward:
/maps/@37.7749,-122.4194,15z → lat, lng, zoom
/maps/place/Caltrain+Station/@... → selected place
/maps/dir/San+Francisco/Palo+Alto → directions
// Sync viewport ↔ URL without full page reload
history.replaceState(null, '', buildMapUrl(viewport, selectedPlace, route));
window.addEventListener('popstate', restoreFromUrl);- Non-map users: all features available without mouse (keyboard tab + Enter)
- Screen reader: announce search results count, selected place details
- High contrast mode for map overlay UI elements
- Alternative to map: list view of search results with addresses
- Core features: 1:1 chat, group chat, voice/video calls, status?
- Media sharing: images, videos, documents, voice messages?
- Message delivery receipts (sent ✓, delivered ✓✓, read ✓✓blue)?
- End-to-end encryption (E2EE) — how detailed to go?
- Online/last seen presence?
- Message reactions, replies, forwarding?
- Web app only or desktop app (Electron)?
- Conversation list with last message preview and unread count
- Chat interface: message bubbles, timestamps, sender names (groups)
- Send/receive: text, images, videos, documents, voice notes
- Message status: sending → sent → delivered → read
- Real-time delivery via WebSocket
- Group chats (up to 256 members)
- Message search (within chat or global)
- Typing indicator ("John is typing...")
- Online/offline presence & last seen
- Messages delivered in < 500ms on good network
- Works on low-bandwidth connections (2G)
- End-to-end encrypted (Signal Protocol)
- Message queue: deliver messages when recipient comes online
- Sync across multiple devices (WhatsApp Web + phone)
┌──────────────────────────────────────────────────────────────────┐
│ Browser Client │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ View Layer │ │
│ │ ┌─────────────────┐ ┌──────────────────────────┐ │ │
│ │ │ Conversation │ │ Chat Window │ │ │
│ │ │ List │ │ ┌──────────────────────┐ │ │ │
│ │ │ (left panel) │ │ │ Virtual Message List│ │ │ │
│ │ │ - sorted by │ │ │ (scroll + load more)│ │ │ │
│ │ │ recency │ │ └──────────────────────┘ │ │ │
│ │ │ - unread badge │ │ ┌──────────────────────┐ │ │ │
│ │ │ - last preview │ │ │ Typing Indicator │ │ │ │
│ │ │ │ │ └──────────────────────┘ │ │ │
│ │ │ │ │ ┌──────────────────────┐ │ │ │
│ │ │ │ │ │ Message Input Bar │ │ │ │
│ │ │ │ │ │ (text, attach, mic) │ │ │ │
│ │ └─────────────────┘ │ └──────────────────────┘ │ │ │
│ │ └──────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────▼──────────────────────────────┐ │
│ │ Chat Controller │ │
│ │ (Message send, media upload orchestration, encryption) │ │
│ └───────────────────┬─────────────────────┬───────────────────┘ │
│ │ │ │
│ ┌───────────────────▼──────┐ ┌───────────▼─────────────────┐ │
│ │ Chat Store (Client) │ │ WebSocket Manager │ │
│ │ Conversations, Messages │ │ (connection, reconnect, │ │
│ │ Contacts, Media cache │ │ heartbeat, queue) │ │
│ └──────────────────────────┘ └─────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
│ WebSocket (wss://)
┌──────────▼─────────────────────────────┐
│ Messaging Server │
│ (WebSocket handler + message router) │
│ ← Connected to Message Queue, DB → │
└─────────────────────────────────────────┘
WhatsApp Web works by mirroring your phone. Modern architecture (multi-device) syncs via a shared encrypted message store on server, no phone dependency.
interface Conversation {
id: string;
type: 'direct' | 'group';
participants: UserSummary[];
lastMessage: MessagePreview;
unreadCount: number;
updatedAt: number;
// Group-only fields
groupName?: string;
groupAvatar?: string;
groupDescription?: string;
adminIds?: string[];
}
interface Message {
id: string; // client-generated UUID (for dedup)
conversationId: string;
senderId: string;
type: 'TEXT' | 'IMAGE' | 'VIDEO' | 'AUDIO' | 'DOCUMENT' | 'STICKER' | 'LOCATION';
content: MessageContent;
timestamp: number;
status: 'SENDING' | 'SENT' | 'DELIVERED' | 'READ' | 'FAILED';
replyTo?: string; // message id being replied to
reactions?: Reaction[];
isDeleted?: boolean;
isEdited?: boolean;
editedAt?: number;
}
type MessageContent =
| { type: 'TEXT'; text: string }
| { type: 'IMAGE'; url: string; thumbnailUrl: string; width: number; height: number; caption?: string }
| { type: 'VIDEO'; url: string; thumbnailUrl: string; duration: number; caption?: string }
| { type: 'AUDIO'; url: string; duration: number; waveform: number[] }
| { type: 'DOCUMENT'; url: string; fileName: string; fileSize: number; mimeType: string }
| { type: 'LOCATION'; lat: number; lng: number; label?: string }
interface MessagePreview {
id: string;
text: string; // truncated preview text
type: Message['type'];
timestamp: number;
}
interface UserPresence {
userId: string;
status: 'online' | 'offline';
lastSeen?: number; // unix timestamp if offline
isTyping?: boolean; // in specific conversation
}
interface Reaction {
emoji: string;
userIds: string[];
count: number;
}interface ChatDraft {
[conversationId: string]: string; // per-conversation draft text
}
interface UploadState {
file: File;
progress: number; // 0-100
uploadedUrl?: string;
status: 'pending' | 'uploading' | 'done' | 'error';
previewUrl: string; // local object URL for immediate preview
}
interface PendingMessages {
[messageId: string]: Message; // messages sent but not yet acked by server
}// Client → Server messages
type ClientMessage =
| { type: 'SEND_MESSAGE'; message: Message }
| { type: 'MESSAGE_ACK'; messageId: string; conversationId: string }
| { type: 'READ_RECEIPT'; conversationId: string; upToMessageId: string }
| { type: 'TYPING_START'; conversationId: string }
| { type: 'TYPING_STOP'; conversationId: string }
| { type: 'PING' } // heartbeat every 30s
// Server → Client messages
type ServerMessage =
| { type: 'NEW_MESSAGE'; message: Message }
| { type: 'MESSAGE_STATUS_UPDATE'; messageId: string; status: Message['status'] }
| { type: 'TYPING'; conversationId: string; userId: string; isTyping: boolean }
| { type: 'PRESENCE_UPDATE'; userId: string; status: string; lastSeen?: number }
| { type: 'PONG' }
| { type: 'SYNC_STATE'; conversations: Conversation[] } // on reconnect
// WebSocket URL
wss://api.example.com/ws?token=<jwt>GET /api/conversations?cursor=&size=20
→ { conversations: Conversation[], nextCursor: string | null }
GET /api/conversations/:id/messages?cursor=&size=50&direction=before
→ { messages: Message[], hasMore: boolean, cursor: string }
POST /api/media/upload
Body: FormData { file, type, conversationId }
→ { url: string, thumbnailUrl?: string, duration?: number }
GET /api/contacts?q=john&size=20
→ { contacts: UserSummary[] }
POST /api/conversations
Body: { participantIds: string[], type: 'direct' | 'group', groupName?: string }
→ { conversation: Conversation }
class WebSocketManager {
private ws: WebSocket;
private reconnectDelay = 1000;
private pingInterval: number;
private messageQueue: ClientMessage[] = []; // buffer while disconnected
connect() {
this.ws = new WebSocket(`wss://api.example.com/ws?token=${getToken()}`);
this.ws.onopen = () => {
this.reconnectDelay = 1000; // reset backoff
this.drainQueue(); // send buffered messages
this.startHeartbeat();
};
this.ws.onclose = () => this.scheduleReconnect();
}
scheduleReconnect() {
setTimeout(() => {
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000); // exponential backoff, cap 30s
this.connect();
}, this.reconnectDelay);
}
startHeartbeat() {
this.pingInterval = setInterval(() => {
this.send({ type: 'PING' });
}, 30000); // ping every 30s to keep connection alive
}
}User hits "Send":
1. Assign client-generated messageId (UUID)
2. Immediately add message to UI with status: SENDING
3. Add to pendingMessages store
4. Send via WebSocket: { type: 'SEND_MESSAGE', message }
5. Server ACKs → update status: SENT, remove from pending
6. Recipient online: DELIVERED status
7. Recipient opens chat: READ status (blue ticks)
On WebSocket disconnect:
→ Queue message in pendingMessages
→ Drain queue on reconnect
→ Server deduplicates by message.id
Chat histories can have thousands of messages. Use virtualized list:
Techniques:
- Only render ~20 visible messages + overscan buffer
- Dynamic row heights (text wraps, images vary)
- Maintain scroll position when prepending older messages
(sticky bottom for new msgs, preserve position when loading history)
- Intersection Observer to trigger "load older messages" when
user scrolls near top
- Group consecutive messages from same sender (no repeated avatar)
Challenge: "Scroll to bottom" on new message
→ Only auto-scroll if user is already at bottom (don't interrupt reading history)
→ Show "N new messages ↓" badge if user has scrolled up
Key concepts (for interview context):
Double Ratchet Algorithm:
- Combines Diffie-Hellman key exchange + symmetric ratchet
- Each message encrypted with unique key (forward secrecy)
- Compromising one message key doesn't expose past/future messages
X3DH (Extended Triple Diffie-Hellman):
- Used for initial key exchange between users
- Allows asynchronous key exchange (recipient offline)
Client responsibilities:
- Generate key pairs locally (never send private key to server)
- Store encrypted message keys in IndexedDB
- Decrypt messages only in browser (server sees ciphertext only)
- Display lock icon 🔒 to indicate E2EE
Server stores:
- Ciphertext only
- Public keys for key exchange
- NOT decryption keys
Image send flow:
1. User selects image
2. Show local preview immediately (createObjectURL)
3. Upload to CDN in background (multipart, show progress bar)
4. On upload complete: send message with CDN URL via WebSocket
5. Recipient receives message → lazy-load image from CDN
Low-bandwidth optimizations:
- Show blurhash placeholder while image loads
- Progressive JPEG / WebP with quality tiers
- Thumbnail-first: load 10% size preview, then full res
- Video: don't autoplay, show thumbnail + duration
- Auto-compress images > 5MB before upload
Voice messages:
- Record with MediaRecorder API
- Show waveform visualization (pre-computed amplitude data)
- Scrubbing support: click to seek
- Playback speed toggle (1x, 1.5x, 2x)
// Don't send typing event on every keystroke — throttle to 1/3s
const sendTypingStart = throttle(() => {
ws.send({ type: 'TYPING_START', conversationId });
}, 3000);
// Send typing stop 2s after last keystroke
const sendTypingStop = debounce(() => {
ws.send({ type: 'TYPING_STOP', conversationId });
}, 2000);
onKeyDown = () => {
sendTypingStart();
sendTypingStop();
};
// Server auto-expires typing state after 5s (in case TYPING_STOP never arrives)IndexedDB schema (persisted across sessions):
- conversations (IDBObjectStore)
- messages (IDBObjectStore, indexed by conversationId + timestamp)
- pendingMessages (IDBObjectStore — unsent messages)
- mediaCache (IDBObjectStore — recently viewed images)
On load:
1. Render conversations from IndexedDB immediately (instant UI)
2. Connect WebSocket
3. Request sync since last seen timestamp
4. Merge server data with local cache
5. Drain pending messages queue
Service Worker:
- Cache app shell (HTML, JS, CSS) for instant loads
- Intercept media CDN requests → serve from cache
Efficient unread counting:
- Server stores per-user watermark (lastReadMessageId per conversation)
- Unread = messages after watermark
- Batch READ_RECEIPT when user opens conversation
Member list:
- Don't load all 256 members upfront
- Load on-demand when user opens member list
- Paginate: 20 at a time
Message fan-out (server concern, worth mentioning):
- Server sends to each group member's WebSocket connection
- For large groups: message queue (Kafka) → individual push
| Product | Hardest Problem | Key Technique |
|---|---|---|
| Google Sheets | Rendering 1M rows, formula recalc | Canvas virtualization, Web Worker formula engine, OT for collab |
| PayPal | Security & no double-charge | Idempotency keys, pessimistic UI, PCI tokenization, httpOnly cookies |
| Google Maps | Tile loading, smooth pan/zoom | Tile CDN + LRU cache, vector tiles, WebGL, polyline encoding |
| Real-time delivery, offline | WebSocket + exponential backoff, IndexedDB queue, Signal Protocol E2EE |
Built with the RADIO Framework: Requirements → Architecture → Data Model → Interface → Optimizations