← Concepts & practices
Concept Language and runtime models

Ownership, aliasing, and lifetimes

Who may use these bytes, and until when?

An inventory importer reads a row and queues a preview. It reads the next row. When the preview finally runs, both entries seem to contain the second row. The queue kept something. What did it have permission to keep?

What to carry away

Access to data is not the same as responsibility for it. Follow the owner, the aliases, and the point after which a use is no longer valid.

TypeScriptGo Permission over time. One reusable row buffer, two implementations.

Start with a useful optimization

One buffer can serve many rows. One view cannot preserve them all.

The importer receives short stock records such as MAPLE,12 and CEDAR,07. A reusable buffer holds the current row. A consumer can inspect those bytes without making a separate row copy each time.

That arrangement works when the consumer finishes before the buffer is reused. A deferred preview changes the requirement: its use comes later. Keeping the view in a queue does not, by itself, give the queue independent row data.

The importer owns the buffer’s reuse policy. The view aliases its bytes: two paths reach the same storage. Its useful lifetime is limited by the borrowing contract. In this example, a view is read-only by agreement and remains valid until the next successful load.

Predict · step · follow the bytes

The queued row is a view of what?

Row 1: MAPLE,12 · Row 2: CEDAR,07. Changing either choice starts a fresh trace.

Step 0 of 5Ready to load

The importer owns one 16-byte buffer. No row has been loaded or queued.

Buffer A Owned by the importer · 16 bytes reused

0·00
1·00
2·00
3·00
4·00
5·00
6·00
7·00
8·00
9·00
10·00
11·00
12·00
13·00
14·00
15·00

Each cell shows offset, character, and hex byte. 00 is cleared padding. Highlighted cells changed since the previous step.

Retained by the queue

No queued rows yet.

Actual TypeScript buffer operations, replayed from captured snapshots. Labels describe backing storage, not physical addresses or allocation timing. Expiry follows this example’s borrowing contract.

With borrowed views, loading row 2 overwrites Buffer A. The first queue entry still points into that buffer. It can now read CEDAR,07 even though it was queued for MAPLE,12.

The memory is still there and still readable; what it holds has changed. There is no thread race in the experiment, and nothing needs to be freed for the bug to happen. Reuse is enough. The identical-row variation makes the limit clearer: matching bytes can hide an expired borrow.

Separate three questions

What can reach the data? What is allowed to use it?

Ownership
Who is responsible for this value or resource, including its reuse or release? Can that responsibility be transferred?
Aliasing
Which references, views, or handles reach the same state, or overlapping parts of it?
Lifetime
During which interval is this use valid? What event ends that permission?

An alias describes a relationship. A borrow adds a contract to that relationship: you may use this data in these ways, for this interval, while someone else keeps responsibility for it. Multiple readers of stable data can be perfectly reasonable. The problem here is retaining a view while the owner replaces what it means.

Ownership is enforced differently across languages. In TypeScript and Go, many ownership rules are API agreements supported by code structure and tests. Their garbage collectors keep reachable memory available; they do not promise that a producer will stop changing it.

Read the same lifetime boundary in your languages.

These choices apply to the comparisons throughout this story.

The buffer that makes reuse visible16 bytes · one explicit contract

This controlled in-memory implementation accepts 1–16 printable ASCII characters per row. It validates before changing the buffer, clears padding to zero, then copies in the row. The returned view covers only that row’s length. It is a model of reuse, not a file reader or a CSV parser.

TypeScript and Go return writable views at the language level; this API asks callers to read them only. The input string itself is copied into the buffer; the returned view refers to the buffer’s bytes.

TypeScriptReusable buffer
rows.ts
export class RowBuffer {
	private readonly bytes = new Uint8Array(CAPACITY);

	// Borrowed, read-only by contract, until the next successful load.
	load(row: string): Uint8Array {
		if (row.length < 1 || row.length > CAPACITY || /[^\x20-\x7e]/.test(row)) {
			throw new Error('Expected 1–16 printable ASCII characters');
		}
		this.bytes.fill(0);
		for (let i = 0; i < row.length; i++) this.bytes[i] = row.charCodeAt(i);
		return this.bytes.subarray(0, row.length);
	}
}
GoReusable buffer
rows.go
type RowBuffer struct{ bytes [Capacity]byte }

// Borrowed, read-only by contract, until the next successful Load.
func (b *RowBuffer) Load(row string) ([]byte, error) {
	if len(row) < 1 || len(row) > Capacity {
		return nil, fmt.Errorf("expected 1–16 printable ASCII characters")
	}
	for i := 0; i < len(row); i++ {
		if row[i] < 32 || row[i] > 126 {
			return nil, fmt.Errorf("expected 1–16 printable ASCII characters")
		}
	}
	clear(b.bytes[:])
	copy(b.bytes[:], row)
	return b.bytes[:len(row):len(row)], nil
}
TypeScriptRetaining a borrow past reuse
rows.ts
// Intentionally broken: later loads overwrite bytes held by earlier entries.
export function retainViews(rows: readonly string[]): Uint8Array[] {
	const source = new RowBuffer();
	const queue: Uint8Array[] = [];
	for (const row of rows) {
		const view = source.load(row);
		queue.push(view);
	}
	return queue;
}
GoRetaining a borrow past reuse
rows.go
// Intentionally broken: the slice headers still share the source array.
func RetainViews(rows []string) ([][]byte, error) {
	source := &RowBuffer{}
	queue := make([][]byte, 0, len(rows))
	for _, row := range rows {
		view, err := source.Load(row)
		if err != nil {
			return nil, err
		}
		queue = append(queue, view)
	}
	return queue, nil
}

The important boundary is the next use, not just the next closing brace. A view can remain in scope after its last valid use. Conversely, a callback can keep a reference long after the function that created it has returned. Follow what will actually read the data next.

Where this shows up in real APIsViews and borrowing contracts

Go’s Scanner.Bytes can return bytes overwritten by a later Scan. Consume them promptly or preserve the data before advancing. Scanner.Text returns a string for callers that need that representation. Always check the scanner’s error after the loop. Our tiny buffer forces reuse on each load; it does not predict exactly when a real scanner reallocates or overwrites storage.

In JavaScript, a typed array’s subarray creates a view into existing storage. A different view object can still alias the same bytes. In Node, Buffer.slice also creates a view; do not assume a method named “slice” always copies.

The later consumer needs a longer lifetime

Give the queue data the producer will leave alone.

At the point of retention, copy the current row into separate storage. The importer can then reuse its buffer while the queue keeps the original bytes. The boundary is small: the row being retained, not the whole importer or input file.

TypeScriptOwn the retained row
rows.ts
export function copyRow(view: Uint8Array): Uint8Array {
	return new Uint8Array(view);
}

export function preparePreviews(rows: readonly string[]): Uint8Array[] {
	const source = new RowBuffer();
	const queue: Uint8Array[] = [];
	for (const row of rows) {
		const view = source.load(row);
		queue.push(copyRow(view));
	}
	return queue;
}
GoOwn the retained row
rows.go
func CopyRow(view []byte) []byte {
	return append([]byte(nil), view...)
}

func PreparePreviews(rows []string) ([][]byte, error) {
	source := &RowBuffer{}
	queue := make([][]byte, 0, len(rows))
	for _, row := range rows {
		view, err := source.Load(row)
		if err != nil {
			return nil, err
		}
		queue = append(queue, CopyRow(view))
	}
	return queue, nil
}

In TypeScript, constructing a new Uint8Array from this view copies its elements. Go copies the bytes into a fresh slice. Returning the queue gives the caller access to independent arrays, under the application’s ownership agreement.

Copying does not make the queued bytes immutable. It separates them from this producer’s reusable storage. A later consumer that mutates a queue entry still needs its own agreement with other readers.

TypeScriptThe caller reads later
rows.ts
export function runExample(): void {
	const rows = ['MAPLE,12', 'CEDAR,07'];
	const previews = preparePreviews(rows);
	// The local source is gone; the queue has independent row bytes.
	console.log(`previews: ${previews.map(text).join(' | ')}`);
	console.log(`bytes processed immediately: ${totalBytes(rows)}`);
}
GoThe caller reads later
rows.go
func main() {
	rows := []string{"MAPLE,12", "CEDAR,07"}
	previews, err := PreparePreviews(rows)
	if err != nil {
		panic(err)
	}
	labels := make([]string, len(previews))
	for i, row := range previews {
		labels[i] = string(row)
	}
	total, err := TotalBytes(rows)
	if err != nil {
		panic(err)
	}
	fmt.Printf("previews: %s\n", strings.Join(labels, " | "))
	fmt.Printf("bytes processed immediately: %d\n", total)
}
The complete programs print
previews: MAPLE,12 | CEDAR,07
bytes processed immediately: 16
Complete runnable filesCopy a file and run it

The complete files contain the buffer, the safe preview queue, an immediate consumer, and an invocation. TypeScript and Go also retain the clearly marked broken function for inspection; the invocation uses the safe path.

In a fresh directory, save rows.ts and run node rows.ts (Node 22 or later strips the types). For Go, run go run rows.go. No extra packages are required.

TypeScriptComplete files
rows.ts
// A controlled reuse model, not a file reader or a CSV parser.
export const CAPACITY = 16;

export class RowBuffer {
	private readonly bytes = new Uint8Array(CAPACITY);

	// Borrowed, read-only by contract, until the next successful load.
	load(row: string): Uint8Array {
		if (row.length < 1 || row.length > CAPACITY || /[^\x20-\x7e]/.test(row)) {
			throw new Error('Expected 1–16 printable ASCII characters');
		}
		this.bytes.fill(0);
		for (let i = 0; i < row.length; i++) this.bytes[i] = row.charCodeAt(i);
		return this.bytes.subarray(0, row.length);
	}
}

// These examples decode only the validated ASCII bytes produced by RowBuffer.
export function text(row: Uint8Array): string {
	return String.fromCharCode(...row);
}

// Intentionally broken: later loads overwrite bytes held by earlier entries.
export function retainViews(rows: readonly string[]): Uint8Array[] {
	const source = new RowBuffer();
	const queue: Uint8Array[] = [];
	for (const row of rows) {
		const view = source.load(row);
		queue.push(view);
	}
	return queue;
}

export function copyRow(view: Uint8Array): Uint8Array {
	return new Uint8Array(view);
}

export function preparePreviews(rows: readonly string[]): Uint8Array[] {
	const source = new RowBuffer();
	const queue: Uint8Array[] = [];
	for (const row of rows) {
		const view = source.load(row);
		queue.push(copyRow(view));
	}
	return queue;
}

export function totalBytes(rows: readonly string[]): number {
	const source = new RowBuffer();
	let total = 0;
	for (const row of rows) {
		const view = source.load(row);
		total += view.length; // Finish using this view before the next load.
	}
	return total; // Only a number leaves the operation.
}

export function runExample(): void {
	const rows = ['MAPLE,12', 'CEDAR,07'];
	const previews = preparePreviews(rows);
	// The local source is gone; the queue has independent row bytes.
	console.log(`previews: ${previews.map(text).join(' | ')}`);
	console.log(`bytes processed immediately: ${totalBytes(rows)}`);
}

runExample();
GoComplete files
rows.go
package main

import (
	"fmt"
	"strings"
)

const Capacity = 16

type RowBuffer struct{ bytes [Capacity]byte }

// Borrowed, read-only by contract, until the next successful Load.
func (b *RowBuffer) Load(row string) ([]byte, error) {
	if len(row) < 1 || len(row) > Capacity {
		return nil, fmt.Errorf("expected 1–16 printable ASCII characters")
	}
	for i := 0; i < len(row); i++ {
		if row[i] < 32 || row[i] > 126 {
			return nil, fmt.Errorf("expected 1–16 printable ASCII characters")
		}
	}
	clear(b.bytes[:])
	copy(b.bytes[:], row)
	return b.bytes[:len(row):len(row)], nil
}


// Intentionally broken: the slice headers still share the source array.
func RetainViews(rows []string) ([][]byte, error) {
	source := &RowBuffer{}
	queue := make([][]byte, 0, len(rows))
	for _, row := range rows {
		view, err := source.Load(row)
		if err != nil {
			return nil, err
		}
		queue = append(queue, view)
	}
	return queue, nil
}


func CopyRow(view []byte) []byte {
	return append([]byte(nil), view...)
}

func PreparePreviews(rows []string) ([][]byte, error) {
	source := &RowBuffer{}
	queue := make([][]byte, 0, len(rows))
	for _, row := range rows {
		view, err := source.Load(row)
		if err != nil {
			return nil, err
		}
		queue = append(queue, CopyRow(view))
	}
	return queue, nil
}


func TotalBytes(rows []string) (int, error) {
	source := &RowBuffer{}
	total := 0
	for _, row := range rows {
		view, err := source.Load(row)
		if err != nil {
			return 0, err
		}
		total += len(view) // Finish using this view before the next load.
	}
	return total, nil
}


func main() {
	rows := []string{"MAPLE,12", "CEDAR,07"}
	previews, err := PreparePreviews(rows)
	if err != nil {
		panic(err)
	}
	labels := make([]string, len(previews))
	for i, row := range previews {
		labels[i] = string(row)
	}
	total, err := TotalBytes(rows)
	if err != nil {
		panic(err)
	}
	fmt.Printf("previews: %s\n", strings.Join(labels, " | "))
	fmt.Printf("bytes processed immediately: %d\n", total)
}

Borrowing may already be enoughFinish the use before reuse

A byte counter needs the current row’s length, then moves on. Only a number survives each iteration. That number does not alias the input bytes, so there is no reason to retain an entire row for this job.

TypeScriptConsume within the borrow
rows.ts
export function totalBytes(rows: readonly string[]): number {
	const source = new RowBuffer();
	let total = 0;
	for (const row of rows) {
		const view = source.load(row);
		total += view.length; // Finish using this view before the next load.
	}
	return total; // Only a number leaves the operation.
}
GoConsume within the borrow
rows.go
func TotalBytes(rows []string) (int, error) {
	source := &RowBuffer{}
	total := 0
	for _, row := range rows {
		view, err := source.Load(row)
		if err != nil {
			return 0, err
		}
		total += len(view) // Finish using this view before the next load.
	}
	return total, nil
}

Other designs are valid too. A producer can hand off a whole owned buffer and acquire another one, provided it stops reusing the handed-off storage. A parser can keep only the fields needed by the preview. A consumer can finish before the next load. Each choice changes who owns the data or how long access is needed.

For a small deferred preview, copying a short row makes the agreement easy to see. A large or unbounded stream needs a queue limit and an overload policy too. Ownership alone does not bound memory use.

Mark where the permission ends

A lifetime describes a relationship. It does not keep data alive by wish.

A view’s permission starts when its row is loaded and ends at the next load. If the first view will be read after a second load, both uses need the buffer across the same boundary, and the first one reads the wrong row. TypeScript and Go both allow that sequence; only the contract and the tests catch it.

If the first view’s last use happens before the second load, the next load can proceed. That is why the immediate byte counter works. If the result must escape the producer, a copy gives it storage that can survive independently.

  1. Load row 1

    Borrow starts.

  2. Use the view

    Read or copy what is needed.

  3. Last borrowed use

    Nothing later depends on that view.

  4. Reuse the buffer

    The producer can write again.

Storage lifetime and useful lifetime can differReachable is not unchanged

In TypeScript and Go, retaining a view can keep its backing storage reachable after a local name disappears. That avoids a dangling memory reference here; it does not preserve the old contents during reuse. Go explicitly permits references to local data to outlive a call when needed; see its allocation FAQ.

The useful questions are which value owns the data and which later operations are permitted, rather than a guess about whether a box in the diagram lives on the stack or heap.

Ownership also decides the ending

A helper’s return is not necessarily the resource’s end.

Now put a real input file around the same import operation. The importer opens it. A helper validates a header, then returns. The importer still needs to read the body.

The helper has borrowed access. If it closes the file just because its own function is finished, it ends the resource’s useful lifetime too early. If everyone assumes someone else will close it, the lifetime can stretch too far. State the owner and put cleanup at that owner’s boundary.

Importer owns the operation

Acquire input → lend it to the validator → read the body → release input.

Validator borrows access

Inspect the header → return a result. Leave the caller’s input available.

Arrange cleanup for early returns and failures as well as success. TypeScript can use try/finally around an acquired resource. Go commonly defers its Close call at the owning function. These mechanisms support an ownership decision; they do not decide who should own the resource for you.

Cleanup can have its own failure contract. Check a fallible close when its result matters. Also keep ordinary cleanup separate from promises about abrupt process termination. Our row-buffer programs use only memory and open no file; this lifecycle is an application of the model.

Cleanup, scope, and garbage collectionThree related mechanisms

Go’s defer guidance demonstrates placing file cleanup next to acquisition.

JavaScript finalization is not a deadline for releasing a resource. FinalizationRegistry cleanup is implementation dependent and may not run. If a file, subscription, or browser resource needs an explicit end, give that responsibility to a known owner.

Build UIs?See where this shows up in your components.
Frontend connection: two views, one object URLWho is allowed to revoke it?

An upload editor creates one object URL for a file preview. A thumbnail and a larger preview both use the URL. Closing the thumbnail should not revoke a resource the larger preview still needs. The editor that owns their shared lifetime can revoke the URL after its final consumer is finished.

Copying the URL string gives another name for the same registered resource; it does not duplicate that resource. Revocation removes the mapping used by later URL dereferences. It does not promise that an already loaded image immediately disappears. The File API’s revocation rules define that behavior.

If the large preview must outlive the editor, move responsibility to an owner with that longer lifetime or arrange a separate resource. The same question from the buffer returns: does this consumer’s use fit inside the owner’s interval?

Choose a boundary for the next use

Borrow for now. Own what must survive.

Who needs the data after this point?

The importer must keep loading rows. A preview worker will read the queued bytes afterward. Which boundary makes that safe?

A note worth keeping

Name the owner. Find the aliases. Put the last use before reuse or release, or transfer responsibility for data that must live longer.

What will use this value next? Which operation could change or invalidate it first? Who is responsible for ending its lifetime?

Scope and verificationA small executable model

The lab records real operations from the displayed TypeScript buffer and copy function, then replays scalar snapshots. It compares backing storage identity and tracks the contract’s load intervals. It does not measure memory allocation, run native code in the browser, or act as an arbitrary-code sandbox.

Shared tests cover preserved row bytes and immediate counts. TypeScript and Go also execute the broken retained-view cases. Native checks exercise overlap, mutation, and ownership boundaries. The loader deliberately handles bounded printable ASCII rows. CSV parsing, streaming I/O, threads, buffer pools, leases, queue backpressure, and resource cleanup implementations remain outside this example.

Connections to follow nextRelated lessons