My Debian 13 laptop was at 90% RAM, and it wasn't Firefox

#linux#systemd#memory#performance#zram
Linux / Systemd / Memory
KA
Kidus Alemayehu

TL;DR: On Debian 13, /tmp is a tmpfs, which means it lives in RAM. A leftover 1.4 GB Chrome download directory was silently eating memory. I moved /tmp to disk, added zram and earlyoom, and wrote a small Go watcher that tells me when memory pressure rises and who is responsible.

The problem

I keep a lot of Firefox tabs open, and my laptop (XPS 15 9570, 16 GB RAM, Debian 13) regularly sat at around 90% memory. Closing tabs barely helped, and no single process looked big in htop. I assumed Firefox was hoarding memory, since it's known for not returning heap to the OS. That turned out to be only part of the story.

First, read memory correctly

Linux uses spare RAM as page cache and gives it back when needed, so "used" is a misleading number. The one that matters is available:

bash
free -h

If available is still several GB, the system is healthy. It only becomes a problem when available is low and swap is filling or the system lags. Mine looked like this:

bash
total used free shared buff/cache available Mem: 15Gi 9.2Gi 3.0Gi 1.1Gi 4.8Gi 6.0Gi Swap: 15Gi 10Gi 4.9Gi

With 10Gi of swap used, my system was heavily relying on disk swapping!

The investigation

When memory is high but no process explains it, check the memory that isn't owned by a process at all. tmpfs mounts are the usual suspect:

bash
df -h /tmp /dev/shm

Mine returned:

bash
Filesystem Size Used Avail Use% Mounted on tmpfs 7.7G 1.7G 6.0G 22% /tmp tmpfs 7.7G 56M 7.6G 1% /dev/shm

1.7 GB used in /tmp. (Side note: tmpfs defaults to half of RAM, so a 7.7G size told me I had roughly 15 GB total.) Then I looked at what was in there:

bash
sudo du -h --max-depth=1 /tmp 2>/dev/null | sort -h | tail -15
160M    /tmp/pip-unpack-nibyk8eu
1.4G    /tmp/com.google.Chrome.chrome_chrome_url_fetcher_.IzjLGQ
1.7G    /tmp

A single leftover Chrome download directory was holding 1.4 GB. It was sitting in RAM, counted as used memory, and unlike page cache the kernel can't simply drop it, because it's real file data.

Why it happens

Debian 13 mounts /tmp as tmpfs by default. That's fast, but every file you put there costs RAM, and tools that crash or get interrupted leave files behind. If you run Go binaries, Python scripts, or Docker builds like I do, /tmp collects a lot of junk.

The fixes

1. Stop using RAM for /tmp. Mask the mount so /tmp becomes a normal directory on disk, then reboot:

bash
sudo systemctl mask tmp.mount

Trade-offs: temp I/O hits the disk instead of RAM (negligible on an SSD), data persists until cleaned, and a runaway process can now fill the disk instead of failing at a cap. To handle the persistence, add an age-based cleanup:

bash
echo 'D /tmp 1777 root root 7d' | sudo tee /etc/tmpfiles.d/tmp-clean.conf

2. Enable zram. Compressed swap in RAM gives the system a buffer before it starts thrashing:

bash
sudo apt install zram-tools # in /etc/default/zramswap: ALGO=zstd, PERCENT=50 sudo systemctl restart zramswap echo 'vm.swappiness=100' | sudo tee /etc/sysctl.d/99-swappiness.conf sudo sysctl --system

A high swappiness is reasonable with zram because swapping is cheap.

3. Install earlyoom. If memory does run out, it kills the biggest offender early instead of letting the machine freeze:

bash
sudo apt install earlyoom

4. Firefox itself. I installed an auto-tab-discard extension, confirmed browser.tabs.unloadOnLowMemory is true, and checked about:processes for heavy extensions. I also restart Firefox every day or two, since that's the only reliable way to hand fragmented heap back to the OS.

Building my own watcher

Existing tools kill processes or show graphs, but I wanted something that warns me early and names the culprit. The kernel exposes everything needed as plain files:

  • /proc/meminfo: MemAvailable, the real headroom
  • /proc/pressure/memory: PSI, the share of time tasks spend stalled waiting for memory
  • /proc/<pid>/smaps_rollup: PSS, the fair per-process number (RSS double-counts shared pages)

The program is one loop with four steps: measure, decide, identify, act.

text
loop every 5s: avail = MemAvailable psi = avg10 from /proc/pressure/memory if (avail < 1500 MB or psi > 10%) and cooldown expired: top3 = processes grouped by name, sorted by PSS notify-send "Memory pressure" "<avail, psi, top3>" sleep

I might write it in Rust or C in the future. It can run as a systemd user service so it starts at login.

PSI is a better signal than "percent used" because it measures actual stalls. A system at 90% used with 0% pressure is fine, while one at 60% with high pressure is struggling. Next I plan to replace the sleep loop with a PSI trigger (write some 150000 1000000 to /proc/pressure/memory and poll() for POLLPRI), so the program uses no CPU until the kernel wakes it, and to add a /tmp size check.

Results

MetricBeforeAfter
/tmp in RAM1.7 GB0 MB
available after a normal session6.0 GB~9.5 GB
Swap/zram under load10 GB~2.1 GB

Takeaways

  1. "90% used" on Linux doesn't mean much. Look at available and PSI.
  2. If no process explains your memory, check tmpfs (df -h /tmp /dev/shm) before blaming the browser.
  3. Debian 13's tmpfs /tmp is a trade-off, not a free win. Decide deliberately.
  4. The kernel already measures everything you need, so a useful monitor is under 100 lines.

What was your biggest hidden memory consumer? Post your free -h and df -h /tmp output in the comments.