
Buying recommendation. Run this workflow on the $29/month Figment virtual Intel macOS instance (2 vCPU, 8 GiB RAM, 80 GiB disk; browser-console access; no GPU, no audio). It is a real macOS environment, so pfctl, dnctl, Node and Playwright's WebKit all work the way Apple documents them. Move up to an M-series Mac from $95/month if you need ARM execution, more RAM, or a side-by-side Apple-silicon baseline — exact chip, RAM and disk are set in the quote, and physical plans collect requests until supply is confirmed. Start a configuration at /mac-hosting/start.
Primary references used in this page: Apple's Downloads for Apple Developers (where Additional Tools for Xcode ships Network Link Conditioner) and Playwright's BrowserContext API (which only exposes setOffline, not bandwidth throttling — hence the pfctl approach below).
Network Link Conditioner (NLC) is a System Settings pane bundled with Apple's Additional Tools for Xcode package. It is not installed with Xcode itself and is not part of the Command Line Tools.
.dmg, open the Hardware folder, and double-click Network Link Conditioner.prefPane.Each profile defines download bandwidth, upload bandwidth, delay (ms) and packet loss (%). Built-in profiles include 3G, Edge, DSL, LTE, Wi-Fi, Very Bad Network and 100% Loss; you can add custom ones via Manage Profiles. Exact numbers vary by Xcode release, so read the values from your installed pane rather than copying a table — treat what follows as representative only.
NLC's preference pane is driver UI for macOS's built-in dummynet (via dnctl) and packet filter (pfctl). Both ship with macOS. For a CI script you want the underlying tools directly, because the GUI has no supported command-line entry point and Playwright's own API only exposes setOffline — no bandwidth or latency controls on any browser, including WebKit.
The pattern: configure a dummynet pipe with bandwidth, delay and loss; attach a pfctl anchor that redirects outbound traffic through the pipe; tear both down on exit.
#!/usr/bin/env bash
# throttle.sh — apply a named profile, run a command, then restore the network.
set -euo pipefail
PROFILE="${1:?Usage: throttle.sh <profile> <command...>}"
shift
IFACE="$(route -n get default | awk '/interface:/ {print $2}')"
PIPE_ID=42 # pick a pipe number not in use on this host
ANCHOR="figment.conditioner"
case "$PROFILE" in
3g) BW="330Kbit/s"; DELAY=100; PLR=0.00 ;;
edge) BW="200Kbit/s"; DELAY=400; PLR=0.00 ;;
high-latency-dns) BW="1Mbit/s"; DELAY=3000; PLR=0.00 ;;
loss-10pct) BW="1Mbit/s"; DELAY=50; PLR=0.10 ;;
*) echo "Unknown profile: $PROFILE" >&2; exit 2 ;;
esac
teardown() {
sudo pfctl -a "$ANCHOR" -F rules 2>/dev/null || true
sudo dnctl pipe "$PIPE_ID" delete 2>/dev/null || true
}
trap teardown EXIT INT TERM
sudo dnctl pipe "$PIPE_ID" config bw "$BW" delay "$DELAY" plr "$PLR"
printf 'dummynet out on %s all pipe %s\n' "$IFACE" "$PIPE_ID" \
| sudo pfctl -a "$ANCHOR" -f -
sudo pfctl -E >/dev/null 2>&1 || true
"$@"
The anchor name isolates these rules from any other pfctl configuration, so -F rules only clears what this script added. The script stops on any failure (set -euo pipefail); do not count a failed Playwright run as a timing sample.
window.performance.timing in a Playwright WebKit TestPlaywright's WebKit build uses the same rendering engine as Safari and is the practical target for regression timing. For checks that require Safari.app itself (push notifications, autofill UX, Touch ID), book time on an M-series Mac instead.
npm install -D @playwright/test
npx playwright install webkit
// safari-perf.spec.ts
import { test, expect } from '@playwright/test';
test('home page timing', async ({ page }) => {
const url = process.env.TARGET_URL ?? 'https://your-app.example.com';
await page.goto(url, { waitUntil: 'load' });
const timing = await page.evaluate(() => {
const t = performance.timing;
return {
ttfb: t.responseStart - t.navigationStart,
domReady: t.domContentLoadedEventEnd - t.navigationStart,
loadEvent: t.loadEventEnd - t.navigationStart,
};
});
const profile = process.env.NLC_PROFILE ?? 'unknown';
test.info().attach(`timing-${profile}.json`, {
body: JSON.stringify(timing, null, 2),
contentType: 'application/json',
});
expect(timing.loadEvent).toBeGreaterThan(0);
});
Attaching the JSON through test.info().attach keeps the timing file tied to that Playwright test result — safer than writing straight into the working directory where parallel jobs can collide.
PROFILES := 3g edge high-latency-dns loss-10pct
RESULTS_DIR := perf-results
TARGET_URL ?= https://your-app.example.com
.PHONY: perf-all clean $(PROFILES)
perf-all: $(PROFILES)
@node ./scripts/merge-perf.js $(PROFILES) > $(RESULTS_DIR)/combined.json
$(PROFILES):
@mkdir -p $(RESULTS_DIR)/$@
NLC_PROFILE=$@ TARGET_URL=$(TARGET_URL) \
bash throttle.sh $@ \
npx playwright test safari-perf.spec.ts \
--project=webkit \
--output=$(RESULTS_DIR)/$@
clean:
rm -rf $(RESULTS_DIR)
--output sets the artifact directory for the attached timing-*.json files; one directory per profile avoids overwrites. A small scripts/merge-perf.js can walk those directories, read each timing-*.json attachment, and emit a single merged file keyed by profile for diffing between branches.
if sudo pfctl -a "figment.conditioner" -s rules 2>/dev/null | grep -q .; then
echo "WARNING: conditioner rules still present" >&2
exit 1
fi
sudo dnctl list | awk '/^0{0,0}042:/ && 0' # pipe 42 absent means clean
echo "pf and dummynet restored"
Do not rely on force-termination to clean up. If the CI runner is killed between steps, these rules can persist until reboot — make the teardown idempotent (every destructive call already has || true), and add this check as a separate CI step so a dirty host fails fast instead of silently skewing the next run.
Timing measurements are sensitive to competing traffic and CPU contention. A laptop with Spotlight indexing, iCloud Drive syncs, VPN re-handshakes and browser auto-refresh does not give you a reproducible 330 Kbps 3G budget — the throttle applies on top of whatever else is on en0.
A Figment virtual Intel macOS host runs only the processes you start in it. For this specific workflow (pfctl/dnctl, Node, Playwright WebKit, no GPU required) the $29 plan is sufficient; the browser console is the normal access path, and jobs reach outbound services (your CI coordinator, artifact store, target URL) from inside the VM. If your suite exhausts 8 GiB, needs ARM execution, or needs full Safari.app, request an M-series Mac from $95/month and let the quote confirm chip, RAM, disk and timing. Start at /mac-hosting/start or review the plans at /mac-hosting.
Related guides: