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.

A three-tier memory budget For devices reporting 2 gigabytes or less: JS heap 60 megabytes, decoded images 80 megabytes, 1,500 DOM nodes, total footprint 250 megabytes, with maps and 3D previews disabled. For 4 gigabytes: JS heap 100, images 150, 2,500 nodes, total 400, maps enabled. For 8 gigabytes and above: JS heap 180, images 300, 4,000 nodes, total 700, all features enabled. Tier JS heap Images DOM nodes Footprint Features ≤ 2 GB budget is written here 60 MB 80 MB 1,500 250 MB no map, no 3D, static thumbnails 4 GB 100 MB 150 MB 2,500 400 MB map on demand ≥ 8 GB 180 MB 300 MB 4,000 700 MB all features

Step-by-Step Fix

  1. Pick the lowest supported device class. Use your analytics to find the lowest navigator.deviceMemory value (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”.
  2. 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-internals or adb shell dumpsys meminfo). Verification: you have a table of current values per page.
  3. 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.
  4. Adapt features by tier. Read navigator.deviceMemory on the client (or the Sec-CH-Device-Memory hint 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.
  5. 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.
  6. 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.
Field p75 JS heap versus budget, by tier For the 2 gigabyte tier the budget is 60 megabytes and the field p75 is 84 megabytes, over budget. For the 4 gigabyte tier the budget is 100 and p75 is 91, within budget. For the 8 gigabyte tier the budget is 180 and p75 is 122, within budget. The low tier is where work is needed. JS heap: field p75 vs budget ≤ 2 GB tier budget 60 MB p75 84 MB — over 4 GB tier budget 100 MB p75 91 MB ≥ 8 GB tier 180 MB p75 122 MB

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.

Is the budget working? A memory budget is working when 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 device tier sits within its budget. Review the tiers quarterly. Three conditions, reviewed quarterly Lab test Fails on a deliberate regression, passes on the current build. Reference device Completes key flows without reloads or tab kills. Field p75 per tier Each device tier sits within its own budget.

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.