At a glance#
| Number | Value | What it drives |
|---|---|---|
| Daily users outside iOS | 411.1M (Q3 2026, preliminary) | ~1.2 unknown calls each a day (assumed) |
| Users, iOS and Android | 500M+ (2026); 350M+ in India, as reported | an Asia region serves most traffic |
| Spam calls identified | 68B+ in 2025 · ~2,150 / s | cross-checks ~1B lookups a day |
| Lookups | ~35,000 / s peak · 100,000 / s target | one key read with an 800 ms deadline |
| Directory | ~6B numbers · ~0.9 TB of records | replicate per region; hot set ~45 GB in Redis |
| Signals in | ~12,000 outcomes / s vs ~22 reports / s | a stream scores both; outcomes measure, reports name |
| On-device list | ~500k numbers · ~4.5 MB · ~225 KB a day | blocks offline; signed base plus deltas |
| iPhone lookup | two queries a call · sub-100 ms goal | PIR shards in memory, CPU-bound |
Architecture#
DiagramA one-ring read path, fed by a streaming spam pipeline
%% title: A one-ring read path, fed by a streaming spam pipeline
flowchart TD
AND@{ icon: "tech:android", label: "Android app<br/>contacts · device spam list" } ==> GW@{ icon: "sd:gateway", label: "Regional edge<br/>auth · rate limits" }
IOS@{ icon: "tech:apple", label: "iPhone<br/>Live Caller ID" } ==> RELAY@{ icon: "sd:tunnel", label: "Oblivious HTTP relay<br/>run by Apple" }
GW ==> LOOK["Lookup service<br/>one read · 800 ms deadline"]:::hot
RELAY ==> PIR["PIR service<br/>encrypted shards in memory"]:::core
LOOK ==> CACHE@{ icon: "tech:redis", label: "Redis<br/>hot records · 8 copies" }
CACHE -.->|"miss"| STORE@{ icon: "tech:cassandra", label: "Cassandra<br/>serving records · per region" }
GW --> INGEST["Intake<br/>call proof · token buckets"]:::service
INGEST ==> BUS@{ icon: "tech:kafka", label: "Kafka-API log<br/>reports · call outcomes" }
BUS ==> SCORE["Scorer<br/>stream plus daily batch"]:::core
SCORE ==>|"new version"| STORE
SCORE --> LISTS@{ icon: "sd:cdn", label: "CDN<br/>daily list · hourly deltas" }
LISTS -.-> AND
STORE -.->|"rebuild shards"| PIR
class AND,IOS client
class GW,RELAY,LISTS edge
class CACHE cache
class STORE store
class BUS queue- The phone is the first tier: contacts and the regional spam list answer with no network; the server is asked only about strangers.
- The read path never computes: scores and best names are built on the write path and stored as one small record per number.
- Two read paths, one truth: Android asks the lookup service; iPhone asks a PIR service through a relay; both serve the same records.
DiagramFrom a report or a declined call to a new verdict in minutes
%% title: From a report or a declined call to a new verdict in minutes
flowchart TD
APP@{ icon: "sd:phone", label: "Phone<br/>report · decline · block · answer" }
IN["Intake<br/>call-log proof · per-user caps"]:::service
LOG@{ icon: "tech:kafka", label: "Kafka-API log<br/>partitioned by number" }
WIN["Stream windows<br/>1 h and 24 h per number"]:::core
SCORE["Scorer<br/>trust-weighted evidence · bands"]:::hot
BQ@{ icon: "tech:bigquery", label: "BigQuery<br/>reporter trust · daily retraining" }
OUT@{ icon: "sd:database", label: "Serving records<br/>new version per change" }
APP ==> IN ==> LOG ==> WIN ==> SCORE
LOG --> BQ
BQ -.->|"weights, daily"| SCORE
SCORE ==> OUT
class APP client
class LOG queue
class BQ service
class OUT storeKey flows#
DiagramSunita's call on one bar of signal
%% title: Sunita's call on one bar of signal
sequenceDiagram
autonumber
participant P as Sunita's phone
participant L as Device list
participant S as Lookup service
participant C as Redis
P->>L: Number in the region list?
L-->>P: No, it started calling this morning
P->>S: Look up the E.164 number, deadline 800 ms
S->>C: Serving record, random hot copy
alt answer inside the deadline
C-->>S: Likely fraud · 1,814 reports
S-->>P: About 140 bytes
P->>P: Red screen before the second ring
else network too slow
P->>P: Show Unknown number at 800 ms
S-->>P: Late answer
P->>P: Repaint while still ringing
endDiagramRohit's iPhone asks without revealing who or which number
%% title: Rohit's iPhone asks without revealing who or which number sequenceDiagram autonumber participant A as Truecaller app participant I as iPhone participant R as Apple's relay participant T as PIR service A->>T: Signed in, fetch single-use tokens Note over I: a call from an unknown number I->>I: Number maps to shard 3, bucket 11 I->>R: Block query and identity query, one token each R->>T: Both, without the IP or device T->>T: Tokens unspent · encrypted dot product over shard 3 T-->>R: Encrypted results R-->>I: Relayed back I->>I: Decrypt, block or show the name Note over T: learns only that a user queried shard 3
DiagramA report becomes evidence, or is dropped
%% title: A report becomes evidence, or is dropped
sequenceDiagram
autonumber
participant P as Reporter's phone
participant N as Intake
participant G as Log
participant W as Stream scorer
participant D as Store and deltas
P->>N: Report spam, with the call event it refers to
alt call proof matches the device's outcome stream
N->>G: Report event, keyed by number
G->>W: Windows update · distinct reporters · trust weights
W->>D: Band crossed · new record version
D->>D: Overwrite cache copies · add to next hourly delta
else no matching call, or over the daily cap
N->>N: Drop before the log
Note over N: a burst of these is a brigading signal
endData model#
DiagramCore entities
%% title: Core entities
classDiagram
class ServingRecord {
+numberHash : 8 bytes of E.164
+displayName : string
+category : person · business · spam type
+spamBand : NONE to LIKELY_FRAUD
+reportCount : int
+verified : bool
+version : int
}
class NameCandidate {
+name : string
+source : owner · business · community
+basis : collection basis
+region : jurisdiction
+collectedAt : date
}
class Report {
+reporter : user id
+callEvent : proof of the call
+category : spam · fraud
+label : free text
}
class Reporter {
+trust : 0 to 1 · daily batch
+dailyCap : int
}
class Listing {
+state : UNKNOWN to UNLISTED
+ownerSince : date
}
class SpamList {
+region : telecom circle
+version : int
+keys : sorted 8-byte hashes
}
ServingRecord "1" --> "many" NameCandidate : best name from
ServingRecord "1" --> "many" Report : scored from
Reporter "1" --> "many" Report : files
ServingRecord "1" --> "1" Listing : governed by
SpamList "1" --> "many" ServingRecord : built from- Partition key: a hash of the E.164 number, never the prefix; ~150 bytes per serving record, ~6B numbers, ~0.9 TB, 3 replicas per region.
- Name candidates (~0.7 TB) and reports live apart from the serving record; the call path reads only the serving record.
Lifecycle#
DiagramA number's listing, from first call to unlisted
%% title: A number's listing, from first call to unlisted stateDiagram-v2 [*] --> UNKNOWN: first seen in a call UNKNOWN --> COMMUNITY_NAMED: trusted suggestions agree UNKNOWN --> REGISTERED: owner signs up and verifies COMMUNITY_NAMED --> REGISTERED: owner signs up UNKNOWN --> SPAM_FLAGGED: score crosses a band COMMUNITY_NAMED --> SPAM_FLAGGED: score crosses a band SPAM_FLAGGED --> UNKNOWN: verdict cleared or disputed SPAM_FLAGGED --> SPAM_FLAGGED: unlist refused COMMUNITY_NAMED --> UNLISTED: owner unlists REGISTERED --> UNLISTED: owner deletes and unlists REGISTERED --> UNKNOWN: number recycled UNLISTED --> [*] class REGISTERED ok class UNLISTED ok class SPAM_FLAGGED bad class COMMUNITY_NAMED queue
Decisions#
| Decision | Chose | Over | Because |
|---|---|---|---|
| Read path | One precomputed record, one read, 800 ms deadline | Scoring per request | the deadline is one ring on a weak network |
| Partitioning | Hash of the E.164 number | Prefix ranges | new number blocks get hot together |
| Hot numbers | 8 cache copies, versioned overwrite | Delete on update | delete-then-miss is a herd at 50 lookups / s |
| Spam evidence | Distinct reporters with call proof, trust-weighted, plus call outcomes | Counting reports | brigading; outcomes outnumber reports ~500 to 1 |
| Rate limits | Per-pod token buckets of limit ÷ N | A shared Redis counter | extra network calls and latency on every request |
| Offline | Regional list, signed base plus hourly deltas | The whole directory on the phone | ~1 TB, mostly people's names |
| iPhone | OHTTP relay, Privacy Pass, PIR | Plain lookups | the server must not learn who asked about which number |
| Names | Lineage per candidate, jurisdiction as tenant | One merged name table | consent law is still settling |
When it breaks#
| Failure | What users see | Contain it with |
|---|---|---|
| A hot number escapes detection | slow or failed lookups | copies from the call-velocity counter; alert at 3× node skew |
| Brigading against a shop | a wrong red label | call proof, reporter trust, verified-owner dispute |
| A bank's number is spoofed | reports against a real bank | verified floor; "often spoofed" warning |
| A phone offline over 7 days | stale spam list | fresh base; visible list age |
| Regional server outage | no names | device list still blocks; "Unknown number" |
| A recycled number | a stranger's name | ownership period; older candidates ignored |
Say it in the interview#
- "The read path never computes": scores and names are built on the write path; a lookup is one key read with a deadline.
- Phone first, then one round trip: contacts and the regional list answer offline; the server gets 800 ms and a late answer can repaint the ringing screen.
- Hash, not prefix, and copies for heat: the call-velocity counter that flags spam also flags hot keys; overwrite, never delete.
- Weigh evidence, not votes: distinct reporters with call proof, weighted by trust, plus call outcomes that outnumber reports ~500 to 1.
- Removals ship as fast as additions: a wrongly flagged number that stays red until tomorrow is a harm we caused.
- Private by construction on iPhone: the relay hides who, Privacy Pass proves "a user", PIR hides the number; shard size trades privacy for latency.
- Separate behaviour from identity: spam verdicts are shared and can't be unlisted; names carry their source, stay in their jurisdiction and go within 24 hours.