I read 50 years of network science, then built a CRM that runs entirely in the browser
Architecting Privacy-First Relationship Graphs: Implementing Weak-Tie Scoring in Client-Side Applications
Current Situation Analysis
Modern contact management systems are fundamentally misaligned with how professional opportunity actually flows. Enterprise CRMs and sales platforms optimize for pipeline velocity, contact volume, and server-side aggregation. They treat every relationship as a uniform data point, ignoring the sociological reality that career mobility, partnership formation, and information diffusion are heavily skewed toward peripheral connections.
This gap exists because traditional SaaS architectures are built around data centralization. To train recommendation engines, run cross-user analytics, or justify subscription pricing through usage telemetry, vendors must ingest, store, and process relationship data on their infrastructure. This creates a structural blind spot: tools that could quantify relationship utility are architecturally discouraged from doing so, because local-first computation breaks their analytics pipelines and increases infrastructure complexity without clear ROI for the vendor.
The empirical case for relationship scoring is no longer theoretical. In 2022, researchers from LinkedIn, MIT, Harvard, and Stanford published a causal experiment in Science (Rajkumar et al.) tracking 20 million users, 2 billion connection edges, and 600,000 job transitions. The study empirically validated Mark Granovetter’s 1973 "strength of weak ties" hypothesis: peripheral contacts consistently outperform close associates in surfacing novel opportunities, cross-industry bridges, and non-redundant information. Despite this, commercial tools still flatten network topology into flat contact lists. Professionals are left managing high-stakes relationship capital with instruments designed for transactional record-keeping.
The architectural consequence is clear: if you want a tool that actually scores relationships by network science principles while preserving data sovereignty, you must invert the traditional stack. Client-side computation, local persistence, and zero-knowledge licensing become mandatory, not optional.
WOW Moment: Key Findings
The divergence between traditional CRM architectures and weak-tie optimized client-side systems isn't just philosophical. It produces measurable differences in data handling, compute distribution, and privacy guarantees.
| Approach | Data Residency | Relationship Scoring | Analytics Dependency | Privacy Model | Compute Location |
|---|---|---|---|---|---|
| Traditional SaaS CRM | Vendor servers | None (flat records) | High (requires aggregation) | Policy-based (TOS) | Server-side |
| Client-Side Weak-Tie Graph | User device | Algorithmic (edge weights) | None (local telemetry only) | Architecture-based (zero-trust) | Browser/IndexedDB |
This finding matters because it shifts the engineering problem from "how do we store contacts?" to "how do we compute relationship leverage without exfiltrating data?" When tie-strength scoring runs locally, you eliminate data residency compliance overhead, reduce server costs to near-zero for storage, and force feature design around actual user value rather than engagement metrics. The trade-off is operational: you lose cross-user network effects, must handle local state migrations manually, and bear the responsibility of educating users on data backup strategies. For professionals managing sensitive pipelines, investor networks, or candidate rosters, this trade-off is not just acceptable—it's required.
Core Solution
Building a client-side relationship graph requires four coordinated layers: local persistence, topology computation, visualization, and secure feature gating. Below is a production-grade implementation pattern using Next.js 14, Dexie.js, D3.js, and TypeScript.
1. Local Persistence & Schema Design
IndexedDB is asynchronous, transactional, and quota-bound. Dexie.js provides a promise-based wrapper that simplifies versioning and query syntax. The schema must account for nodes (contacts), edges (relationships), and metadata (interaction history, tags, notes).
// db/relationshipGraph.ts
import Dexie, { type EntityTable } from 'dexie';
interface ContactNode {
id: string;
name: string;
company: string;
industry: string;
lastInteraction: Date;
mutualConnections: number;
tags: string[];
}
interface RelationshipEdge {
id: string;
sourceId: string;
targetId: string;
weight: number; // 0.0 to 1.0
interactionCount: number;
tenureOverlapMonths: number;
}
const db = new Dexie('ProfessionalNetworkDB') as Dexie & {
contacts: EntityTable<ContactNode, 'id'>;
edges: EntityTable<RelationshipEdge, 'id'>;
};
db.version(1).stores({
contacts: '++id, name, company, industry, tags',
edges: '++id, sourceId, targetId, weight'
});
db.version(2).upgrade(tx => {
return tx.table('edges').toCollection().modify(edge => {
edge.tenureOverlapMonths = edge.tenureOverlapMonths ?? 0;
});
});
export { db };
Why this structure? Separating nodes and edges mirrors graph database normalization. Versioned upgrades prevent silent schema drift. Explicit indexing on sourceId, targetId, and weight accelerates traversal queries without full table scans.
2. Tie-Strength Scoring Algorithm
Granovetter's theory and the Rajkumar et al. findings converge on three measurable proxies for weak-tie leverage: interaction recency decay, mutual connection density, and structural hole bridging. The scoring function normalizes these into a 0–1 weight.
// utils/tieStrength.ts
export function calculateTieStrength(
interactionCount: number,
daysSinceLastContact: number,
mutualConnections: number,
totalNetworkSize: number
): number {
const recencyDecay = Math.exp(-daysSinceLastContact / 180);
const interactionDensity = Math.min(interactionCount / 12, 1.0);
const structuralBridge = Math.min(mutualConnections / Math.sqrt(totalNetworkSize), 1.0);
const rawScore = (recencyDecay * 0.4) + (interactionDensity * 0.3) + (structuralBridge * 0.3);
return Math.max(0.0, Math.min(1.0, rawScore));
}
Why this weighting? Recent interactions decay exponentially because stale connections lose informational novelty. Mutual connections act as a bridge multiplier: high mutual density indicates a closed cluster (strong tie), while low mutual density relative to network size indicates a structural hole (weak tie). The 0.4/0.3/0.3 split reflects empirical findings that recency and novelty outweigh raw interaction volume.
3. Client-Side Graph Visualization
D3.js force simulations render efficiently in SVG for <2,000 nodes. Beyond that, switch to Canvas or WebGL. The simulation must respect local data boundaries and avoid blocking the main thread.
// components/NetworkGraph.tsx
'use client';
import { useEffect, useRef } from 'react';
import * as d3 from 'd3';
import { db } from '@/db/relationshipGraph';
interface GraphProps {
containerId: string;
}
export function NetworkGraph({ containerId }: GraphProps) {
const svgRef = useRef<SVGSVGElement>(null);
useEffect(() => {
const renderGraph = async () => {
const contacts = await db.contacts.toArray();
const edges = await db.edges.toArray();
const simulation = d3
.forceSimulation<ContactNode>(contacts)
.force('link', d3.forceLink<ContactNode, d3.SimulationLinkDatum<ContactNode>>(edges).id(d => d.id).distance(50))
.force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(400, 300));
const svg = d3.select(svgRef.current);
svg.selectAll('*').remove();
const link = svg.append('g')
.selectAll('line')
.data(edges)
.join('line')
.attr('stroke', '#94a3b8')
.attr('stroke-width', d => d.weight * 3);
const node = svg.append('g')
.selectAll('circle')
.data(contacts)
.join('circle')
.attr('r', 6)
.attr('fill', '#3b82f6')
.call(d3.drag<SVGCircleElement, ContactNode>()
.on('start', (event, d) => { if (!event.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
.on('drag', (event, d) => { d.fx = event.x; d.fy = event.y; })
.on('end', (event, d) => { if (!event.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; }));
simulation.on('tick', () => {
link.attr('x1', d => (d.source as ContactNode).x!).attr('y1', d => (d.source as ContactNode).y!)
.attr('x2', d => (d.target as ContactNode).x!).attr('y2', d => (d.target as ContactNode).y!);
node.attr('cx', d => d.x!).attr('cy', d => d.y!);
});
return () => simulation.stop();
};
renderGraph();
}, []);
return <svg ref={svgRef} id="network-canvas" width="800" height="600" />;
}
Why D3 over higher-level libraries? Force simulation parameters, edge weighting, and drag interactions require fine-grained control. D3's declarative data join pattern (selectAll().data().join()) aligns naturally with reactive state updates. The useEffect cleanup prevents memory leaks during route transitions.
4. Secure AI Outreach Proxy
Client-side apps cannot safely embed API keys. A Next.js App Router route handler validates license keys via Stripe, proxies requests to Anthropic Claude, and returns ephemeral drafts. No contact data is persisted server-side.
// app/api/draft/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Anthropic from '@anthropic-ai/sdk';
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
export async function POST(req: NextRequest) {
const authHeader = req.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Invalid license key' }, { status: 401 });
}
const licenseKey = authHeader.slice(7);
const isValid = await validateLicense(licenseKey);
if (!isValid) {
return NextResponse.json({ error: 'License expired or invalid' }, { status: 403 });
}
const { contactName, industry, outreachGoal } = await req.json();
const response = await anthropic.messages.create({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 512,
messages: [{
role: 'user',
content: `Draft a professional outreach message to ${contactName} in ${industry}. Goal: ${outreachGoal}. Keep it under 150 words. Focus on mutual value, not sales.`
}]
});
return NextResponse.json({ draft: response.content[0].text });
}
async function validateLicense(key: string): Promise<boolean> {
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
try {
const subscription = await stripe.subscriptions.retrieve(key, { expand: ['latest_invoice.payment_intent'] });
return subscription.status === 'active';
} catch {
return false;
}
}
Why this pattern? License validation happens at the edge, not in the client. Stripe subscription status acts as the source of truth. The AI proxy is stateless, ephemeral, and rate-limited by infrastructure. Contact data never touches the server; only the prompt payload does.
Pitfall Guide
1. IndexedDB Schema Drift
Explanation: Browser storage lacks server-side migration coordination. Adding fields or changing types without explicit upgrade callbacks corrupts existing records or throws VersionError.
Fix: Always increment db.version() and provide .upgrade() handlers that backfill defaults. Ship a JSON export/import utility as a fallback migration path.
2. Force Simulation Main-Thread Blocking
Explanation: D3's default SVG renderer recalculates physics on every tick. With >3,000 nodes, frame drops cause UI freezes and memory leaks.
Fix: Switch to <canvas> rendering for large graphs. Implement spatial partitioning (quadtree) or throttle simulation ticks using requestAnimationFrame with delta-time capping.
3. Tie-Strength Heuristic Bias
Explanation: Overweighting recent interactions creates a "recency trap" where dormant but high-leverage connections are deprioritized. Fix: Apply exponential decay with a longer half-life (180–360 days). Add a "dormant bridge" multiplier that boosts scores for contacts with low interaction count but high mutual connection diversity.
4. Cross-Tab State Desync
Explanation: Multiple browser tabs editing the same IndexedDB database cause race conditions. Last-write-wins semantics overwrite concurrent changes.
Fix: Use the BroadcastChannel API to emit state change events. Implement optimistic locking with version counters on each record, or enforce single-tab editing via sessionStorage flags.
5. Privacy vs. Analytics Paradox
Explanation: Zero-trust architecture prevents aggregate usage tracking. Teams struggle to measure feature adoption or debug production issues without telemetry. Fix: Instrument local counters for feature usage. Ship anonymized, opt-in telemetry only when explicitly permitted. Use error boundary logging that redacts PII before console output.
6. AI Proxy Latency & Cost Leakage
Explanation: Unbounded API calls to Claude drain credits and degrade UX during peak usage. No client-side caching means repeated prompts regenerate identical drafts. Fix: Implement a local prompt cache keyed by contact ID + goal hash. Add server-side rate limiting per license key. Queue non-urgent drafts and process them during off-peak hours.
7. Browser Storage Quota Exhaustion
Explanation: IndexedDB has per-origin limits (typically 50–80% of available disk). Large CSV imports with high-resolution metadata can trigger QuotaExceededError.
Fix: Compress contact data using LZ-string before storage. Implement chunked imports with progress indicators. Warn users when approaching 70% quota and prompt cleanup.
Production Bundle
Action Checklist
- Schema versioning: Define explicit Dexie upgrade paths with default backfills before production deployment
- Tie-strength calibration: Validate scoring weights against a sample dataset of 500+ contacts to ensure weak-tie prioritization aligns with user expectations
- Visualization fallback: Implement Canvas rendering path for graphs exceeding 2,500 nodes to prevent main-thread blocking
- Cross-tab sync: Deploy BroadcastChannel event listeners to prevent concurrent edit conflicts
- License validation: Configure Stripe webhook listeners to sync subscription status with edge runtime caches
- Quota management: Add storage usage monitoring and automatic data compression before import
- AI prompt caching: Hash input payloads locally to prevent redundant API calls and reduce latency
- Export/Import pipeline: Build JSON backup utility with encryption for paid-tier users to mitigate data loss risk
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| <5,000 contacts, solo user | Client-only IndexedDB + D3 SVG | Zero server cost, instant load, full privacy | $0 infrastructure |
| 5,000–20,000 contacts, team | Client-only + Canvas renderer + BroadcastChannel sync | Prevents UI freezes, enables multi-tab collaboration | $0 infrastructure, higher dev time |
| Enterprise compliance required | Hybrid: Local graph + encrypted cloud backup | Meets audit requirements while keeping compute local | Stripe + Vercel edge functions (~$15–50/mo) |
| AI drafting at scale | Server-proxied Claude + local prompt cache | Controls API spend, maintains zero-knowledge contact storage | Anthropic credits + rate limiting middleware |
Configuration Template
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverComponentsExternalPackages: ['@anthropic-ai/sdk', 'stripe']
},
headers: async () => [
{
source: '/:path*',
headers: [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }
]
}
]
};
module.exports = nextConfig;
// utils/storageQuota.ts
export async function checkStorageQuota(): Promise<{ used: number; quota: number; percentage: number }> {
if (!navigator.storage || !navigator.storage.estimate) {
return { used: 0, quota: 0, percentage: 0 };
}
const estimate = await navigator.storage.estimate();
const used = estimate.usage ?? 0;
const quota = estimate.quota ?? 0;
return {
used,
quota,
percentage: quota > 0 ? (used / quota) * 100 : 0
};
}
export function warnIfQuotaHigh(threshold = 75): boolean {
checkStorageQuota().then(({ percentage }) => {
if (percentage >= threshold) {
console.warn(`IndexedDB quota at ${percentage.toFixed(1)}%. Consider exporting or pruning data.`);
}
});
return false;
}
Quick Start Guide
- Initialize the project: Run
npx create-next-app@latest relationship-graph --typescript --tailwind --app. Install dependencies:npm install dexie d3 @anthropic-ai/sdk stripe. - Define the schema: Create
db/relationshipGraph.tswith Dexie versioning, node/edge interfaces, and upgrade callbacks. Run a test import of a 100-record CSV to verify indexing. - Implement scoring & viz: Add
tieStrength.tswith the decay/bridge algorithm. MountNetworkGraph.tsxin a client component. Verify force simulation stability with synthetic edge weights. - Secure the AI route: Deploy
app/api/draft/route.tswith Stripe license validation. Test with a dummy key to confirm 401/403 responses. Add local prompt caching in the frontend to prevent redundant calls. - Ship with safeguards: Integrate
storageQuota.tswarnings. Build JSON export/import with optional AES-GCM encryption for paid tiers. Deploy to Vercel, configure environment variables, and validate end-to-end flow in incognito mode to confirm zero server-side data retention.
Mid-Year Sale — Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all tutorials.
Sign In / Register — Start Free Trial7-day free trial · Cancel anytime · 30-day money-back
