This sets up a lightweight local memory monitor on macOS using only built-in tools.
It watches practical memory health signals including:
- Swap Used
- Wired Memory
- Compressed Memory
- top memory-consuming processes
No third-party monitoring app is required.
- Runs every 60 seconds using
launchd - Checks memory state using
vm_stat,sysctl, andps - Alerts when configured memory thresholds are crossed
- Shows current memory summary
- Shows likely memory culprits by resident memory usage
- Writes a small debug log so you can confirm it is running
Unload the old LaunchAgent if it exists:
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.kjell.memorywatch.plist 2>/dev/nullRemove the old files:
rm -f ~/Library/LaunchAgents/com.kjell.memorywatch.plist
rm -f ~/.bin/low_system_memory_alert.shOptional cleanup:
rm -f /tmp/memorywatch-debug.log
rm -f /tmp/memorywatch.out
rm -f /tmp/memorywatch.errCreate a local bin directory if it does not already exist:
mkdir -p ~/.binCreate the script file:
nano ~/.bin/early_memory_alert.shPaste this:
#!/bin/zsh
LOG_FILE="/tmp/memorywatch-debug.log"
SWAP_LIMIT_GB=4
COMPRESSED_LIMIT_GB=4
WIRED_LIMIT_GB=8
VM=$(vm_stat)
PAGE_SIZE=$(echo "$VM" | awk '/page size of/ {gsub(/\./, "", $8); print $8}')
get_pages() {
echo "$VM" | awk -v label="$1" '
index($0, label) {
val=$NF
gsub(/\./, "", val)
print val
exit
}'
}
pages_to_gb() {
awk -v pages="$1" -v page_size="$PAGE_SIZE" \
'BEGIN {printf "%.2f", (pages * page_size) / 1024 / 1024 / 1024}'
}
FREE_PAGES=$(get_pages "Pages free:")
SPEC_PAGES=$(get_pages "Pages speculative:")
COMPRESSED_PAGES=$(get_pages "Pages occupied by compressor:")
WIRED_PAGES=$(get_pages "Pages wired down:")
FREE_GB=$(pages_to_gb "$FREE_PAGES")
SPEC_GB=$(pages_to_gb "$SPEC_PAGES")
COMPRESSED_GB=$(pages_to_gb "$COMPRESSED_PAGES")
WIRED_GB=$(pages_to_gb "$WIRED_PAGES")
PHYSICAL_GB=$(sysctl -n hw.memsize | awk '{printf "%.2f", $1 / 1024 / 1024 / 1024}')
SWAP_USED_GB=$(sysctl vm.swapusage | awk -F 'used = ' '
{
split($2, a, " ")
val=a[1]
if (val ~ /M$/) {
gsub(/M/, "", val)
printf "%.2f", val / 1024
} else if (val ~ /G$/) {
gsub(/G/, "", val)
printf "%.2f", val
} else {
printf "%.2f", 0
}
}')
CULPRITS=$(ps -axo pid,rss,comm | sort -nrk 2 | head -8 | awk '
{
mb = int($2 / 1024)
printf "%5d MB PID %-7s %s\n", mb, $1, $3
}')
echo "$(date): physical=${PHYSICAL_GB}GB free=${FREE_GB}GB speculative=${SPEC_GB}GB swap=${SWAP_USED_GB}GB wired=${WIRED_GB}GB compressed=${COMPRESSED_GB}GB" >> "$LOG_FILE"
SHOULD_ALERT=$(awk \
-v swap="$SWAP_USED_GB" \
-v wired="$WIRED_GB" \
-v compressed="$COMPRESSED_GB" \
-v swap_limit="$SWAP_LIMIT_GB" \
-v wired_limit="$WIRED_LIMIT_GB" \
-v compressed_limit="$COMPRESSED_LIMIT_GB" \
'BEGIN {
if (swap >= swap_limit || wired >= wired_limit || compressed >= compressed_limit) print 1;
else print 0;
}')
if [[ "$SHOULD_ALERT" == "1" ]]; then
MESSAGE="Memory warning
Alert thresholds:
Swap Used >= ${SWAP_LIMIT_GB}GB
Wired Memory >= ${WIRED_LIMIT_GB}GB
Compressed Memory >= ${COMPRESSED_LIMIT_GB}GB
Current state:
Physical Memory: ${PHYSICAL_GB}GB
Free Memory: ${FREE_GB}GB
Speculative Memory: ${SPEC_GB}GB
Swap Used: ${SWAP_USED_GB}GB
Wired Memory: ${WIRED_GB}GB
Compressed: ${COMPRESSED_GB}GB
Top memory consumers:
${CULPRITS}"
ESCAPED_MESSAGE=$(printf '%s' "$MESSAGE" | sed 's/"/\\"/g')
/usr/bin/osascript <<EOF
display alert "Memory Warning" message "$ESCAPED_MESSAGE"
EOF
fiMake it executable:
chmod +x ~/.bin/early_memory_alert.shTest it manually:
~/.bin/early_memory_alert.shConfirm the debug log was written:
tail -5 /tmp/memorywatch-debug.logA healthy log line should look roughly like this:
Thu May 21 11:04:50 MDT 2026: physical=36.00GB free=0.70GB speculative=0.55GB swap=1.58GB wired=5.26GB compressed=1.31GB
You should not see an alert unless one of the configured thresholds is crossed.
Create the LaunchAgents directory if needed:
mkdir -p ~/Library/LaunchAgentsCreate the plist:
nano ~/Library/LaunchAgents/com.kjell.memorywatch.plistPaste this:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.kjell.memorywatch</string>
<key>ProgramArguments</key>
<array>
<string>/bin/zsh</string>
<string>/Users/YOUR_USERNAME/.bin/early_memory_alert.sh</string>
</array>
<key>StartInterval</key>
<integer>60</integer>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/memorywatch.out</string>
<key>StandardErrorPath</key>
<string>/tmp/memorywatch.err</string>
</dict>
</plist>Replace YOUR_USERNAME with your macOS username.
Check your username:
whoamiFor example, if your username is kjell, this line should be:
<string>/Users/kjell/.bin/early_memory_alert.sh</string>Validate the plist:
plutil -lint ~/Library/LaunchAgents/com.kjell.memorywatch.plistIf it may already be loaded, unload it first:
launchctl bootout gui/$(id -u)/com.kjell.memorywatch 2>/dev/null
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.kjell.memorywatch.plist 2>/dev/nullLoad it:
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.kjell.memorywatch.plistVerify it is loaded:
launchctl list | grep memorywatchExpected output should look similar to:
- 0 com.kjell.memorywatch
The 0 means the last run exited successfully.
Check the debug log:
tail -f /tmp/memorywatch-debug.logYou should see a new line roughly every 60 seconds.
Check stdout and stderr logs if needed:
tail -f /tmp/memorywatch.outtail -f /tmp/memorywatch.errEdit the script:
nano ~/.bin/early_memory_alert.shChange these values near the top:
SWAP_LIMIT_GB=4
COMPRESSED_LIMIT_GB=4
WIRED_LIMIT_GB=8Current behavior:
Alert if Swap Used >= 4 GB
Alert if Compressed Memory >= 4 GB
Alert if Wired Memory >= 8 GB
Suggested starting values for a 36 GB Mac:
Swap Used: 4 GB
Compressed Memory: 4 GB
Wired Memory: 8 GB
If alerts are too noisy, increase the thresholds slightly.
Edit the plist:
nano ~/Library/LaunchAgents/com.kjell.memorywatch.plistChange this value:
<key>StartInterval</key>
<integer>60</integer>Examples:
60 = every 1 minute
300 = every 5 minutes
600 = every 10 minutes
After changing the plist, reload the LaunchAgent:
launchctl bootout gui/$(id -u)/com.kjell.memorywatch 2>/dev/null
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.kjell.memorywatch.plistIf you only edit the shell script, you usually do not need to reload the LaunchAgent.
launchd runs the script path fresh every execution, so the next scheduled run will use the updated script.
To test immediately:
~/.bin/early_memory_alert.shBecause the plist is in:
~/Library/LaunchAgents/
and contains:
<key>RunAtLoad</key>
<true/>it should automatically start after reboot and login.
After reboot, verify with:
launchctl list | grep memorywatchDisable the scheduled job:
launchctl bootout gui/$(id -u)/com.kjell.memorywatch 2>/dev/null
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.kjell.memorywatch.plist 2>/dev/nullRemove the files:
rm -f ~/Library/LaunchAgents/com.kjell.memorywatch.plist
rm -f ~/.bin/early_memory_alert.shOptional cleanup:
rm -f /tmp/memorywatch-debug.log
rm -f /tmp/memorywatch.out
rm -f /tmp/memorywatch.errmemory_pressure is still useful, but this monitor focuses on additional lower-level memory signals such as swap usage, wired memory, and compression activity.
macOS intentionally uses available RAM aggressively for cache and reclaimable work.
Low free memory alone is not necessarily a problem, so the script logs free and speculative memory for context but does not alert on them.
Swap means macOS has moved memory pressure to disk.
Some swap usage is normal, especially on long-running systems, but sustained or growing swap usage is often a useful signal that applications are consuming substantial memory.
Wired memory cannot easily be reclaimed by macOS.
High wired memory can indicate:
- heavy system allocations
- drivers
- GPU allocations
- virtualization
- security/network extensions
- application leaks
Compressed memory indicates macOS is actively compressing RAM to avoid additional swap activity.
Some compression is normal. Large sustained compression can indicate increasing memory pressure.
display notification can be suppressed depending on notification permissions, Focus mode, terminal app behavior, or AppleScript notification identity.
display alert is more intrusive, but more reliable for this use case.
The script uses RSS from ps, which means resident memory currently held by each process.
This is not a perfect leak detector, but it is usually good enough to identify the application that should be investigated or quit first.