Setting Memory Budgets for Low-End Devices
Your app is fine on engineers’ laptops and flagship phones, but a large share of users are on devices with 2–4 GB of RAM, where tabs get killed, scrolling stutters and background tabs reload. This guide from Remote Debugging Memory on Mobile Browsers, part of Browser DevTools & Performance Profiling Workflows, shows how to set explicit memory budgets per page, detect the device class, adapt features to fit, and check the budgets automatically.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
| Crashes and reloads concentrated on low-end Android and older iPhones | Page footprint sized for desktop | Define a budget per page for the lowest supported class | A target the whole team can test against |
| No one knows what “too much memory” means | No numbers attached to memory | Budget JS heap, images and DOM nodes separately | Regressions become measurable failures |
| Heavy features (maps, 3D, video) load for everyone | No device-class adaptation | Gate on navigator.deviceMemory / client hints |
Lower peak on constrained devices |
| Budget met in the lab but not in the field | Lab data is small and extensions-free | Compare with field memory measurements | Budget reflects real usage |
| Budget erodes release by release | Nothing enforces it | Check budgets in CI and fail on overage | Growth caught before shipping |
Root Cause: Memory Is Shared, Unannounced and Unforgiving
On a phone, your page competes for memory with the operating system, the browser’s own processes, the user’s other apps and every other open tab. The per-process limit that triggers a kill is unpublished and varies — on iOS it leads to the reloads described in the Safari guide, and on Android the low-memory killer terminates background renderers first and foreground ones under severe pressure. Nobody tells the page in advance. The only defence is to keep the page’s footprint comfortably below the point where trouble starts on the weakest devices you support.
A budget turns that into something testable. A useful budget has a few separate lines, because different kinds of memory grow for different reasons and are measured with different tools: the JavaScript heap (measured with snapshots, performance.memory or measureUserAgentSpecificMemory), decoded image memory (width × height × 4 bytes per image, roughly), DOM size (node count), and a total footprint checked on a real device. A single total number is less actionable, because when it is exceeded you still have to work out which part grew.
Budgets also need a device-class dimension. The same page can reasonably use more memory on an 8 GB laptop than on a 2 GB phone, so budgets are usually written for the lowest class you support, and optional features are enabled only on higher classes. Chromium exposes an approximate device memory value through navigator.deviceMemory in JavaScript and the Sec-CH-Device-Memory client hint for servers; both are deliberately coarse (rounded to values such as 0.5, 1, 2, 4 or 8 GB) to limit fingerprinting, which is all you need for picking a tier.
Step-by-Step Fix
- Pick the lowest supported device class. Use your analytics to find the lowest
navigator.deviceMemoryvalue (or device models) that make up a meaningful share of traffic. Verification: you can name a reference device, for example “Android phone reporting 2 GB”. - Measure the current footprint on that device. Using remote debugging over USB, record the JS heap after key flows, count DOM nodes (
document.querySelectorAll('*').length), and read the process footprint in Chrome’s Task Manager equivalent (chrome://memory-internalsoradb shell dumpsys meminfo). Verification: you have a table of current values per page. - Write the budget with headroom. Set each line at a level comfortably below where you observed kills or severe slowdowns, and record it in the repository next to the page code. Verification: every key page has JS heap, image, DOM and total footprint targets.
- Adapt features by tier. Read
navigator.deviceMemoryon the client (or theSec-CH-Device-Memoryhint on the server) and choose lighter defaults for low tiers: fewer items per page, static images instead of interactive maps, lower-resolution images, no speculative prefetching. Verification: on the reference device, the page loads the low-tier configuration. - Enforce in the lab. Add an automated test that emulates a low-tier configuration and asserts JS heap and DOM node limits after each key flow. Verification: the test fails when you deliberately add 10,000 hidden nodes or a large retained array.
- Compare with field data. Collect memory measurements from real users by tier and compare against the budget. Verification: the 75th percentile of each tier is within its budget; if not, the budget or the page needs revisiting.
Command and Code Reference
Use case: choose a feature tier on the client. Fall back to the lowest tier when the API is unavailable only if your audience skews low-end; otherwise default to the middle tier.
// device-tier.js
export function memoryTier() {
// Rounded device RAM in GB (Chromium); undefined in Safari and Firefox
const gb = navigator.deviceMemory;
if (gb === undefined) return 'mid'; // unknown: choose a safe middle
if (gb <= 2) return 'low';
if (gb <= 4) return 'mid';
return 'high';
}
export const TIER_CONFIG = {
low: { pageSize: 20, interactiveMap: false, imageWidth: 480, prefetch: false },
mid: { pageSize: 40, interactiveMap: 'on-demand', imageWidth: 800, prefetch: false },
high: { pageSize: 80, interactiveMap: true, imageWidth: 1280, prefetch: true },
};
const config = TIER_CONFIG[memoryTier()];
Use case: request the device memory hint on the server. Opting in lets the server pick image sizes and page sizes before any JavaScript runs.
HTTP/1.1 200 OK
Accept-CH: Sec-CH-Device-Memory
Vary: Sec-CH-Device-Memory
Use case: enforce budget lines in a lab test. Emulate a low-end phone, run a key flow and assert JS heap and DOM size.
// budget.test.mjs (Puppeteer) — lab check for the low tier
import puppeteer, { KnownDevices } from 'puppeteer';
const BUDGET = { jsHeapMB: 60, domNodes: 1500 };
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.emulate(KnownDevices['Moto G4']); // small viewport, mobile UA
await page.emulateCPUThrottling(4);
await page.evaluateOnNewDocument(() => {
Object.defineProperty(navigator, 'deviceMemory', { get: () => 2 }); // force low tier
});
await page.goto('http://localhost:5173/search?q=shoes', { waitUntil: 'networkidle0' });
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
const m = await page.metrics();
const heapMB = m.JSHeapUsedSize / 1048576;
if (heapMB > BUDGET.jsHeapMB) throw new Error(`JS heap ${heapMB.toFixed(1)} MB > ${BUDGET.jsHeapMB}`);
if (m.Nodes > BUDGET.domNodes) throw new Error(`DOM nodes ${m.Nodes} > ${BUDGET.domNodes}`);
await browser.close();
Verification and Regression Prevention
A budget is working when three things are true: the lab test fails on a deliberate regression and passes on the current build, the reference device completes key flows without reloads or kills, and the field 75th percentile for each tier sits within its budget. Review the numbers each quarter; as device populations change, the lowest supported tier and its budget should move with them.
Fold the lab test into CI next to your other checks, alongside a heap size budget in your CI pipeline for leak detection. Make the budget visible in pull requests — a comment showing JS heap and DOM node deltas for key flows — so the conversation about memory happens before merge rather than after users complain.
Edge Cases and Gotchas
deviceMemory is coarse and not universal
The value is rounded and capped, and Safari and Firefox do not expose it. Treat it as a hint for choosing defaults, not as a measurement, and make sure pages work acceptably in the unknown case.
Images dominate more than you expect
A single 4000×3000 photo decodes to about 48 MB regardless of its file size. Budget decoded image memory explicitly, serve images sized for the display, and remember that a gallery with twenty such images can exceed an entire low-tier budget on its own.
Lab emulation does not reproduce kills
Emulating a device in desktop Chrome changes viewport, CPU speed and user agent, but not the memory limit. Lab tests enforce your budget numbers; only real devices show where the operating system actually starts killing tabs.
Budgets per page, not per app
Different pages have different needs; a dashboard can justify more memory than a settings page. Budget key pages individually so that a heavy page does not “borrow” headroom from a light one.
Frequently Asked Questions
How do I choose budget numbers without a published limit?
Measure. Record footprints on your reference device during key flows, note where reloads or severe slowdowns begin, and set the budget well below that point. Start conservative, then relax lines that field data shows are safe.
Should low-end users get a different app?
Usually not a different app, but different defaults: smaller page sizes, fewer eagerly loaded features, lower-resolution media and no speculative work. Keep the same functionality available on demand so users can still reach everything.
Does Safari provide any device memory signal?
Not through navigator.deviceMemory. Use your analytics’ device model data to understand iOS memory classes, and design the low tier so that it is also a reasonable default for browsers that do not report the value.
Related
- Remote Debugging Memory on Mobile Browsers — the parent topic
- Replacing performance.memory in Real User Monitoring — collecting the field data budgets are checked against
- Canvas and ImageBitmap Memory in Long-Running Apps — the image side of the budget
- Browser DevTools & Performance Profiling Workflows — the section overview