The browser tab that ran all day
T04P behaved well for ten minutes and badly by the sixth hour, exposing timer drift, leaked listeners, unbounded history, and assumptions about browser attention.
Most of my T04P testing began with a fresh page. I opened the dashboard, watched a few updates, disconnected the network, reconnected it, and declared the behavior stable.
The actual screen opened before an event and remained open until the room emptied. By the sixth hour, its history panel lagged, duplicate status transitions appeared, and relative ages sometimes stopped advancing after the laptop slept.
Nothing had failed in one dramatic moment. The page had accumulated small assumptions that were harmless for ten minutes and expensive over a day.
I started testing the browser tab as a long-running process rather than a document somebody occasionally visited.
Fresh load had become a hidden repair mechanism
Reloading during development reset every troublesome resource. It cleared arrays, removed old DOM nodes, discarded timers, closed the socket, and installed one clean set of event listeners. The application appeared healthy partly because my workflow performed a full repair every few minutes.
The wallboard did not receive that repair. It survived network changes, browser backgrounding, display sleep, feed bursts, and configuration refreshes in one JavaScript lifetime.
I wrote a small endurance checklist:
open before the first simulated event
run at ordinary volume for two hours
burst for ten minutes
pause one source
sleep and wake the laptop
disconnect and reconnect twice
leave the tab backgrounded
return and continue for two more hoursThe goal was not an impressive uptime number. It was to force lifecycle transitions to happen without a reload concealing their residue.
I measured a few stable quantities at intervals: number of rendered history rows, normalized records in memory, active socket subscriptions, scheduled timers, and time to apply a new event. A value that grew without a product reason became an investigation.
The page did not need to run forever. It needed to remain predictable for the period people actually used it.
T04P appended every incoming event to an in-memory array and rendered the full array. That was not technically a leak; every record was still reachable because the code intended to keep it. It had the same practical effect.
The event history served two jobs. The visible panel helped someone understand the last few changes. The full session history supported later inspection. I had used one unbounded browser collection for both.
I separated them. The service retained the session record according to its own bounded policy. The browser kept a window appropriate to the visible task, initially the most recent 200 entries per relevant stream. Older items could be requested in pages when someone deliberately opened history.
The live view used a ring-like bounded structure rather than repeated array.slice() over an ever-growing list. When a new row entered, the oldest visible row left. Counts in diagnostics confirmed that the collection reached its limit and stayed there.
I also stopped preserving every derived label. The browser stored normalized event facts and computed presentation for the visible window. Caching formatted strings for thousands of departed rows had made memory harder to reason about.
Bounded history was not premature optimization. It was the data-retention policy of a long-running interface.
The DOM kept what the screen no longer showed
The first panel hid old rows with CSS after a display limit. The elements remained in the document. By late afternoon, the browser was laying out and retaining far more content than the interface admitted.
I changed the renderer to remove departed nodes. Each row had a stable event identity, so an update could target the existing row rather than append a replacement. The visible count matched the element count.
This exposed another mistake: references to removed elements stayed in a lookup object. Deleting a row from the DOM did not remove the JavaScript reference. The lookup needed the same eviction policy as the visible window.
I added a diagnostic assertion:
rendered row nodes <= visible history limit
row lookup entries == rendered row nodes
visible event records <= visible history limitThe exact numbers mattered less than their relationship. When one collection grew while the others stayed bounded, ownership had diverged.
Developer tools could show that memory increased, but the invariant pointed to why. A long-running view benefits from counters that describe what it believes it owns.
Reconfiguration installed listeners twice
T04P could switch between event configurations without a full reload. The code removed the old view, created a new one, and subscribed its handlers to the socket.
It did not unsubscribe every old handler.
After the first switch, one incoming event was processed twice. After another switch, three times. Some updates were idempotent assignments, so the bug appeared only as extra work. Counter increments made it visible as wrong state.
The original setup function closed over anonymous callbacks:
socket.on('entry', function (event) {
applyEntry(event)
})Without retaining the same function reference, cleanup could not reliably remove it. I gave subscriptions explicit ownership and a disposal path:
function subscribeToEntry(socket, applyEntry) {
function onEntry(event) {
applyEntry(event)
}
socket.on('entry', onEntry)
return function dispose() {
socket.removeListener('entry', onEntry)
}
}The configuration controller collected disposers and ran them before installing the next view. A test switched configurations repeatedly, then asserted that one emitted event produced one reducer call.
Cleanup stopped being an afterthought attached to page unload. Any component with a lifecycle needed to release the resources it acquired.
Reconnect multiplied a heartbeat
A related error created a new interval inside the socket connect handler. On every reconnect, another timer began sending browser heartbeats. None of the previous timers stopped.
The server logs showed increasing heartbeat volume from one tab, but the page still looked normal. This was the kind of bug a six-hour run found and a functional reconnect test missed.
I moved timer ownership outside the callback. The connection transition could start the one existing heartbeat schedule or pause it; it could not allocate a new anonymous interval each time.
The timer controller exposed its state in diagnostics:
heartbeat schedule: active
schedule instance: 1
last tick elapsed: 5.1s
last round trip: 84msI did not rely on an exact timer count from browser internals. The application tracked the resources it intentionally created. Starting an already active schedule became a no-op and logged an unexpected transition in development.
This changed how I wrote initialization. A callback that may run many times should not contain setup that assumes it runs once. Reconnection is repetition by definition.
Interval callbacks were not a clock
Relative ages advanced through setInterval(updateAges, 1000). I incremented a counter on each callback and displayed the result. When the tab was backgrounded or the laptop slept, callbacks slowed or stopped. Returning to the page resumed an age that was far too young.
The fix was to derive age from the observation timestamp and the current time on every render. The interval merely requested a repaint; it was not the source of elapsed truth.
function ageInSeconds(observedAt, now) {
return Math.max(0, Math.floor((now - observedAt) / 1000))
}On visibility change and window focus, the page recalculated immediately rather than waiting for the next interval. If the gap was long enough to challenge continuity, it entered recovery and checked a snapshot before calling the feed current.
This separated display cadence from time semantics. Updating once a second did not mean one second had elapsed between updates. A skipped render did not stop the observation from aging.
The same principle applied to timeouts. Their callback meant “check whether the deadline has passed,” not “the deadline is exactly now.” The handler compared explicit times before changing state.
Browser scheduling is cooperative and contextual. Product time cannot be defined by how often a tab receives attention from the event loop.
The Page Visibility API gave the application a useful signal that the document was hidden. I initially treated hidden as disconnected and paused all work. That saved some rendering and broke the recovery model when the tab returned.
The browser could remain connected while hidden. The service could continue receiving source observations. The local projection could keep a bounded stream or choose to suspend and reconcile later. Those were product choices, not a universal hidden-tab rule.
For T04P, I reduced presentation work while hidden. It stopped repainting relative ages and deferred nonessential DOM updates. It continued tracking the latest contiguous feed revision within bounded memory. On return, it rendered the current projection once and recalculated all visible ages.
If the hidden interval or buffered volume crossed a bound, the client discarded the tail and requested a snapshot. It did not attempt to replay hours of visual changes.
I avoided assuming that visibilitychange would always fire before suspension or sleep. It was a hint that improved efficiency, not the foundation of correctness. Timestamp and revision checks on return covered the cases where the browser disappeared without notice.
The interface did not show “offline” merely because it had been hidden. Attention state and network state were separate.
Rendering work grew with the wrong thing
Every incoming event triggered a full history render. The implementation emptied the list and recreated rows from the complete array. At ten records it was simple. At several thousand, one new event performed thousands of DOM operations.
The cost grew with session history when it should have grown with the visible change.
I updated the current value directly and appended or revised one history row. When the window evicted its oldest item, the renderer removed one node. A complete render remained available after a snapshot or configuration change, but ordinary events used incremental work.
I measured event-application time over the endurance run rather than relying on an early benchmark. The median mattered less than the slope. If applying the same kind of event became steadily slower as the day advanced, some operation still depended on accumulated history.
A test seeded the visible limit, applied another thousand events, and checked that render work and retained node count remained bounded. It did not demand identical timing across machines. It instrumented how many rows the code touched.
Counting work was more stable than setting a fragile millisecond threshold. One incoming counter update should not rebuild two hundred unrelated notes, regardless of processor speed.
During debugging, every message printed a full payload to the browser console. Leaving developer tools open made the slowdown dramatically worse because the console retained objects for inspection.
I replaced routine payload logs with transition summaries and counters. Detailed samples could be enabled for one feed and a bounded duration. The project kept enough information to diagnose a revision gap without serializing the entire state on every heartbeat.
The in-page diagnostic panel also capped its history. It showed the latest meaningful transitions and aggregate counts such as duplicates, recoveries, and discarded old rows. An instrumentation view that leaks memory undermines the system it is meant to explain.
This did not mean logs caused every performance problem. It meant the measurement setup had to resemble actual use. A profile taken with verbose logging could lead me to optimize the wrong path; a profile without the long-running conditions could miss the problem entirely.
I added a visible development reminder when detailed tracing was enabled. It was too easy to leave an expensive aid running and blame the dashboard later.
Replacing a view needed one owner
The page accumulated several controllers: socket connection, feed subscriptions, relative-age rendering, history, and configuration. Bugs appeared where two of them believed they owned the same transition.
Both the socket callback and configuration view could clear history. Both the feed adapter and UI could start recovery. Cleanup order varied depending on which button or network event happened first.
I introduced one session controller with a narrow lifecycle:
create session
start session
replace configuration
stop sessionEach child resource returned a disposer. Stopping was idempotent; calling it twice did not remove resources belonging to a newer session. A session identity prevented late callbacks from an old configuration from applying to the new one.
This was plain JavaScript organization, not a new framework. The important change was making ownership visible. Whoever started a timer, subscription, or asynchronous request also registered how it would end.
The controller made the endurance test repeatable. I could start and stop ten sessions inside one page, then assert that resource counts returned to their baseline before the next began.
Memory limits required product decisions
It was tempting to choose a large number for every cap and call the problem solved. The useful limits came from the task.
The wallboard needed enough recent history to explain a change seen a few minutes ago. It did not need the entire event in the DOM. The browser needed enough buffered revisions to bridge ordinary short interruptions. It did not need to act as the archive of record. The diagnostic panel needed recent transitions and aggregate totals, not every heartbeat.
Those decisions produced different limits for different collections. One global “maximum items” constant would have hidden their meanings.
When a limit was reached, behavior was explicit. Visible history evicted the oldest row and offered older-page retrieval. Recovery buffering abandoned the delta and requested a snapshot. Diagnostic transitions summarized counts after detailed samples rolled off.
No cap silently dropped unsent human observations. Those belonged to a small durable outbox with a different policy. Resource bounds had to respect the consequence of the data, not merely its shape in memory.
Performance work became clearer when retention policy and product responsibility were discussed together.
The endurance fixture became part of development
I could not wait six hours after every change. The long manual run found the categories; shorter deterministic fixtures protected them.
One accelerated scenario emitted the same number of events as a busy day, with ordinary and burst phases. Another repeated configuration replacement and reconnect cycles. A fake clock advanced through background and sleep-like gaps. Instrumentation asserted stable collection sizes and one active subscription per feed.
The fixture could not perfectly reproduce browser garbage collection or scheduler behavior. I still ran occasional real-time sessions on the target class of machine. The two forms of testing served different purposes: accelerated runs caught ownership regressions; real duration exposed environmental behavior and slow trends.
I saved snapshots of application counters at the start, midpoint, and end:
visible events: 200 / 200 / 200
rendered rows: 200 / 200 / 200
entry listeners: 1 / 1 / 1
heartbeat schedules: 1 / 1 / 1
median rows touched per event: 1 / 1 / 1The numbers were intentionally boring. Stability was the desired result.
A tab has a lifecycle even when the page has one URL
T04P's long-running problems did not require an exotic memory bug. They came from ordinary collections without retention rules, listeners without disposal, timers mistaken for clocks, callbacks that initialized more callbacks, and render work tied to session length.
The fresh page had hidden all of them.
After the changes, the tab could sleep, wake, reconnect, replace its configuration, and process a day-shaped stream while keeping its resource counts bounded. It still depended on the browser and service. It did not claim immortality. It had explicit recovery when its local continuity could no longer be defended.
This project changed my default test window. I began asking not only whether an interface worked after launch, but what it accumulated, what it owned, how it returned from absence, and which parts became slower with age.
A browser tab is easy to open and easy to mistake for disposable. When the product asks it to run all day, it deserves the same lifecycle discipline as any other long-lived process.
The endurance run did not produce a glamorous feature. It produced a page whose behavior at closing time still resembled its behavior at opening time. For an operational screen, that consistency was the feature.