diff --git a/CHANGELOG.md b/CHANGELOG.md
index 654e33f..a4a9dba 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -41,6 +41,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
and older unmarked image pairs are visibly flagged for regeneration.
- **Visual Identity Fingerprints.** Consistent pairs now record a compact fingerprint of canonical appearance fields;
changing those fields causes the existing pair to be flagged stale.
+- **Character Table Pagination.** Productions now render 50 character cards/rows at a time with an explicit Show more
+ control, keeping sorting and record lookups intact while reducing DOM and image work for large casts.
- **Security Regression Coverage.** Added a Python test gate for character metadata validation, private-download URL
rejection and oversized JSON request rejection.
- **Navigation Reliability.** Rehearser phase requests now survive the feature-module initialization boundary.
diff --git a/TODO.md b/TODO.md
index f8c53d6..2b61a25 100644
--- a/TODO.md
+++ b/TODO.md
@@ -53,7 +53,7 @@ the first block before investing in architecture or polish.
cross-record validation so mismatched profile/reference files are detected even when both are current-version.
- [ ] **M — Add bounded duplicate-generation protection.** Consistent portrait/reference jobs now deduplicate
repeated requests with `Idempotency-Key`; extend the same bounded contract to voice and casting jobs.
-- [ ] **M — Improve large-table performance.** Virtualize/paginate character and voice
+- [ ] **M — Improve large-table performance.** Character productions now paginate at 50 visible rows; paginate voice
tables, update only affected rows, and disable preloading on low-memory devices.
- [ ] **M — Add explicit cache invalidation and eviction.** Fish.audio search responses now have bounded eviction;
add equivalent LRU cleanup for audio/image entries and object URLs.
diff --git a/static/js/library-characters.js b/static/js/library-characters.js
index 656f7dc..ae78d3a 100644
--- a/static/js/library-characters.js
+++ b/static/js/library-characters.js
@@ -182,6 +182,15 @@ async function libraryRenderCharacters() {
const narrIdx = chars.findIndex(function (r) { return String(r.name || '').trim().toLowerCase() === 'narrator'; });
if (narrIdx > 0) chars.unshift(chars.splice(narrIdx, 1)[0]);
+ // Keep large productions responsive: the complete record set remains in
+ // memory for sorting, relationship lookups and bulk operations, while
+ // only one 50-row page is put into the DOM at a time.
+ const pageKey = 'ttsvc_libchars_page::' + book;
+ const pageSize = 50;
+ const requestedPage = Math.max(1, parseInt(localStorage.getItem(pageKey) || '1', 10) || 1);
+ const visibleCount = Math.min(chars.length, requestedPage * pageSize);
+ const visibleChars = chars.slice(0, visibleCount);
+
const cov = libBookCover(book);
const prod = document.createElement('div');
prod.className = 'lib-chars-production';
@@ -206,7 +215,7 @@ async function libraryRenderCharacters() {
+ ''
+ ''
+ ''
- + ''
+ + ''
+ ''
+ ''
+ '