← Applied algorithms
Routes and connections When links need a ranking

PageRank

A link is a vote. A popular vote counts more.

NetworkX, Python’s graph library, ships it as pagerank, and its documentation says where it came from: “It was originally designed as an algorithm to rank web pages.”

We’ll use it on a tiny wiki with five pages, where a plain list cannot tell a reader which page is the best starting point. Each page links to some others. Those links are votes, and a vote from an already important page should carry more weight.

PageRank turns that circular definition into repeated calculation: pass score along links, spread score from pages with nowhere to link, add a small teleportation share, and stop when the numbers barely move.

TypeScriptGoOne link graph in each language

01 / The idea

A link is a vote, but not every vote is equal.

Home points to Docs and Guide. Docs points back to Home, Guide, and Blog. Guide points to Docs. Blog points to Home and Guide. The arrows form a loop: each page’s importance depends on the others.

PageRank estimates importance from link structure, not from the words on the page and not from editorial truth. A page linked by several useful pages rises; a page with no outgoing links still has score and must not make the total disappear.

PageRank

Pass the score. Recalculate the whole wiki.

TINY WIKI · LINK VOTESIteration 1
Docs31.9%
3 outgoing links
Guide29.1%
1 outgoing links
Home20.6%
2 outgoing links
Blog12.1%
2 outgoing links
Orphan6.4%
dangling · sends score everywhere

Largest score change: 1.36e-1.

highest score score after damping and link redistribution

01/ 03
Start evenly

Start every page evenly.

Every page begins with 20%. In the first update Home, Docs, Guide, and Blog pass that 20% along their links; Orphan has nowhere to send its score, so it is dangling and its 20% is spread across all five pages. After this one update Docs already has 31.9% and Orphan 6.4%.

Reduced motion: choose a scene to see its completed state.

Read this scene

Every page begins with 20%. In the first update Home, Docs, Guide, and Blog pass that 20% along their links; Orphan has nowhere to send its score, so it is dangling and its 20% is spread across all five pages. After this one update Docs already has 31.9% and Orphan 6.4%.

Iteration 1. Largest change 1.36e-1. Leading pages: Docs 31.9%, Guide 29.1%.

Watch and Step through replay the same rank snapshots. Try it changes the damping policy and runs the TypeScript implementation from a fresh result.

Docs finishes first at about 35.5%. Orphan is last, but it does not fall to zero: the teleportation share gives it 3.0 points, and its own dangling score, spread back over every page, gives it the other 0.6. The ranking answers “which page does this link graph favor?”, a narrower question than “which page is correct?”

02 / Name the rule

Pass each page’s score to its destinations.

Start every page at 1 ÷ N. For each destination, add a base teleportation share and the scores arriving through links. If a page has no outgoing links, add its score evenly to every destination.

Share

Outgoing score

A page with d links contributes its current score divided by d to each destination.

Repair

Dangling score

A page with no links sends its score evenly across the whole graph.

Damp

Teleportation

The remaining 1 − d share prevents a closed link component from owning everything.

The update is new[v] = (1 − d)/N + d × (linked[v] + dangling/N). Repeat until the largest score change is below a chosen tolerance, or until a bounded iteration limit says the calculation did not converge.

Why not count incoming links once?The vote’s source has weight

Three links from tiny pages do not necessarily outweigh one link from a page with a large score. PageRank passes the score that a source currently holds, so popularity can reinforce itself, but damping keeps that reinforcement bounded. A link remains a structural signal, not proof that the destination is accurate.

03 / Read the shape

One score table becomes the next score table.

Basic form builds outgoing and incoming lists, starts uniformly, computes each iteration, and sorts the converged scores with a page-id tie-breaker. In the wild adds the small helpers a caller needs: a safe score lookup, the list of dangling pages, and a readable ranking. At the call site prints the ranking, the top page, and which pages are dangling.

Build outgoing link lists, start every page at 1 ÷ N, and repeatedly redistribute linked and dangling score with teleportation.

TypeScriptReading
wiki.ts
export const MAX_PAGES = 64;
export const MAX_LINKS = 256;
export const MAX_ITERATIONS = 100;

export type Link = { from: string; to: string };
export type Wiki = { pages: string[]; links: Link[] };
export type RankOptions = { damping?: number; tolerance?: number; maxIterations?: number };
export type RankSnapshot = {
	iteration: number;
	scores: Record<string, number>;
	delta: number;
	dangling: number;
};
export type PageScore = { page: string; score: number };
export type RankResult = {
	scores: Record<string, number>;
	ordered: PageScore[];
	iterations: number;
	converged: boolean;
	trace: RankSnapshot[];
};

export type RankErrorCode =
	'too-big' | 'bad-page' | 'unknown-page' | 'duplicate-link' | 'bad-options';

export class RankError extends Error {
	readonly code: RankErrorCode;
	constructor(code: RankErrorCode, message: string) {
		super(message);
		this.name = 'RankError';
		this.code = code;
	}
}

function validate(wiki: Wiki, options: Required<RankOptions>): void {
	if (wiki.pages.length > MAX_PAGES || wiki.links.length > MAX_LINKS)
		throw new RankError('too-big', `up to ${MAX_PAGES} pages and ${MAX_LINKS} links`);
	const known = new Set<string>();
	for (const page of wiki.pages) {
		if (!/^[a-z][a-z0-9-]{0,23}$/.test(page) || known.has(page))
			throw new RankError('bad-page', `page ids are unique lowercase slugs: ${page}`);
		known.add(page);
	}
	const links = new Set<string>();
	for (const link of wiki.links) {
		if (!known.has(link.from) || !known.has(link.to))
			throw new RankError(
				'unknown-page',
				`a link names a page that does not exist: ${link.from}, ${link.to}`
			);
		const key = `${link.from}\0${link.to}`;
		if (links.has(key))
			throw new RankError('duplicate-link', `link appears twice: ${link.from} -> ${link.to}`);
		links.add(key);
	}
	if (
		!Number.isFinite(options.damping) ||
		options.damping <= 0 ||
		options.damping >= 1 ||
		!Number.isFinite(options.tolerance) ||
		options.tolerance <= 0 ||
		!Number.isInteger(options.maxIterations) ||
		options.maxIterations < 1 ||
		options.maxIterations > MAX_ITERATIONS
	)
		throw new RankError(
			'bad-options',
			`damping is between 0 and 1, tolerance is positive, and maxIterations is 1 to ${MAX_ITERATIONS}`
		);
}

function optionsOf(options: RankOptions = {}): Required<RankOptions> {
	return {
		damping: options.damping ?? 0.85,
		tolerance: options.tolerance ?? 1e-8,
		maxIterations: options.maxIterations ?? MAX_ITERATIONS
	};
}

function ordered(scores: Map<string, number>): PageScore[] {
	return [...scores.entries()]
		.map(([page, score]) => ({ page, score }))
		.sort((a, b) => b.score - a.score || a.page.localeCompare(b.page));
}

// Each iteration redistributes linked rank, spreads dangling rank, and adds teleportation.
export function traceRank(wiki: Wiki, options: RankOptions = {}): RankSnapshot[] {
	const resolved = optionsOf(options);
	validate(wiki, resolved);
	if (wiki.pages.length === 0) return [];
	const count = wiki.pages.length;
	const outgoing = new Map<string, string[]>(wiki.pages.map((page) => [page, []]));
	for (const link of wiki.links) outgoing.get(link.from)!.push(link.to);
	// Turn the links around once, so each page can gather its votes in O(indegree).
	const incoming = new Map<string, string[]>(wiki.pages.map((page) => [page, []]));
	for (const [from, destinations] of outgoing)
		for (const to of destinations) incoming.get(to)!.push(from);
	let scores = new Map(wiki.pages.map((page) => [page, 1 / count]));
	const trace: RankSnapshot[] = [];
	for (let iteration = 1; iteration <= resolved.maxIterations; iteration++) {
		const dangling = [...scores]
			.filter(([page]) => outgoing.get(page)!.length === 0)
			.reduce((sum, [, score]) => sum + score, 0);
		const next = new Map<string, number>();
		for (const page of wiki.pages) {
			let linked = 0;
			for (const from of incoming.get(page)!)
				linked += scores.get(from)! / outgoing.get(from)!.length;
			next.set(
				page,
				(1 - resolved.damping) / count + resolved.damping * (linked + dangling / count)
			);
		}
		const delta = Math.max(
			...wiki.pages.map((page) => Math.abs(next.get(page)! - scores.get(page)!))
		);
		const snapshot = Object.fromEntries(wiki.pages.map((page) => [page, next.get(page)!]));
		trace.push({ iteration, scores: snapshot, delta, dangling });
		scores = next;
		if (delta < resolved.tolerance) break;
	}
	return trace;
}

export function rankPages(wiki: Wiki, options: RankOptions = {}): RankResult {
	const trace = traceRank(wiki, options);
	const resolved = optionsOf(options);
	validate(wiki, resolved);
	const scores =
		trace.at(-1)?.scores ??
		Object.fromEntries(wiki.pages.map((page) => [page, 1 / wiki.pages.length]));
	const scoreMap = new Map(Object.entries(scores));
	return {
		scores,
		ordered: ordered(scoreMap),
		iterations: trace.length,
		converged: trace.length > 0 && trace.at(-1)!.delta < resolved.tolerance,
		trace
	};
}
GoAlongside
wiki.go
const (
	MaxPages      = 64
	MaxLinks      = 256
	MaxIterations = 100
)

type Link struct {
	From string
	To   string
}

type Wiki struct {
	Pages []string
	Links []Link
}

type RankOptions struct {
	Damping       float64
	Tolerance     float64
	MaxIterations int
}

type RankSnapshot struct {
	Iteration int
	Scores    map[string]float64
	Delta     float64
	Dangling  float64
}

type PageScore struct {
	Page  string
	Score float64
}

type RankResult struct {
	Scores     map[string]float64
	Ordered    []PageScore
	Iterations int
	Converged  bool
	Trace      []RankSnapshot
}

type RankError struct {
	Code    string
	Message string
}

func (e *RankError) Error() string { return e.Message }

var pagePattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,23}$`)

func validate(wiki Wiki, options RankOptions) error {
	if len(wiki.Pages) > MaxPages || len(wiki.Links) > MaxLinks {
		return &RankError{"too-big", fmt.Sprintf("up to %d pages and %d links", MaxPages, MaxLinks)}
	}
	known := map[string]bool{}
	for _, page := range wiki.Pages {
		if !pagePattern.MatchString(page) || known[page] {
			return &RankError{"bad-page", "page ids are unique lowercase slugs: " + page}
		}
		known[page] = true
	}
	links := map[string]bool{}
	for _, link := range wiki.Links {
		if !known[link.From] || !known[link.To] {
			return &RankError{"unknown-page", fmt.Sprintf("a link names a page that does not exist: %s, %s", link.From, link.To)}
		}
		key := link.From + "\x00" + link.To
		if links[key] {
			return &RankError{"duplicate-link", fmt.Sprintf("link appears twice: %s -> %s", link.From, link.To)}
		}
		links[key] = true
	}
	if math.IsNaN(options.Damping) || math.IsInf(options.Damping, 0) ||
		options.Damping <= 0 || options.Damping >= 1 ||
		math.IsNaN(options.Tolerance) || math.IsInf(options.Tolerance, 0) || options.Tolerance <= 0 ||
		options.MaxIterations < 1 || options.MaxIterations > MaxIterations {
		return &RankError{"bad-options", fmt.Sprintf("damping is between 0 and 1, tolerance is positive, and maxIterations is 1 to %d", MaxIterations)}
	}
	return nil
}

// DefaultRankOptions is the TypeScript default: damping 0.85, tolerance 1e-8, 100 iterations.
// A zero field is refused, as in TypeScript, rather than quietly replaced by a default.
func DefaultRankOptions() RankOptions {
	return RankOptions{Damping: 0.85, Tolerance: 1e-8, MaxIterations: MaxIterations}
}

func ordered(scores map[string]float64) []PageScore {
	result := []PageScore{}
	for page, score := range scores {
		result = append(result, PageScore{page, score})
	}
	sort.Slice(result, func(i, j int) bool {
		if result[i].Score == result[j].Score {
			return result[i].Page < result[j].Page
		}
		return result[i].Score > result[j].Score
	})
	return result
}

// Each iteration redistributes linked rank, spreads dangling rank, and adds teleportation.
func TraceRank(wiki Wiki, options RankOptions) ([]RankSnapshot, error) {
	if err := validate(wiki, options); err != nil {
		return nil, err
	}
	if len(wiki.Pages) == 0 {
		return []RankSnapshot{}, nil
	}
	outgoing := map[string][]string{}
	for _, page := range wiki.Pages {
		outgoing[page] = []string{}
	}
	for _, link := range wiki.Links {
		outgoing[link.From] = append(outgoing[link.From], link.To)
	}
	// Turn the links around once, so each page can gather its votes in O(indegree).
	incoming := map[string][]string{}
	for _, from := range wiki.Pages {
		for _, to := range outgoing[from] {
			incoming[to] = append(incoming[to], from)
		}
	}
	count := float64(len(wiki.Pages))
	scores := map[string]float64{}
	for _, page := range wiki.Pages {
		scores[page] = 1 / count
	}
	trace := []RankSnapshot{}
	for iteration := 1; iteration <= options.MaxIterations; iteration++ {
		dangling := 0.0
		for _, page := range wiki.Pages {
			if len(outgoing[page]) == 0 {
				dangling += scores[page]
			}
		}
		next := map[string]float64{}
		for _, page := range wiki.Pages {
			linked := 0.0
			for _, from := range incoming[page] {
				linked += scores[from] / float64(len(outgoing[from]))
			}
			next[page] = (1-options.Damping)/count + options.Damping*(linked+dangling/count)
		}
		delta := 0.0
		for _, page := range wiki.Pages {
			if difference := math.Abs(next[page] - scores[page]); difference > delta {
				delta = difference
			}
		}
		snapshot := map[string]float64{}
		for _, page := range wiki.Pages {
			snapshot[page] = next[page]
		}
		trace = append(trace, RankSnapshot{iteration, snapshot, delta, dangling})
		scores = next
		if delta < options.Tolerance {
			break
		}
	}
	return trace, nil
}

func RankPages(wiki Wiki, options RankOptions) (RankResult, error) {
	trace, err := TraceRank(wiki, options)
	if err != nil {
		return RankResult{}, err
	}
	scores := map[string]float64{}
	if len(trace) > 0 {
		for page, score := range trace[len(trace)-1].Scores {
			scores[page] = score
		}
	} else if len(wiki.Pages) > 0 {
		for _, page := range wiki.Pages {
			scores[page] = 1 / float64(len(wiki.Pages))
		}
	}
	return RankResult{scores, ordered(scores), len(trace), len(trace) > 0 && trace[len(trace)-1].Delta < options.Tolerance, trace}, nil
}
Reading the TypeScriptMaps and snapshots

scores is a map from page id to number. The implementation turns the links around once, gathers each page’s incoming contributions, separately sums dangling pages, and records immutable snapshots for the visual. The tolerance is a stopping policy, not an exact symbolic answer.

Reading the GoFloat64 and explicit options

Go uses float64 for scores and sort.Slice for the final order. sort.Slice is not stable; the order is deterministic because ties fall back to the page id. Start from DefaultRankOptions() and change one field: a zero damping or tolerance is refused, as in TypeScript, rather than read as “use the default”. Each snapshot copies its map so a later iteration cannot change the evidence already shown.

What is refusedBounded graphs and explicit policies

Both versions bound the example at 64 pages and 256 links, require unique lowercase ids, refuse unknown or duplicate links, and require damping strictly between 0 and 1 and a positive tolerance. The default damping is 0.85, the tolerance is 10−8, and the maximum is 100 iterations.

04 / Try a decision

Where does Orphan’s score go?

Without a rule for dangling pages, the total rank leaks out of the system. Make the policy explicit before interpreting the bars.

Orphan has no outgoing links. What should one PageRank iteration do with its score?

05 / Follow the cost

Each iteration touches the graph.

PageRank: time and extra space per operation
OperationTimeExtra spaceWhat it assumes
Gather one page’s votesO(indegree)O(1)Add each linking page’s current score divided by its number of outgoing links.
Compute one iterationO(V + E)O(V)Every link is read once through the incoming lists, which are built once before the first iteration. The snapshot for the film adds O(V).
Rank until toleranceO(k(V + E))O(kV)k iterations depend on the damping factor, graph shape, and chosen tolerance. The shown code keeps one snapshot per iteration; without them it needs O(V).
Store the graph—O(V + E)Page ids, the outgoing lists, and the incoming lists built from them.
Sort the final scoresO(V log V)O(V)The lesson sorts by score and then page id for deterministic ties.

With V pages and E links, one iteration is O(V + E) because the code builds each page’s incoming list once, before the first iteration. Asking every source “do you link to this page?” inside the loop would read every link once per page: O(V·E) per iteration. The number of iterations is not a fixed property of the algorithm; damping, graph structure, tolerance, and the starting vector all matter.

The visual keeps every snapshot so the changing scores can be inspected. A production job usually keeps the current and next vectors, then writes only the final ranking or selected checkpoints. Sorting the final scores is separate from calculating them.

06 / Give it a real job

Rank the docs once, when the site builds.

A documentation site wants a “Start here” list, and a way to break ties when two pages match a search equally well. After the site builds, a job reads every link in the page bodies, runs rankPages once over the whole graph, and writes each page’s score into the search index beside its text. The search still decides which pages match the words; the score only orders the start list and breaks ties.

The same job reports danglingPages. A page with no links out, like Orphan, is usually a dead end that a writer forgot to link onward from. The job counts links in the page body, not the navigation bar: a menu on every page would be a vote from every page for the same few.

What it leaves out matters as much. It knows nothing about whether a page is correct, recent, or read, and a new page that nothing links to yet starts near the bottom however good it is. This runs in the site build, over the whole link graph; a page component only reads the stored score, and nothing in a component computes it.

07 / Make the call

Choose a ranking model before tuning it.

Use PageRank when importance should flow through a directed link graph and repeated influence is the question. Use a direct popularity count when every incoming link should have equal weight. Use visits or clicks when what readers actually do matters more than what authors chose to link.

Damping, teleportation, and dangling-page handling are part of the model. Changing them changes the answer. A PageRank score is not a quality score, a recommendation guarantee, or a statement that a page is true.

When a ranking looks surprising, inspect the links and policies before blaming the arithmetic: who links to the winner, which pages are dangling, and how often the model teleports.

SourcesLibrary documentation, checked 23 September 2026
  • NetworkX 3.7, pagerank: “It was originally designed as an algorithm to rank web pages.” “Damping parameter for PageRank, default=0.85.” On dangling nodes: “By default, dangling nodes are given outedges according to the personalization vector (uniform if not specified).”

08 / Take the idea with you

Explain a ranking without saying “PageRank.”

“Give every page an equal share. Then, over and over, let each page hand its share out evenly to the pages it links to, let a page with no links hand its share to everyone, and keep a small slice back for everyone too. When the shares stop moving, the pages holding the most are the ones linked to most by pages that are linked to themselves.”

Before moving on, pick a site you know well and guess its top page from the links alone. Then check what the site itself puts first, and say what its links would have missed.

Connections to follow nextRelated lessons
  • Graph overview names nodes, directed links, and weighted relationships.
  • Adjacency list stores each page’s outgoing neighbors compactly.
  • Strongly connected components finds groups of pages that all reach each other. A group with no link out would hoard score if teleportation did not let some of it leave.