Deepen PracticeOrdered learning track

Client-Side Write-Ahead Log

Learn Multiple Tab Orchestration and Web Worker In Action - Part 052

Building a client-side write-ahead log for browser applications, including WAL records, commit markers, recovery, outbox integration, storage transactions, workers, compaction, and failure testing.

15 min read2812 words
PrevNext
Lesson 5272 lesson track40–59 Deepen Practice
#browser#write-ahead-log#indexeddb#web-locks+7 more

Part 052 — Client-Side Write-Ahead Log

Goal: build a browser-local write-ahead log that makes important client-side mutations recoverable after crashes, reloads, worker restarts, tab races, and partial side effects.

A write-ahead log, or WAL, is a simple idea:

Before mutating important state, first record the intent durably.

Databases use WALs to recover after crashes. Browser applications can use the same principle at a smaller scale.

The browser version is not a database engine. It is a reliability pattern for local workflows such as:

  • offline mutation queue
  • multi-step form save
  • staged file import
  • Cache API manifest promotion
  • OPFS blob write
  • IndexedDB projection update
  • auth/session cleanup transaction
  • background sync replay
  • Service Worker cache update
  • cross-tab single-flight result handoff

The problem is not that IndexedDB transactions are useless. They are useful. The problem is that real browser workflows often span more than one operation boundary:

write IndexedDB row
write OPFS file
update Cache API response
send network request
broadcast signal
update projection

No browser API gives you a single atomic transaction across all of those. A client-side WAL gives you a recovery protocol.


1. WAL mental model

A WAL separates three things:

  1. intent
  2. application of intent
  3. completion marker

If the tab crashes after PREPARE but before COMMIT, recovery can inspect the WAL and decide what to do.

The WAL does not remove failure. It turns hidden partial failure into an explicit state.


2. WAL vs event log vs outbox

These terms overlap but are not the same.

PatternMain questionTypical content
Event logWhat happened?Domain facts and state transitions
OutboxWhat must be sent?Pending server sync items
WALWhat local mutation is in progress?Prepare/apply/commit records for recovery

A single implementation can share storage. But the mental model should stay separate.

Example: offline case approval.

The event log explains domain state. The outbox tracks remote delivery. The WAL protects multi-step local mutation.


3. Browser-specific WAL constraints

A browser WAL must be designed around these realities:

ConstraintConsequence
Tabs can close anytimeRecovery must happen on next startup/resume.
Workers can terminateWorker tasks need durable step markers.
Broadcast can be missedWAL completion cannot depend on broadcast delivery.
IndexedDB has transactions, but not across OPFS/Cache/networkWAL protects cross-store workflows.
Page may freezeDo not require cleanup callbacks.
Multiple tabs may recover simultaneouslyRecovery must be coordinated or idempotent.
Storage quota existsWAL must be compacted.
Old code may runWAL records need schema versioning.

A browser WAL should prefer redo over rollback.

Rollback is hard because external side effects may already have happened. Redo is safe if the operation is idempotent.


4. WAL record schema

A WAL record should be explicit enough for recovery without requiring the original in-memory context.

type WalRecord = {
  walId: string;
  operationId: string;
  operationType: string;
  schemaVersion: number;

  phase: "prepare" | "step" | "commit" | "abort" | "repair-required";
  step?: string;

  status: "open" | "committed" | "aborted" | "repair-required";

  actorId?: string;
  tabId: string;
  connectionId: string;
  sessionId?: string;
  tenantId?: string;

  idempotencyKey?: string;
  fencingToken?: string;

  createdAt: string;
  updatedAt: string;
  deadlineAt?: string;

  payloadRef?: string;
  payload?: unknown;

  error?: {
    name: string;
    message: string;
    code?: string;
    retryable?: boolean;
  };
};

Important fields:

FieldPurpose
walIdUnique record ID.
operationIdGroups all records for one logical operation.
operationTypeRecovery strategy selector.
phaseWhat part of the operation this record represents.
statusWhether the operation is still open.
idempotencyKeyPrevents duplicate external or local effects.
fencingTokenPrevents stale owner from committing after leadership changed.
payloadRefPoints to OPFS/Cache/IndexedDB payload instead of embedding large bytes.

Use small WAL records. Large payloads belong in OPFS, Cache API, or a dedicated IndexedDB store referenced by ID.


5. WAL store layout in IndexedDB

Suggested stores:

Object stores:

StoreKeyPurpose
walRecordswalIdAppend-ish records for operation phases.
walOperationsoperationIdCurrent operation status/head.
payloadRefspayloadIdMetadata for OPFS/Cache/large values.
leasesresourceIdFallback recovery ownership if Web Locks unavailable.
metastringschema, runtime, compaction metadata.

Useful indexes:

IndexQuery
byOperationall records for operation
byStatusopen/repair operations
byTypeStatusrecovery by operation type
byCreatedAtcompaction and stale detection
byDeadlineexpired operation detection
byIdempotencyKeyduplicate prevention

6. Minimal WAL API

A clean WAL API looks like this:

type BeginWalInput = {
  operationType: string;
  operationId?: string;
  idempotencyKey?: string;
  fencingToken?: string;
  payload?: unknown;
  payloadRef?: string;
  deadlineAt?: string;
};

type WalHandle = {
  operationId: string;
  appendStep(step: string, payload?: unknown): Promise<void>;
  commit(payload?: unknown): Promise<void>;
  abort(error: unknown): Promise<void>;
  markRepairRequired(error: unknown): Promise<void>;
};

Usage:

const wal = await walStore.begin({
  operationType: "case-local-append",
  idempotencyKey: `case:${caseId}:approve:${commandId}`,
  payload: { caseId, commandId },
});

try {
  await appendEventAndOutboxItem(command);
  await wal.appendStep("event-and-outbox-written", { caseId });

  await updateProjectionHint(caseId);
  await wal.appendStep("projection-hint-written");

  await wal.commit();
  eventBus.publish({ type: "event-log.appended", fromSeq, toSeq });
} catch (err) {
  await wal.abort(err);
  throw err;
}

This is not enough for every operation. But it creates a consistent shape.


7. Atomicity inside IndexedDB

If the entire workflow fits inside one IndexedDB transaction, use the transaction first. Do not add WAL complexity unnecessarily.

Good transaction-only workflow:

await tx(["events", "aggregateHeads", "outbox"], "readwrite", async tx => {
  await tx.events.add(event);
  await tx.aggregateHeads.put(head);
  await tx.outbox.add(outboxItem);
});

No WAL needed if:

  • all writes are in the same IndexedDB database
  • all writes are in one transaction
  • no external side effect occurs in the middle
  • failure means transaction aborts cleanly

Use WAL when:

  • operation crosses IndexedDB + OPFS
  • operation crosses IndexedDB + Cache API
  • operation crosses local write + network call
  • operation has long-running worker steps
  • operation must resume after restart
  • operation has external side effects
  • operation must be observable during recovery

A WAL is not a replacement for transactions. It is what you use when transactions stop at the API boundary.


8. Operation pattern: OPFS staged write + IndexedDB metadata

Example: a worker imports a large CSV file into OPFS and registers metadata in IndexedDB.

Desired invariant:

Metadata should not point to a missing or incomplete file.

Workflow:

Recovery states:

Last WAL phaseRecovery action
prepare onlydelete temp if exists, mark aborted or retry
temp-file-writtenverify file/checksum, continue or cleanup
checksum-verifiedpromote if final missing, else verify metadata
metadata-written but no commitverify final file + metadata, then commit
commitno recovery needed

Pseudo-code:

async function recoverImport(op: WalOperation) {
  const records = await wal.recordsFor(op.operationId);
  const last = lastRecord(records);

  switch (last.step) {
    case undefined:
      await cleanupTemp(op.operationId);
      return wal.abortOperation(op.operationId, "no progress before crash");

    case "temp-file-written":
      if (await checksumMatches(op.payloadRef)) {
        return continueImportFromChecksum(op);
      }
      await cleanupTemp(op.operationId);
      return wal.markRepairRequired(op.operationId, "temp checksum mismatch");

    case "metadata-written":
      if (await finalFileAndMetadataMatch(op)) {
        return wal.commitOperation(op.operationId);
      }
      return wal.markRepairRequired(op.operationId, "metadata/file mismatch");

    default:
      return wal.markRepairRequired(op.operationId, "unknown import step");
  }
}

The WAL makes recovery local and deterministic.


9. Operation pattern: Cache manifest promotion

Problem:

You want to update a set of cached artifacts. You do not want tabs to see a half-promoted cache version.

Use a manifest with WAL.

Recovery:

Failure pointRecovery
staging cache created, incompletedelete staging cache
staging complete, manifest not pendingresume manifest write or delete staging
pending manifest existsvalidate and promote or rollback
active manifest promoted, WAL not committedcommit WAL and broadcast later

Do not let readers choose “latest cache name by sorting names”. Readers should read the manifest.

type CacheManifest = {
  activeVersion: string;
  previousVersion?: string;
  status: "active" | "promoting" | "rollback";
  updatedAt: string;
  artifacts: Array<{
    requestUrl: string;
    cacheName: string;
    integrity?: string;
  }>;
};

The manifest is the authority. Cache names are storage implementation details.


10. Operation pattern: offline outbox send

Sending an outbox item has external side effects. A crash can happen after the server accepted the request but before the browser records the ACK.

The only safe answer is idempotency.

If crash occurs after server accepted but before local ACK:

  1. recovery sees open WAL send
  2. retry sends same idempotency key
  3. server returns same result or current status
  4. browser records ACK
  5. WAL commits

Without server idempotency, the browser cannot make this safe. It can only reduce damage.


11. WAL recovery ownership

Multiple tabs may try to recover the same WAL records. Use ownership.

Preferred:

await navigator.locks.request("wal:recovery", async () => {
  await recoverOpenOperations();
});

If Web Locks is unavailable, use an IndexedDB lease.

type Lease = {
  resourceId: string;
  ownerId: string;
  fencingToken: number;
  expiresAt: string;
};

Acquisition:

async function acquireRecoveryLease(now: number): Promise<Lease | null> {
  return tx(["leases"], "readwrite", async tx => {
    const lease = await tx.leases.get("wal:recovery");

    if (lease && Date.parse(lease.expiresAt) > now) {
      return null;
    }

    const next: Lease = {
      resourceId: "wal:recovery",
      ownerId: runtime.connectionId,
      fencingToken: (lease?.fencingToken ?? 0) + 1,
      expiresAt: new Date(now + 15_000).toISOString(),
    };

    await tx.leases.put(next);
    return next;
  });
}

Every recovery commit should verify the fencing token when possible.

Stale recovery owners must not finalize operations after lease loss.


12. Startup recovery flow

Run recovery at controlled times:

  • app startup after DB open
  • tab visible/focus after being hidden
  • Service Worker activation, if relevant
  • leader election acquisition
  • before starting sync flush
  • after schema migration

Flow:

A runtime should not declare itself fully ready until critical WAL recovery has run.

async function boot() {
  await openDatabase();
  await runCriticalRecovery();
  await startProjectionRuntime();
  await startUserInterface();
}

Some recovery can be non-critical and continue later. For example, cleaning old staging cache can be delayed. Session revocation cleanup should not be delayed.


13. Recovery handler registry

Each operation type needs a recovery handler.

type RecoveryHandler = {
  operationType: string;
  recover(operation: WalOperation, records: WalRecord[]): Promise<void>;
};

Registry:

const recoveryHandlers: Record<string, RecoveryHandler> = {
  "opfs-import": opfsImportRecovery,
  "cache-promote": cachePromoteRecovery,
  "outbox-send": outboxSendRecovery,
  "session-logout": sessionLogoutRecovery,
  "projection-rebuild": projectionRebuildRecovery,
};

Unknown operation type:

await wal.markRepairRequired(operation.operationId, {
  name: "UnknownWalOperation",
  message: `No recovery handler for ${operation.operationType}`,
  retryable: false,
});

Do not silently ignore unknown open WAL records. That is how local state corruption becomes invisible.


14. Idempotent local steps

Design every WAL-protected step to be idempotent.

StepIdempotent design
Write OPFS temp filedeterministic temp path per operation ID
Promote OPFS fileif final exists, verify checksum
Write IndexedDB metadataupsert with expected operation ID
Insert eventidempotency key returns existing event
Add Cache entrysame request key and integrity metadata
Mark outbox ackedidempotent status transition
Broadcast signalsafe to send multiple times
Cleanup tempignore missing temp file

Example:

async function promoteFile(op: ImportOperation) {
  if (await exists(op.finalPath)) {
    if (await checksum(op.finalPath) === op.expectedSha256) return;
    throw new Error("final path exists with wrong checksum");
  }

  await rename(op.tempPath, op.finalPath);
}

Idempotency turns crash recovery from guesswork into engineering.


15. WAL status state machine

A WAL operation should have a small state machine.

Invalid transitions should be rejected.

function assertTransition(from: WalStatus, to: WalStatus) {
  const allowed = new Set([
    "open->committed",
    "open->aborted",
    "open->repair-required",
    "repair-required->open",
    "repair-required->aborted",
    "committed->compacted",
    "aborted->compacted",
  ]);

  if (!allowed.has(`${from}->${to}`)) {
    throw new Error(`invalid WAL transition ${from} -> ${to}`);
  }
}

A WAL with free-form statuses becomes another unreliable state store.


16. Deadlines and stale operations

Every open WAL operation should eventually be classified.

type WalDeadlinePolicy = {
  operationType: string;
  softDeadlineMs: number;
  hardDeadlineMs: number;
  onSoftDeadline: "warn" | "retry" | "recover";
  onHardDeadline: "repair-required" | "abort" | "force-recover";
};

Example policies:

OperationSoft deadlineHard deadlineHard action
OPFS import1 min30 minrepair-required
Cache promote30 sec5 minrollback or repair
Outbox send30 sec24 hrkeep pending, no repair if offline
Session logout cleanup5 sec30 secforce cleanup again
Projection rebuild10 sec5 minrestart rebuild

Do not mark offline outbox sends as corrupt just because the user stayed offline. Deadline policy must reflect operation semantics.


17. WAL and Service Worker

Service Workers can participate in WAL-protected workflows, but do not assume they are always running.

Use Service Worker WAL for:

  • cache promotion
  • background sync attempt markers
  • push handling dedupe
  • notification click routing markers
  • offline request replay coordination

Caveats:

  • Service Worker can be terminated when idle
  • long async work must be tied to extendable events correctly
  • clients may be uncontrolled on first load
  • multiple service worker versions can exist during update lifecycle
  • IndexedDB connections can be blocked by old clients

A Service Worker recovery handler should be short and idempotent.

self.addEventListener("activate", event => {
  event.waitUntil((async () => {
    await claimIfPolicyAllows();
    await recoverServiceWorkerWalOperations();
  })());
});

Do not rely only on Service Worker activation for recovery. The window runtime should also recover critical operations.


18. WAL and Dedicated Workers

Dedicated Workers are useful for long-running WAL-protected operations.

Pattern:

If the worker crashes, the UI can restart it and ask recovery to continue from the WAL.

Worker messages should include operationId.

type WorkerTask = {
  taskId: string;
  operationId: string;
  type: "import-file";
  payloadRef: string;
};

Do not use worker memory as the only source of progress. Progress that matters must be durable.


19. WAL and page lifecycle

Page lifecycle events are best-effort signals, not reliable commit hooks.

Bad:

window.addEventListener("beforeunload", async () => {
  await finishCriticalMutation();
});

Better:

await wal.begin(...);
await doRecoverableSteps();
await wal.commit();

Use lifecycle events to trigger recovery/catch-up, not to guarantee cleanup.

document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "visible") {
    void recoverOpenWalOperations();
  }
});

When page becomes hidden, stop admitting risky new long operations unless they are designed to recover.


20. Compaction

A WAL is operational metadata. It should not grow forever.

Compaction policy:

WAL statusSuggested retention
openkeep until recovered/classified
committedshort retention, e.g. hours/days for debugging
abortedshort retention if harmless, longer if user-visible
repair-requiredkeep until resolved/manual cleanup
compactedremove records and maybe keep summary marker

Compaction job:

async function compactWal(now: Date) {
  await withRecoveryLock(async () => {
    const candidates = await wal.findCompactable(now);

    for (const op of candidates) {
      if (op.status === "open") continue;
      if (op.status === "repair-required") continue;

      await cleanupPayloadRefs(op);
      await wal.compact(op.operationId);
    }
  });
}

Compaction must cleanup referenced payloads safely:

  • OPFS temp files
  • staging caches
  • orphaned payload rows
  • expired idempotency records

Never delete payloads still referenced by event log, outbox, manifest, or projection.


21. Security-sensitive WAL operations

Logout/session revocation is a good WAL use case.

Desired invariant:

Once logout begins, stale async work should not resurrect sensitive state.

Workflow:

Recovery after crash:

  • if logout WAL is open, repeat cleanup
  • reject protected work with stale session epoch
  • force auth revalidation before UI becomes ready
  • broadcast logout marker again if needed

Do not store tokens in the WAL. Store session epoch and reason code only.


22. Observability

Expose WAL health.

Metrics:

MetricMeaning
open WAL operationspossible in-progress or stuck work
repair-required countuser/system intervention needed
oldest open operation agestalled workflow detector
recovery durationstartup cost
recovery success/failure countreliability signal
compaction lagstorage growth risk
operations by type/statusfailure hotspot
duplicate idempotency hitsretry/double-click signal
stale fencing rejectsleadership race signal

Debug view:

WAL
---
open:                  2
repairRequired:        1
oldestOpenAge:          00:03:12
lastRecoveryAt:         2026-07-08T04:10:13Z
lastRecoveryDuration:   42ms

Open operations
---------------
opfs-import   op-123   step=temp-file-written   owner=conn-9
outbox-send   op-456   step=request-sent         owner=sw-v52

Repair required
---------------
cache-promote op-555   reason=manifest/cache integrity mismatch

For advanced debugging, keep a compact recent WAL trace:

type WalTrace = {
  operationId: string;
  transition: string;
  at: string;
  runtimeId: string;
};

23. Testing strategy

WAL testing should intentionally kill things between steps.

23.1 Unit tests

  • valid state transitions
  • invalid state transitions
  • duplicate idempotency key
  • unknown operation type
  • recovery handler classification
  • compaction candidate selection

23.2 Integration tests

  • crash after prepare
  • crash after each step
  • crash after external request but before local ACK
  • duplicate recovery from two tabs
  • stale fencing token commit
  • quota error during WAL append
  • schema migration with open WAL records

23.3 Browser automation tests

With Playwright or similar:

  1. open tab A
  2. begin import
  3. force reload after WAL prepare
  4. verify recovery resumes or repairs
  5. open tab B simultaneously
  6. verify only one recovery owner commits
  7. verify UI shows consistent final state

23.4 Chaos hooks

Add test-only crash points:

await chaos.point("after-wal-prepare");
await chaos.point("after-temp-file-written");
await chaos.point("after-server-accepted-before-ack");
await chaos.point("before-wal-commit");

A WAL without crash-point tests is mostly wishful thinking.


24. Common anti-patterns

Anti-pattern: WAL without recovery

Writing prepare records but never scanning them is worse than no WAL. It creates false confidence.

Anti-pattern: WAL as audit log

A WAL is operational recovery data. It is not automatically a domain audit log. Use event log for domain facts.

Anti-pattern: no idempotency

If recovery repeats a side effect and creates duplicates, the WAL did not solve the problem. It just made duplicate execution more systematic.

Anti-pattern: embedding huge payloads

Large payloads in WAL records create clone cost, quota pressure, and slow recovery. Use payload references.

Anti-pattern: committing after broadcast

Bad:

broadcastChange();
await wal.commit();

Better:

await wal.commit();
broadcastChange();

Receivers should only observe committed durable state.

Anti-pattern: one global recovery handler

Different operation types require different recovery semantics. A generic “retry everything” loop will eventually corrupt something.


25. Reference WAL runtime skeleton

class ClientWal {
  constructor(private readonly db: WalDb) {}

  async begin(input: BeginWalInput): Promise<WalHandle> {
    const now = new Date().toISOString();
    const operationId = input.operationId ?? crypto.randomUUID();
    const walId = crypto.randomUUID();

    await this.db.tx(["walRecords", "walOperations"], "readwrite", async tx => {
      if (input.idempotencyKey) {
        const existing = await tx.walOperations.index("byIdempotencyKey").get(input.idempotencyKey);
        if (existing && existing.status !== "aborted") {
          throw new DuplicateOperationError(existing.operationId);
        }
      }

      await tx.walOperations.add({
        operationId,
        operationType: input.operationType,
        status: "open",
        idempotencyKey: input.idempotencyKey,
        fencingToken: input.fencingToken,
        createdAt: now,
        updatedAt: now,
        deadlineAt: input.deadlineAt,
      });

      await tx.walRecords.add({
        walId,
        operationId,
        operationType: input.operationType,
        schemaVersion: 1,
        phase: "prepare",
        status: "open",
        tabId: runtime.tabId,
        connectionId: runtime.connectionId,
        sessionId: runtime.sessionId,
        tenantId: runtime.tenantId,
        idempotencyKey: input.idempotencyKey,
        fencingToken: input.fencingToken,
        createdAt: now,
        updatedAt: now,
        payload: input.payload,
        payloadRef: input.payloadRef,
      });
    });

    return new IndexedDbWalHandle(this.db, operationId);
  }

  async recoverAll(registry: RecoveryRegistry): Promise<void> {
    await withWalRecoveryOwnership(async lease => {
      const openOps = await this.db.findOpenOperations();

      for (const op of openOps) {
        if (lease && op.fencingToken && Number(op.fencingToken) > lease.fencingToken) {
          continue;
        }

        const handler = registry.get(op.operationType);
        if (!handler) {
          await this.markRepairRequired(op.operationId, new Error("unknown operation type"));
          continue;
        }

        const records = await this.db.recordsFor(op.operationId);
        await handler.recover(op, records);
      }
    });
  }

  async markRepairRequired(operationId: string, err: unknown) {
    await this.db.markRepairRequired(operationId, serializeError(err));
  }
}

Handle:

class IndexedDbWalHandle implements WalHandle {
  constructor(
    private readonly db: WalDb,
    public readonly operationId: string,
  ) {}

  async appendStep(step: string, payload?: unknown): Promise<void> {
    await this.db.appendRecord(this.operationId, {
      phase: "step",
      step,
      payload,
    });
  }

  async commit(payload?: unknown): Promise<void> {
    await this.db.tx(["walRecords", "walOperations"], "readwrite", async tx => {
      const op = await tx.walOperations.get(this.operationId);
      assertTransition(op.status, "committed");

      await tx.walRecords.add(record({
        operationId: this.operationId,
        phase: "commit",
        status: "committed",
        payload,
      }));

      await tx.walOperations.put({
        ...op,
        status: "committed",
        updatedAt: new Date().toISOString(),
      });
    });
  }

  async abort(error: unknown): Promise<void> {
    await this.db.abortOperation(this.operationId, serializeError(error));
  }

  async markRepairRequired(error: unknown): Promise<void> {
    await this.db.markRepairRequired(this.operationId, serializeError(error));
  }
}

This skeleton is intentionally plain. The hard part is not class design. The hard part is defining recovery semantics per operation type.


26. Production checklist

Before adding a client-side WAL, define:

  • Which operations need WAL protection?
  • What is the operation boundary?
  • What are the steps?
  • Which steps are idempotent?
  • Which steps have external side effects?
  • What idempotency key protects each side effect?
  • What payload is stored inline vs by reference?
  • What store owns payload references?
  • What does recovery do after each possible last step?
  • Can two tabs recover at the same time?
  • What lock/lease/fencing mechanism prevents stale commits?
  • What is the compaction policy?
  • What is the repair-required UX?
  • What happens on logout/session revocation?
  • What happens on schema migration?
  • What metrics prove the WAL is healthy?
  • What crash points are tested?

If you cannot define recovery behavior, do not add a WAL record yet. A WAL is a recovery contract, not a decorative log.


27. Final mental model

A client-side WAL is useful because browser workflows are interrupted all the time. Tabs close. Workers die. Pages freeze. Broadcasts are missed. Service Workers update. Network calls succeed while local writes fail. OPFS writes and IndexedDB updates do not share one universal transaction.

The WAL gives you a durable sentence:

“I started operation X, reached step Y, and therefore recovery should do Z.”

That sentence is the difference between guessing and engineering.

Use IndexedDB transactions when one transaction is enough. Use event sourcing when you need replayable domain facts. Use an outbox when work must be delivered remotely. Use a WAL when a local operation can be interrupted between meaningful steps.

The invariant is:

No important cross-store or external side-effect workflow should be able to fail halfway without leaving enough durable information to finish, retry, rollback, or ask for repair.

That is the role of a client-side write-ahead log.


References

Lesson Recap

You just completed lesson 52 in deepen practice. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.