Skip to content

Instantly share code, notes, and snippets.

@KjellKod
Created May 21, 2026 17:18
Show Gist options
  • Select an option

  • Save KjellKod/6bbb1fe030cf2b5741bfa04aefd41c6a to your computer and use it in GitHub Desktop.

Select an option

Save KjellKod/6bbb1fe030cf2b5741bfa04aefd41c6a to your computer and use it in GitHub Desktop.

macOS Memory Alert Setup

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.

What it does

  • Runs every 60 seconds using launchd
  • Checks memory state using vm_stat, sysctl, and ps
  • 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

0. Remove the old monitor

Unload the old LaunchAgent if it exists:

launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.kjell.memorywatch.plist 2>/dev/null

Remove the old files:

rm -f ~/Library/LaunchAgents/com.kjell.memorywatch.plist
rm -f ~/.bin/low_system_memory_alert.sh

Optional cleanup:

rm -f /tmp/memorywatch-debug.log
rm -f /tmp/memorywatch.out
rm -f /tmp/memorywatch.err

1. Create the new script

Create a local bin directory if it does not already exist:

mkdir -p ~/.bin

Create the script file:

nano ~/.bin/early_memory_alert.sh

Paste 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
fi

Make it executable:

chmod +x ~/.bin/early_memory_alert.sh

Test it manually:

~/.bin/early_memory_alert.sh

Confirm the debug log was written:

tail -5 /tmp/memorywatch-debug.log

A 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.

2. Create the LaunchAgent

Create the LaunchAgents directory if needed:

mkdir -p ~/Library/LaunchAgents

Create the plist:

nano ~/Library/LaunchAgents/com.kjell.memorywatch.plist

Paste 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:

whoami

For 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.plist

3. Load the LaunchAgent

If 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/null

Load it:

launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.kjell.memorywatch.plist

Verify it is loaded:

launchctl list | grep memorywatch

Expected output should look similar to:

-       0       com.kjell.memorywatch

The 0 means the last run exited successfully.

4. Verify it is running on schedule

Check the debug log:

tail -f /tmp/memorywatch-debug.log

You should see a new line roughly every 60 seconds.

Check stdout and stderr logs if needed:

tail -f /tmp/memorywatch.out
tail -f /tmp/memorywatch.err

5. Change thresholds

Edit the script:

nano ~/.bin/early_memory_alert.sh

Change these values near the top:

SWAP_LIMIT_GB=4
COMPRESSED_LIMIT_GB=4
WIRED_LIMIT_GB=8

Current 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.

6. Change how often it runs

Edit the plist:

nano ~/Library/LaunchAgents/com.kjell.memorywatch.plist

Change 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.plist

7. Reload after editing the script

If 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.sh

8. Confirm it starts after reboot

Because 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 memorywatch

9. Disable or uninstall

Disable 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/null

Remove the files:

rm -f ~/Library/LaunchAgents/com.kjell.memorywatch.plist
rm -f ~/.bin/early_memory_alert.sh

Optional cleanup:

rm -f /tmp/memorywatch-debug.log
rm -f /tmp/memorywatch.out
rm -f /tmp/memorywatch.err

Notes

Why not rely only on memory_pressure?

memory_pressure is still useful, but this monitor focuses on additional lower-level memory signals such as swap usage, wired memory, and compression activity.

Why does the script not alert on low free memory?

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.

Why monitor Swap Used?

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.

Why monitor Wired 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

Why monitor Compressed Memory?

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.

Why use display alert instead of display notification?

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.

What does “Top memory consumers” mean?

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment