Letting CreateRatio's background worker go idle
How a small polling guard lets an inactive background worker exit without losing pending activity or resuming with stale settings.
Reading Felix Rieseberg's How to be more ambitious: A tutorial for humans with models inspired me to keep improving CreateRatio. A good first candidate was the memory held by its background worker when there was no activity to track.
Sometimes the useful performance improvement is not making a task faster. It is recognizing when there is no task to do.
CreateRatio PR #2 applies that idea to a browser extension's creation tracker. The change is small: skip the five-second refresh-and-flush cycle when a page is inactive and has no measurements waiting for delivery. The consequence is more interesting: empty polling no longer keeps the background worker alive.
Watch the mechanism
Here's how the change works, from skipping empty requests to keeping real activity safe.
Before: an empty queue still caused work
CreateRatio separates the tracker running in a website tab from the extension's background worker. The tracker classifies activity and sends measurements. The worker handles extension-side work.
Before the change, each tracker ran this every five seconds:
const interval = setInterval(() => {
refresh();
flush();
}, 5000);
flush() prepares recorded activity for delivery. Even when there's nothing to send, it asks the worker for a snapshot of the latest settings.
That means “nothing to deliver” still produces a message to the worker. An inactive page can keep asking for an update it does not currently need.
The important distinction is between the content script, which stays in the tab, and the background worker, whose process can exit when the browser lets it become idle. Keeping one quiet does not remove the other.
After: work needs a reason
The PR keeps the timer but guards its work:
const interval = setInterval(() => {
if (
outbox.length ||
(pageActive &&
document.visibilityState === 'visible' &&
document.hasFocus())
) {
refresh();
flush();
}
}, 5000);
The timer only needs to act in two cases:
- Undelivered measurements exist. Try to send them, even if the page is inactive.
- The page is active, visible, and focused. Continue the normal refresh-and-flush cycle.
Otherwise, the timer ticks without doing that work. The tab stays open, and the browser can let the worker go to sleep.
Quiet must not mean losing measurements
Stopping work is easy. Doing it without losing anyone's activity means handling three everyday situations.
Flush the last partial interval
Suppose someone interacts with the page, then switches away two seconds later—before the next five-second tick. Those two seconds still matter.
The existing handlers still save activity when you leave the page. The new guard doesn't replace them, so those two seconds aren't lost just because the next timer tick hasn't arrived.
Keep retrying an undelivered packet
A delivery can fail as the page becomes inactive. If the guard checked only visibility, that packet could be stranded.
That's why the outbox.length check matters. While activity is waiting to be delivered, the timer can keep retrying the same measurement. It preserves the original two seconds without adding time while you're away.
Once the queue is empty and the page remains inactive, the recurring work can stop again.
Refresh settings on return
Settings can change while you're away. CreateRatio refreshes them when you return, so disabled tracking stays disabled. If you turn it back on, it only counts new activity—not the time you spent away.
What changed in practice
The before-and-after test used Chromium on Linux with twenty offline X test tabs and three separate runs for each version. Another tab stayed in the foreground while the worker was observed for 42 seconds. The debugger was disconnected during that observation so it wouldn't keep the worker awake.
Watch the worker disappear
Blue is before the fix; teal is after. Each line counts the separate test runs in which the worker was still awake.
Before the fix, the worker stayed alive for the whole observation. After the fix, it was first seen to have stopped at around 24 seconds in all three runs. The timing will vary with the browser and what else the extension is doing. Returning to a supported tab woke a new worker and restored the saved settings each time.
| Measurement | Before | After |
|---|---|---|
| Median worker-process PSS at idle checkpoint | 18.32 MiB | 0; process exited |
| Median worker-process private resident memory at that checkpoint | 13.68 MiB | 0; process exited |
| Median worker-process PSS while attached and awake | 17.30 MiB | 17.40 MiB |
| Runs with a live worker at idle checkpoint | 3/3 | 0/3 |
| Runs with a working worker on return | 3/3; already awake | 3/3; woke again |
The important part isn't that the running worker got smaller: it stopped running when it wasn't needed.
The memory figures are two views of the same process, not savings to add together. PSS includes a share of memory shared with other processes; private resident memory counts pages used only by this process.
These measurements followed the worker's own process, not the browser's total memory.
Content scripts still cost memory
The JavaScript memory used in the pages was essentially unchanged. A separate comparison with and without the content scripts estimated their added JS-heap cost at about 324 KiB per tab, or 6.3 MiB across twenty tabs.
Those scripts stay loaded while the worker sleeps. Zero worker-process memory is not zero extension memory.
Keeping AI-assisted changes lightweight
Fixing this once isn't enough. I also want my AI coding agents to avoid bringing the problem back with the next feature or refactor. So I turned “keep it lightweight” into a project contract:
- Let idle stay idle. An inactive tracker with nothing pending must not keep waking the worker with periodic requests.
- Bound what stays in memory. Queues and caches need limits, without silently discarding unsaved measurements. Timers, listeners, and observers need clear owners and lifetimes so repeated visits don't keep adding more.
- Measure resource-sensitive changes. Compare before and after under the same conditions, including many tabs or repeated navigation when those costs can change.
More memory can be a valid trade-off for a useful feature, but an increase beyond the agreed tolerance needs an explanation and my approval.
I also kept AGENTS.md short. It points to the standards in CONTRIBUTING.md and the verification steps in docs/testing.md, rather than repeating them. Each rule has one home, so agents don't have to choose between overlapping instructions as the project evolves.
The existing regression tests run in CI: they check inactive polling, delivery retries, and settings on return. They don't measure RAM or prove worker shutdown. Those still need browser measurements before I consider a resource-sensitive change done.
The takeaway
The win is not a more efficient empty request. It is refusing to send that request until there is a reason.
CreateRatio still saves the activity you've earned, retries if delivery fails, and picks up current settings when you return. It just doesn't need to keep the worker awake when there's nothing to do.