Describe the bug
The Call History table grows without bound via the call_log() path, and browser CPU scales with its row count until one core is saturated. The cost lands in the Chrome browser process, not the renderer, so it degrades the whole browser rather than just the tab.
This looks like a missed path rather than a design choice: MAX_HISTORY_ROWS exists and is enforced — but only in the other function that writes to the same table.
The two append paths
appendCallHistory() (main.js:1893) caps correctly:
tableBody.insertBefore(newRow, tableBody.firstChild);
// cap rows (assumes MAX_HISTORY_ROWS global)
var maxRows = Number(MAX_HISTORY_ROWS) || 200;
while (tableBody.rows.length > maxRows) {
tableBody.deleteRow(tableBody.rows.length - 1);
}
call_log(d) (main.js:1514) writes to the same #callHistoryBody and never prunes — no deleteRow, no removeChild, no rows.length check anywhere in its body.
The intent is already stated at main.js:76-79:
// MAX_HISTORY_ROWS: Maximum number of entries retained in the Call History table.
// Older rows beyond this limit should be pruned to prevent unbounded
// table growth and UI performance degradation.
const MAX_HISTORY_ROWS = 1000;
git log -S suggests why one path was missed: call_log() was added in 2f035e3 (2025-04-20), and MAX_HISTORY_ROWS came later in 7859de9 (2026-02-12). The cap was applied to the function being worked on at the time; the older path was not updated.
Evidence
Measured from a controlled Chrome instance (separate --user-data-dir, incognito, extensions disabled) against a live P25 system. CPU as a percentage of one core, 30s samples, from a clean load:
rows: 14 23 48 70 102 156 190 209 234 284 324 369 410 432 539 672 692
browser_cpu: 0 1 2 2 6 12 18 13 15 27 87 100 109 103 112 101 111
gpu_cpu: 0 1 0 0 1 1 1 0 1 1 1 1 0 0 1 1 0
renderer_cpu: 0 2 2 2 2 3 3 3 3 4 4 3 3 3 3 4 4
heap_MB: 1.6 1 1.7 1.7 1.1 1.3 1.1 1.8 1.7 1.1 1.7 1.8 1.7 1.7 2.2 2.1 1.9
GPU flat ~1%, renderer flat 2-4%, JS heap flat 1-2 MB. Only the browser process moves, and it tracks row count. The knee is between ~284 and ~369 rows.
Causal test — at 851 rows the browser process was at 90-113% of one core. Trimming the tbody to 40 rows via CDP, changing nothing else (same page, same server, same data flow, no reload):
851 rows -> 90-113% of one core
83 rows -> 3% of one core (regrew from 40 during the sampling window)
Also corroborating: at ~719 rows the browser process failed to answer its own /json/list debug endpoint within 5 seconds.
On the reporting user's system rows accrued at ~26/min, reaching saturation in roughly 14 minutes, matching the original complaint that the tab degraded "after a few minutes". Correction to an earlier characterisation of that system as "quiet": only the talkgroups being monitored are quiet — Call History logs all system activity, and the system itself is busy. So the real-world row rate is higher than 26/min, and upstream's current 1000 is reached sooner than that figure suggests.
Reproduction note
Chrome's native window occlusion detection can mark the window visibilityState: "hidden" even while maximized, throttling everything to ~0% and making the problem look absent. Measurements taken with the tab behind another window are throttled and not comparable. The dose-response run above used:
--disable-features=CalculateNativeWinOcclusion --disable-backgrounding-occluded-windows
--disable-renderer-backgrounding --disable-background-timer-throttling
Suggested fix
⚠️ Correction to the original version of this report. It first suggested simply applying the
existing cap inside call_log(). That is a mitigation, not a fix, and on its own it ships a
data-loss regression — see below. Please read this section rather than the edit history.
The table is also the export buffer. csvTable() (main.js:3082) builds the CSV by reading
innerText straight out of the DOM rows:
const rows = document.querySelectorAll(`table#${table_id} tr`);
...
const cols = rows[i].querySelectorAll('td');
const row = Array.from(cols).map(cell => cell.innerText.trim() ...);
There is no backing store behind it: no array of call records, indexedDB and sessionStorage
are unused, and all 28 localStorage uses are settings (column widths, colors, toggles). The
server sends deltas rather than history — a live poll returned call_log with zero entries.
#callHistoryBody is not a view of a model; it is the model.
So capping the table caps the export. Row N+1 is not paged out, it is gone. Note this applies
to the existing MAX_HISTORY_ROWS = 1000 on the appendCallHistory() path too — that path
already silently truncates exports today, just later. The tension predates this report.
Decoupling store from view gets all three properties at once:
- Keep call records in a JS array as they arrive.
- Render only the most recent N rows into the table.
- Export from the array, not the DOM.
The measurements support this split directly: with ~850 rows in the DOM the JS heap sat at
1-2 MB, so the record objects cost effectively nothing — it is the DOM nodes that drive the
browser-process CPU. A render window keeps CPU flat without making retention the price.
The dose-response curve above should therefore set the render window (~200 keeps you well
clear of the ~370 knee), not a data limit.
Why the browser process rather than the renderer
Unknown, and deliberately not guessed at here. DOM cost would normally land in renderer + GPU, and both are flat across the whole curve. Something in the browser process scales with DOM size or mutation rate on this page; accessibility-tree maintenance is a candidate but was not tested. The fix does not depend on the answer.
System Information
- OP25: master
28f2c40
- Server: Raspbian 12 (bookworm), Raspberry Pi 4 Model B, GNU Radio 3.10.5.1, Python 3.11.2
- Browser: Chrome on Windows 11, separate machine, reaching the host over a VPN
Relationship to #300
Independent. #300 is a server-side payload problem and is fixed by capping what the server sends; this is purely client-side table growth and persists with small payloads. Both were present together, which is why the earlier symptoms were hard to separate.
Describe the bug
The Call History table grows without bound via the
call_log()path, and browser CPU scales with its row count until one core is saturated. The cost lands in the Chrome browser process, not the renderer, so it degrades the whole browser rather than just the tab.This looks like a missed path rather than a design choice:
MAX_HISTORY_ROWSexists and is enforced — but only in the other function that writes to the same table.The two append paths
appendCallHistory()(main.js:1893) caps correctly:call_log(d)(main.js:1514) writes to the same#callHistoryBodyand never prunes — nodeleteRow, noremoveChild, norows.lengthcheck anywhere in its body.The intent is already stated at main.js:76-79:
git log -Ssuggests why one path was missed:call_log()was added in2f035e3(2025-04-20), andMAX_HISTORY_ROWScame later in7859de9(2026-02-12). The cap was applied to the function being worked on at the time; the older path was not updated.Evidence
Measured from a controlled Chrome instance (separate
--user-data-dir, incognito, extensions disabled) against a live P25 system. CPU as a percentage of one core, 30s samples, from a clean load:GPU flat ~1%, renderer flat 2-4%, JS heap flat 1-2 MB. Only the browser process moves, and it tracks row count. The knee is between ~284 and ~369 rows.
Causal test — at 851 rows the browser process was at 90-113% of one core. Trimming the tbody to 40 rows via CDP, changing nothing else (same page, same server, same data flow, no reload):
Also corroborating: at ~719 rows the browser process failed to answer its own
/json/listdebug endpoint within 5 seconds.On the reporting user's system rows accrued at ~26/min, reaching saturation in roughly 14 minutes, matching the original complaint that the tab degraded "after a few minutes". Correction to an earlier characterisation of that system as "quiet": only the talkgroups being monitored are quiet — Call History logs all system activity, and the system itself is busy. So the real-world row rate is higher than 26/min, and upstream's current 1000 is reached sooner than that figure suggests.
Reproduction note
Chrome's native window occlusion detection can mark the window
visibilityState: "hidden"even while maximized, throttling everything to ~0% and making the problem look absent. Measurements taken with the tab behind another window are throttled and not comparable. The dose-response run above used:Suggested fix
existing cap inside
call_log(). That is a mitigation, not a fix, and on its own it ships adata-loss regression — see below. Please read this section rather than the edit history.
The table is also the export buffer.
csvTable()(main.js:3082) builds the CSV by readinginnerTextstraight out of the DOM rows:There is no backing store behind it: no array of call records,
indexedDBandsessionStorageare unused, and all 28
localStorageuses are settings (column widths, colors, toggles). Theserver sends deltas rather than history — a live poll returned
call_logwith zero entries.#callHistoryBodyis not a view of a model; it is the model.So capping the table caps the export. Row N+1 is not paged out, it is gone. Note this applies
to the existing
MAX_HISTORY_ROWS = 1000on theappendCallHistory()path too — that pathalready silently truncates exports today, just later. The tension predates this report.
Decoupling store from view gets all three properties at once:
The measurements support this split directly: with ~850 rows in the DOM the JS heap sat at
1-2 MB, so the record objects cost effectively nothing — it is the DOM nodes that drive the
browser-process CPU. A render window keeps CPU flat without making retention the price.
The dose-response curve above should therefore set the render window (~200 keeps you well
clear of the ~370 knee), not a data limit.
Why the browser process rather than the renderer
Unknown, and deliberately not guessed at here. DOM cost would normally land in renderer + GPU, and both are flat across the whole curve. Something in the browser process scales with DOM size or mutation rate on this page; accessibility-tree maintenance is a candidate but was not tested. The fix does not depend on the answer.
System Information
28f2c40Relationship to #300
Independent. #300 is a server-side payload problem and is fixed by capping what the server sends; this is purely client-side table growth and persists with small payloads. Both were present together, which is why the earlier symptoms were hard to separate.