How to Schedule Website Screenshots from a macOS launchd Job
Schedule recurring browser screenshots on macOS with a LaunchAgent, Playwright, and launchd calendar intervals. Learn what happens during sleep, how to troubleshoot runs, and when to use a screenshot API.
Use a per-user launchd LaunchAgent to run a browser automation script on a calendar schedule. The agent invokes Playwright, which opens the website and saves either the visible viewport or a full-page screenshot. This captures a rendered web page, not the macOS desktop. For the desktop, use a screen-capture framework such as ScreenCaptureKit instead.
This guide uses a local Mac, Node.js, and Chromium. The examples create a screenshot directory under your home folder and run once daily at 09:00 local time. Adjust the schedule, URL, browser, and output path for your needs. Apple’s archived guide documents the calendar schedule and sleep behavior; exact behavior should be checked against the macOS version you use.
1. Choose what you mean by a website screenshot
| Approach | Captures | Good fit | Considerations |
|---|---|---|---|
| Browser page screenshot | A page rendered by a browser, including a chosen viewport or full scrollable page | Recurring website captures, page review, visual comparisons | Requires browser automation and a browser runtime. It does not require the browser window to be visible. |
| macOS screen-content capture | A display, app, or window as it appears on screen | Capturing desktop state or content outside a browser page | Screen Recording permission applies. It captures screen content, and is not the same as a full-page web screenshot. |
For website pages, use browser automation. Playwright’s page screenshot API writes to a specified path, and fullPage: true captures the full scrollable page. For Safari-specific automation, Apple’s WebDriver route requires enabling “Allow remote automation” in Safari Developer settings, or running safaridriver --enable in Terminal. Safari automation is optional; the setup below uses Playwright with Chromium.
2. Install Node.js and Playwright
Install a current Node.js release using the method you normally manage on your Mac. Then create a dedicated project directory and install Playwright with its Chromium browser:
mkdir -p "$HOME/website-screenshots"
cd "$HOME/website-screenshots"
npm init -y
npm install playwright
npx playwright install chromium
Playwright’s browser installation is separate from installing its package. Run the install command as the same macOS user who will own the LaunchAgent, so the job can access the browser files.
3. Create the screenshot script
Save this as $HOME/website-screenshots/capture.cjs. It creates a dated PNG in an output subdirectory, waits for the page to load, and closes the browser even if capture fails.
const fs = require('node:fs/promises');
const path = require('node:path');
const { chromium } = require('playwright');
const targetUrl = process.env.TARGET_URL || 'https://example.com';
const outputDir = process.env.OUTPUT_DIR || path.join(process.env.HOME, 'website-screenshots', 'captures');
const fullPage = process.env.FULL_PAGE !== '0';
async function main() {
await fs.mkdir(outputDir, { recursive: true });
const timestamp = new Date().toISOString().replaceAll(':', '-');
const outputPath = path.join(outputDir, `page-${timestamp}.png`);
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.setDefaultNavigationTimeout(60_000);
await page.goto(targetUrl, { waitUntil: 'networkidle', timeout: 60_000 });
await page.screenshot({ path: outputPath, fullPage });
console.log(`Saved ${outputPath}`);
} finally {
await browser.close();
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Replace https://example.com with the page you own or are permitted to capture. The networkidle wait can be unsuitable for pages with continuous polling, analytics, or streaming requests. If navigation never settles, use waitUntil: 'domcontentloaded' and wait for a page-specific selector or a short explicit delay before capturing.
To capture only the viewport, set FULL_PAGE=0. The viewport dimensions are set in newPage; change them to match the layout you need. Full-page captures can produce very tall images and may trigger lazy-loaded content only as the page is scrolled, depending on page behavior.
4. Add a per-user LaunchAgent
A LaunchAgent runs on behalf of the logged-in user. That context is usually appropriate when the job needs the user’s files and home directory. A LaunchDaemon runs in a system context and may start before a user logs in, so it is not a drop-in replacement for a job that depends on a user’s browser state or personal output directory.
Create the LaunchAgents folder and save the following property list as $HOME/Library/LaunchAgents/com.example.website-screenshot.plist. Replace YOUR_USERNAME with your short macOS account name if you use the literal paths shown. The example runs every day at 09:00 and writes standard output and errors to log files.
mkdir -p "$HOME/Library/LaunchAgents" "$HOME/website-screenshots/logs"
<?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.example.website-screenshot</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/node</string>
<string>/Users/YOUR_USERNAME/website-screenshots/capture.cjs</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/YOUR_USERNAME/website-screenshots</string>
<key>EnvironmentVariables</key>
<dict>
<key>TARGET_URL</key>
<string>https://example.com</string>
<key>FULL_PAGE</key>
<string>1</string>
</dict>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>9</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>StandardOutPath</key>
<string>/Users/YOUR_USERNAME/website-screenshots/logs/stdout.log</string>
<key>StandardErrorPath</key>
<string>/Users/YOUR_USERNAME/website-screenshots/logs/stderr.log</string>
</dict>
</plist>
Find the correct Node executable with command -v node in Terminal and use that absolute path in ProgramArguments. Common locations include /opt/homebrew/bin/node on Apple Silicon Homebrew installations and /usr/local/bin/node on some Intel setups, but use the path on your own Mac. Find your short account name with whoami and substitute it in every absolute path. A launchd plist does not expand $HOME or shell substitutions in path strings.
ProgramArguments is an argument array, not a shell command line. Each argument belongs in its own string; do not add shell quotes around the executable or script path in the plist. If you need shell features such as redirection or pipelines, call a shell explicitly, but direct executable arguments are simpler and easier to diagnose.
Schedule fields
StartCalendarInterval accepts calendar fields such as Minute, Hour, Day, Weekday, and Month. Fields you omit act as wildcards. For example, only Hour and Minute means once each day at that time. To run at 09:00 and 17:00 daily, use an array of dictionaries:
<key>StartCalendarInterval</key>
<array>
<dict><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
<dict><key>Hour</key><integer>17</integer><key>Minute</key><integer>0</integer></dict>
</array>
To run at 09:15 every Monday, set Weekday to 1, Hour to 9, and Minute to 15. Apple’s archived guide uses weekday values from 0 (Sunday) through 6 (Saturday). If scheduling around daylight-saving transitions or a timezone change matters, verify the resulting local schedule on the macOS version and configuration you deploy.
5. Validate and load the job
Before loading the job, validate the plist and confirm that the script runs directly under your account:
plutil -lint "$HOME/Library/LaunchAgents/com.example.website-screenshot.plist"
"$(command -v node)" "$HOME/website-screenshots/capture.cjs"
ls -lt "$HOME/website-screenshots/captures"
Load it into the current user’s launchd domain, then request a run now to check the launchd configuration:
launchctl bootstrap "gui/$(id -u)" "$HOME/Library/LaunchAgents/com.example.website-screenshot.plist"
launchctl kickstart -k "gui/$(id -u)/com.example.website-screenshot"
launchctl print "gui/$(id -u)/com.example.website-screenshot"
After editing the plist, boot out the existing service and bootstrap the updated file again:
launchctl bootout "gui/$(id -u)/com.example.website-screenshot"
launchctl bootstrap "gui/$(id -u)" "$HOME/Library/LaunchAgents/com.example.website-screenshot.plist"
These commands use the current user’s GUI launchd domain. If the Mac is in an unusual login or remote-session state, the relevant domain can differ; inspect launchctl print output and logs rather than assuming the job was loaded. Apple documents SMAppService for apps that register and manage their own LaunchAgents or LaunchDaemons on macOS 13 and later; manually created per-user plists remain a separate workflow.
6. Understand sleep, shutdown, and login state
Sleep and shutdown have different scheduling outcomes. Apple’s archived Daemons and Services Programming Guide, in “Scheduling Timed Jobs,” says: “If you schedule a launchd job by setting the StartCalendarInterval key and the computer is asleep when the job should have run, your job will run when the computer wakes up.” A calendar occurrence missed because the Mac was powered off waits until the next scheduled time. Therefore, do not promise an exact capture time if the Mac may sleep or be shut down.
A LaunchAgent is associated with the logged-in user. Do not assume it can use an interactive browser session, unlocked credentials, or GUI-only state while that user is logged out. The Playwright script above launches its own headless Chromium, but still needs its installed runtime, readable files, a writable output directory, and network access to the target. Test the expected login and sleep conditions for your use case.
7. Make recurring captures useful for comparison
- Keep the browser version and host environment stable where practical. Playwright notes that OS version, settings, hardware, power source, and headless mode can affect screenshot rendering.
- Use a fixed viewport and device scale factor if consistent dimensions matter.
- Choose a deterministic wait condition. A page with ads, rotating content, timestamps, or personalized recommendations can change between runs even when the capture code is unchanged.
- Use a stable naming scheme that includes an ISO timestamp, as in the script, and decide how long to retain files.
- Monitor the error log and periodically check that output files are still being created. A scheduled job can keep running into a changed URL, expired login, disk issue, or browser update.
For visual regression work, compare captures under a stable environment and account for dynamic page regions. Playwright documents screenshot comparison capabilities, but recurring capture by itself does not decide whether a visual change is a defect.
8. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| The job does not load | Invalid plist, wrong file location, or a service already loaded under a different label | Run plutil -lint, confirm the file is under ~/Library/LaunchAgents, then inspect launchctl print gui/$(id -u)/LABEL. |
| “Program not found” or immediate exit | The plist uses a nonexistent Node path or relies on a shell PATH that launchd does not provide | Set ProgramArguments[0] to the absolute path from command -v node. Check StandardErrorPath. |
| Works in Terminal but not on schedule | Different working directory, environment, login state, permissions, or unset environment variables | Use absolute paths, set WorkingDirectory and needed variables, and run the script as the same user. Read the launchd logs. |
| Cannot find Playwright or Chromium | Dependencies or browser were installed for a different project or account | Run from the project directory, keep node_modules there, and install Chromium as the LaunchAgent owner. |
| Navigation times out | The site is slow, unavailable, or never reaches network idle | Check the URL and network. Try domcontentloaded plus a selector wait, and set a timeout appropriate to the site. |
| Screenshot is blank or incomplete | Capture happened before page content rendered, required interaction was missed, or lazy content was not loaded | Wait for a meaningful selector, handle required navigation or consent state where appropriate, and inspect the page at the same viewport. |
| Capture is viewport-sized instead of full page | FULL_PAGE=0 or fullPage is false |
Set FULL_PAGE=1 or remove the override. Confirm the page has a finite, renderable document height. |
| No file appears at the expected path | Wrong absolute output path, unwritable directory, or script exited before screenshot | Check the error log, create the directory, verify permissions, and print the resolved output path. |
| Repeated or delayed captures after waking | Calendar jobs missed during sleep can run on wake; shutdown misses an occurrence entirely | Expect wake-time execution after sleep and the next scheduled run after shutdown. Avoid treating launchd as a guaranteed exact-time scheduler. |
| Safari automation is unavailable | Remote automation is disabled or Safari/WebDriver setup differs | Enable “Allow remote automation” in Safari Developer settings or run safaridriver --enable. Use Playwright/Chromium if Safari-specific behavior is not required. |
9. Performance, reliability, and cost
A local job costs no per-capture API fee, but it uses the Mac’s CPU, memory, disk, and network and requires maintenance of Node.js, Playwright, and the browser runtime. Full-page captures of long pages can use more memory and create large files. Limit capture frequency to what the task needs, choose a manageable viewport, and prune old images if disk use matters.
Reliability depends on the Mac being available, the job being loaded in the expected user context, the browser dependencies remaining intact, and the target responding. launchd handles calendar scheduling, but sleep may defer a run until wake and shutdown skips the occurrence. Use logs and output checks to detect failures; do not infer success just because the service is loaded.
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. A single GET request can return a PNG, JPEG, WebP, or PDF. For recurring captures, your scheduler can call the API and save the response instead of installing and maintaining a local browser. See the ScreenshotNeo API documentation.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', res);
The cURL example works directly in a scheduled shell script. Python and Node.js examples show the equivalent request; for plain Node.js, save the response bytes using your preferred filesystem method. Check the response status and headers in your integration so failures are visible. Keep the API key out of a public repository and restrict access to any script or plist that contains it.
- Cookie banners, newsletter popups, and chat widgets are removed before the shot; each cleanup step can be turned off.
- Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed. Response headers identify the page verdict and billing status.
- An MCP server lets AI agents using Claude, Cursor, or another MCP client take screenshots, inspect page information, and capture PDFs.
- The free plan includes 1,000 screenshots a month with no card. Paid plans start at $5 for 3,000 screenshots; every feature is available on every plan.
Sign up for 1,000 free screenshots a month, with no card required.
FAQ
Does the Mac need to stay awake?
It needs to be available for an exact-time capture. If it sleeps through a calendar event, Apple documents that launchd runs the job when the Mac wakes. If it is powered off, the missed event waits for the next scheduled occurrence.
Can I use a cron schedule instead?
This guide uses launchd because Apple’s scheduling guide recommends it for timed jobs on macOS. A per-user LaunchAgent also keeps the task in the user context that owns the script and output files.
Will this capture a logged-in website?
Only if the automation has an authenticated session available. The sample starts a fresh headless browser and does not import your interactive Safari cookies. Use an authorized, deliberately configured authentication method and protect any credentials.
Can I capture a whole page in Safari?
Safari remote automation is an available route after enabling it, but the supplied runnable full-page code uses Playwright. Browser support and full-page behavior depend on the automation setup you choose.
Can I capture an app window or the desktop instead?
Yes. That is screen-content capture, a different task from taking a browser page screenshot. ScreenCaptureKit targets displays, apps, and windows and uses macOS screen-recording permission.


