Den dag min JavaScript-app begyndte at æde RAM som et gammelt RPG

Den dag min JavaScript-app begyndte at æde RAM som et gammelt RPG

Jeg kan huske den konkrete aften. Jeg havde en lille single page app kørende i browseren, og efter et kvarters klikken føltes den tung som et gammelt RPG-spil på en for lille bærbar. Faner frøs, ventilatoren kørte op, og Chrome Task Manager viste 1,5 GB RAM til en side, der mest viste tekst og lidt knapper.

Mit første gæt var selvfølgelig: “det er sikkert React”. Det var det ikke. Det var mig.

Hvad en memory leak faktisk er i browseren

Jeg startede dengang med en meget vag idé om “memory leak”. Noget med at hukommelse ikke bliver frigivet. Fint nok, men hvad betyder det i JavaScript, hvor man ikke selv kalder free() eller lignende?

Kort version: Browseren har en garbage collector. Den fjerner automatisk ting fra hukommelsen, når der ikke er flere referencer til dem. En memory leak opstår, når du på en eller anden måde sørger for, at der er en reference til data, som du egentlig er færdig med at bruge.

En meget lille illustration:

// Dårlig idé
const leaks = [];

function addStuff() {
  const bigArray = new Array(1000000).fill("x");
  leaks.push(bigArray); // vi gemmer det et globalt sted
}

Hver gang addStuff() kører, skaber vi en stor mængde data og lægger den i et globalt array. Garbage collector kan ikke fjerne det, fordi leaks stadig peger på det hele.

Men i rigtige webapps er det sjældent så åbenlyst. Der er det typisk:

  • Event listeners, der bliver ved med at pege på DOM-noder eller objekter
  • Timers (setInterval/setTimeout), der aldrig bliver ryddet op
  • Caches eller “lagring” af data i globale variabler, der kun vokser
  • DOM-noder, der er fjernet visuelt, men stadig har referencer i JavaScript

Alt sammen betyder: Browseren må ikke smide ting væk, fordi din kode stadig holder fast.

Før vs. efter: hvordan en leak-fordærvet app føles

Jeg synes det hjælper at tænke i før/efter. Hvordan føles en “ren” app, og hvordan føles en app med memory leak efter noget tid?

Situation Uden memory leak Med memory leak
Efter 1 minuts brug Siden føles ens som ved load Ingen forskel endnu, ofte
Efter 10-15 minutters aktiv klik Responsiviteten er stadig stabil Animationer hakker, scroll føles tungere
Efter mange rute-skift (SPA) RAM-forbrug fluktuerer, men vender tilbage til nogenlunde samme niveau RAM vokser gradvist og falder aldrig rigtigt igen
Når du minimerer og maksimerer fanen Ingen forskel, GC kan ofte rydde fint op Nogle gange kortvarigt bedre, men vokser hurtigt igen
Efter længere tid på svag maskine Siden er lidt tungere pga. normal caching, men stadig brugbar Chrome fryser, “Aw, Snap”-fejl eller hele maskinen sejler

Min erfaring: Det er den der langsomme snigende forringelse, som er mistænkelig. Ikke den ene store blokering, men følelsen af at appen bare bliver slidt op, mens du bruger den.

Typiske symptomer: hvornår bør du mistænke memory leaks?

Jeg kigger især efter de her tegn:

  • RAM-forbrug for en enkelt fane vokser støt i Chrome Task Manager
  • Langvarige sessions (dashboard, admin, spil) går fra fine til ulidelige
  • SPA-router: Hver gang du skifter side, bliver det lidt langsommere
  • Brugere melder om “den bliver bare værre og værre, jo mere jeg bruger den”

Hvis du laver små projekter, kan du faktisk træne øjet ved at måle på dine egne ting. For eksempel mens du tester noget andet, kan du lige åbne Chrome DevTools og kigge på Memory eller Performance fanerne, sådan som vi gør i andre artikler om performance og debugging, fx når vi arbejder med Core Web Vitals.

En simpel metode: to heap snapshots og sammenligning

Den teknik, der endelig fik mig til at forstå mine egne leaks, var ekstremt lavpraktisk:

  1. Start med en frisk side, tag et heap snapshot
  2. Udfør en specifik handling mange gange (fx skift route 20 gange)
  3. Tag endnu et snapshot og sammenlign

Sådan gør du i Chrome:

  1. Åbn DevTools (F12 eller højreklik > Inspect)
  2. Gå til fanen Memory
  3. Vælg “Heap snapshot” øverst
  4. Tryk på Take snapshot → det er din før-måling
  5. Brug appen (gentag den handling du mistænker)
  6. Tag et nyt snapshot → det er din efter-måling

Nu har du to billeder af hukommelsen med et “før” og et “efter”. Tanken er: Hvis du laver noget, der burde være reversibelt (fx åbne og lukke en modal, mounte og unmounte en komponent, skifte side frem og tilbage), så skal antallet af bestemte objekter stabilisere sig.

Hvis du ser klasser/typer hvor antallet bare vokser ved hver cyklus, har du sandsynligvis fundet et leak.

Brug “Comparison”-visningen

Når du har to snapshots, kan du i Memory-fanen vælge “Comparison” og vælge snapshot 2 relativt til snapshot 1. Så ser du kolonner som:

  • Δ (Delta) antal objekter: hvor mange flere (eller færre) instanser finder vi nu?
  • Type/Constructor: hvilken slags objekt er det?

Her er tricket: Gør testen gentagelig. For eksempel:

  1. Åbn rute A → rute B → rute A igen, 10 gange
  2. Tag snapshot
  3. Genindlæs siden, gentag med fixet, sammenlign igen

Det føles næsten som en lille manuel test-automatisering. Meget i stil med at måle forespørgsler med EXPLAIN i SQL, som vi kigger på i artiklen om SQL indeks. Før/efter, sortér på det der er vokset, og find synderen.

Fem klassiske leak-mønstre i JavaScript (med små eksempler)

Jeg ville gerne have haft sådan en liste, da jeg sad og stirrede mig blind på heap snapshots første gang. Her er de mønstre, jeg oftest har set i mine egne ting og hos venner.

1. Glemte event listeners

Det her er min personlige klassiker. Jeg tilføjer en event listener på window eller document fra en komponent og glemmer fuldstændig at fjerne den igen.

function init() {
  const el = document.getElementById("btn");
  el.addEventListener("click", () => {
    console.log("clicked");
  });
}

// kaldes hver gang en side vises

Hvis init() bliver kaldt mange gange (fx ved hver route-change i en simpel SPA), får du en ny listener hver gang. DOM-noden og callback-funktionerne bliver hængende i hukommelsen, fordi der er referencer fra event-systemet.

Det jeg burde have gjort:

function setupButton() {
  const el = document.getElementById("btn");
  const handler = () => {
    console.log("clicked");
  };

  el.addEventListener("click", handler);

  return () => {
    el.removeEventListener("click", handler);
  };
}

// et sted i din "komponent-livscyklus"
const cleanup = setupButton();
// ... når komponenten fjernes
cleanup();

Tommelfingerregel: Hvis du addEventListener inde i noget, der bliver kørt flere gange i løbet af en session, så sørg for at du har en plan for removeEventListener også. MDN har en god, kort reference på removeEventListener, hvis du vil nørde detaljer.

2. Intervals og timeouts der aldrig ryddes op

Et andet klassisk problem er setInterval. Du sætter et interval, men glemmer at kalde clearInterval, når ting ikke længere skal køre.

function startTimer() {
  setInterval(() => {
    // gør et eller andet
  }, 1000);
}

Hvis den funktion kaldes hver gang en side eller komponent vises, har du pludselig 10, 20, 50 intervaller der kører parallelt. Hver interval-callback holder måske referencer til DOM, state, data.

En mindre dum version:

function startTimer() {
  const id = setInterval(() => {
    // gør et eller andet
  }, 1000);

  return () => clearInterval(id);
}

const stop = startTimer();
// når komponenten / viewet fjernes
stop();

Samme idé med setTimeout, bare med clearTimeout. Det er ikke altid de giver store leaks, men i nogle tilfælde holder de ting i live længere end nødvendigt.

3. Caches der vokser uden grænse

Jeg har en svaghed for at lave små caches i globale variabler “for performance”. Det er også fint. Men hvis du aldrig sletter noget fra dem, får du et leak-lignende mønster.

const userCache = {};

async function getUser(id) {
  if (userCache[id]) return userCache[id];
  const res = await fetch(`/api/users/${id}`);
  const data = await res.json();
  userCache[id] = data;
  return data;
}

I en lille app er det nok ligegyldigt. Men forestil dig en admin, hvor du bladrer gennem tusindvis af brugere. Cachen vokser og vokser. Browseren har stadig en reference til userCache, så GC kan ikke smide noget ud.

En enkel forbedring er at sætte en grænse eller bruge en LRU-lignende strategi (sidst brugt først ud):

const userCache = new Map();
const MAX_USERS = 1000;

async function getUser(id) {
  if (userCache.has(id)) return userCache.get(id);

  const res = await fetch(`/api/users/${id}`);
  const data = await res.json();

  userCache.set(id, data);

  if (userCache.size > MAX_USERS) {
    // slet den ældste nøgle
    const firstKey = userCache.keys().next().value;
    userCache.delete(firstKey);
  }

  return data;
}

Det er ikke magisk perfekt, men det stopper i det mindste uendelig vækst.

4. Detached DOM-noder

Her blev jeg selv overrasket første gang. Jeg troede naivt, at “hvis den ikke står i DOM, så er den væk”. Det er ikke altid rigtigt.

Et simpelt problem ser sådan ud:

let lastModalEl = null;

function showModal() {
  const modal = document.createElement("div");
  modal.innerHTML = "Hej";
  document.body.appendChild(modal);
  lastModalEl = modal;
}

function hideModal() {
  if (!lastModalEl) return;
  document.body.removeChild(lastModalEl);
  // vi glemmer at sætte lastModalEl = null
}

Her fjerner vi godt nok noden fra DOM. Men vi har stadig en JavaScript-reference i lastModalEl. Garbage collector ser: “Der er stadig en variabel, der peger på den her node”, så den beholder den i hukommelsen.

En lidt mere ansvarlig version:

function hideModal() {
  if (!lastModalEl) return;
  document.body.removeChild(lastModalEl);
  lastModalEl = null; // slip referencen
}

I frameworks som React/Vue/Svelte hjælper de ofte med meget af det her, hvis du holder dig til deres komponent-livscyklus. Men du kan stadig skabe detached noder, hvis du manuelt roder i DOM ved siden af.

5. Globale references og singletons der lever for evigt

Det sidste mønster er mere abstrakt, men jeg ser det tit i små projekter: et eller andet “globalt” objekt, hvor man smider alt, hvad der er besværligt at håndtere ordentligt.

const appState = {
  currentUser: null,
  modals: [],
  debugData: [],
  // og lidt mere efterhånden
};

Appen holder så fast i appState fra start til slut. Hver gang du åbner en ny view, kunne du finde på at smide ting i debugData eller modals eller andre properties. Ingen sletter noget igen, for “det kan jo være vi skal bruge det”.

Her er tricket: Brug andre scopes end globalen, når noget er midlertidigt. For eksempel per komponent, per rute eller per feature-funktion. Og hvis du har brug for global state, så lad den indeholde pegepinde til ting, der kan skiftes ud, ikke en evigt voksende liste uden begrænsning.

Verificér fixet: hvad forventer du at se i snapshots?

Jeg har flere gange troet jeg havde fixet et leak, bare fordi appen “føles” bedre. Det viste sig så at være placebo. Det er her målingen er din ven.

Mønsteret, jeg bruger nu, ligner det her:

  1. Genindlæs siden
  2. Udfør en bestemt cyklus, fx: gå fra A → B → A, 10 gange
  3. Tag snapshot 1
  4. Gentag samme cyklus endnu 10 gange
  5. Tag snapshot 2 og brug Comparison

Hvis dit fix virker, skal du se noget i den her stil:

  • Antal instanser af dine vigtigste komponent-typer er nogenlunde stabilt
  • Ingen vigtige DOM-node-typer vokser lineært med antal cyklusser
  • Ingen “mystiske” klasser med stort deltabyte-forbrug vokser konstant

For de mere visuelle, kan man også kigge på fanen Performance og optage en længere session. Der kan du tilføje memory-grafen i toppen og se, om kurven:

  • Vokser som en trappe uden at falde, hver gang du laver noget
  • Eller “saver” lidt op og ned, når GC kører, men generelt holder sig stabil

I mine egne små sideprojekter prøver jeg at kombinere det med andre målinger. Fx under debugging af produktion, hvor man i forvejen er i DevTools for at kigge på netværk, performance og errors, som jeg også er inde på i artiklen om logs og hukommelse.

Forebyggelse: små vaner i komponent-livscyklus

Det bedste jeg har gjort for mig selv, er at få nogle simple vaner ind, hver gang jeg skriver kode, der “hæfter” noget på verden udenfor funktionen.

Tænk altid i setup + cleanup

Hver gang du skriver noget i stil med:

window.addEventListener("resize", onResize);

så spørg dig selv: Hvornår skal det stoppe igen?

Hvis svaret er “aldrig”, er det måske fint. Men ofte er svaret: “Når den her komponent ikke længere er synlig” eller “når brugeren forlader den her side”. Så prøv at pakke det i en funktion, der giver dig en cleanup tilbage:

function listenToResize(handler) {
  window.addEventListener("resize", handler);
  return () => window.removeEventListener("resize", handler);
}

Det mønster kan du genbruge andres steder i appen, så du ikke skal huske detaljerne hver gang.

Hold styr på dine timers

Jeg er begyndt at gøre det til en regel, at setInterval og setTimeout aldrig står “løst” i en komponent. De skal altid kobles til noget cleanup.

function createPoller(fn, interval) {
  const id = setInterval(fn, interval);
  return () => clearInterval(id);
}

Det ligner lidt patterns fra frameworks, men det kan også bruges i helt plain JavaScript. Du kan næsten tænke i et lille onMount / onUnmount-par, også selvom du ikke bruger et framework.

Lav “lofter” for caches og debugging-data

Hver gang jeg laver en slags buffer eller cache nu, stiller jeg to spørgsmål:

  • Hvad er det maksimale, det her bør fylde?
  • Hvornår kan jeg med ro i sindet slette gamle ting?

Det kan være så simpelt som:

const logs = [];
const MAX_LOGS = 200;

function addLog(entry) {
  logs.push(entry);
  if (logs.length > MAX_LOGS) {
    logs.shift(); // smid det ældste væk
  }
}

Det er ikke fint arkitekt-sprog, men det gør en kæmpe forskel i små projekter.

Pas på “smutveje” uden om dit framework

Hvis du bruger React, Vue eller lignende, har du egentlig mange leaks lukket inde, hvis du holder dig til komponent-mønstret. De har egne cleanup-mekanismer gennem hooks/livscyklus.

Problemerne opstår tit de steder, hvor man lige “smutter uden om” og manuelt:

  • Tilføjer event listeners direkte på window eller document
  • Skaber DOM-noder med document.createElement uden for React
  • Gemmer referencer til DOM eller store objekter i moduler, der lever hele appens levetid

Det kan være nødvendigt nogle gange, men så er det ekstra vigtigt at tænke i samme before/after-logik. Hvis komponenter i forvejen har et place til cleanup (fx useEffect med return-funktion i React), så brug det konsekvent.

En lille øvelse du kan lave i din egen browser

Hvis du vil have hænderne ned i det her uden at vente på, at din næste rigtige app lækker, kan du lave en lille lege-side:

  1. Lav en side med en knap “Tilføj 1000 bokse”
  2. Hver gang du klikker, opretter du 1000 DOM-noder og tilføjer dem til en container
  3. Lav også en knap “Ryd”, der fjerner alle børn fra containeren

Først laver du en version, hvor du også gemmer hver node i et globalt array og aldrig rydder det. Mål med Memory fanen. Så laver du en version uden den globale reference.

Det er meget jordnært, men det giver den der “aha”-følelse, når du kan se memory-kurven opføre sig helt forskelligt for to næsten ens stykker kode.

Hvis du senere vil kombinere det med netværk, logging og andre performance-ting, hænger det ret fint sammen med flere af de andre emner vi skriver om på Coding Class, fx håndtering af tokens i webapps og debugging i produktion.

Til sidst: leaks er bugs, bare langsommere

Jeg plejer at minde mig selv om, at en memory leak ikke er noget eksotisk. Det er bare en bug, der viser sig langsomt og indirekte. Det gør den sværere at opdage, men logikken er den samme: noget bliver hængende, som ikke burde være der.

Jeg synes personligt det er ret tilfredsstillende, når man kan bevise over for sig selv, at en ændring faktisk gjorde noget: RAM-forbruget er fladet ud, snapshots ser fornuftige ud, og appen føles stabil, også efter længere tids brug.

Spørgsmålet er måske mest: Hvornår opdager du den første memory leak i dit eget projekt og får den lukket på en måde, du kan forklare andre?

Start i Chrome Task Manager for at se om en fane bruger usædvanligt meget RAM, og brug derefter Chrome DevTools Memory-panel til at tage heap snapshots og en allocations-tidslinje. Gentag de brugerhandlinger, der får appen til at blive tung, tag snapshots før og efter, og sammenlign dem for at se hvilke objekter der ikke bliver frigivet. Brug også Performance-panelet til at se langvarige opgaver, og kør i inkognito uden extensions for at udelukke tredjepart.
Tjek for uafmeldte event listeners, glemt setInterval/setTimeout, closures der holder store objekter, og JS-referencer til fjernede DOM-noder. Se også efter ubounded caches eller maps, og problemer i tredjepartsbiblioteker der holder referencer længere end nødvendigt.
Fjern eller afmeld event listeners og clearInterval/clearTimeout, nulstil eller fjern referencer til store objekter, og sørg for cleanup i dine komponenters afmonteringshooks (fx useEffect return eller componentWillUnmount). Overvej WeakMap/WeakRef til caches eller sæt en begrænsning på cache-størrelse, og genkør profiler for at bekræfte at heappen falder tilbage.
Bekymr dig når brugen stiger kontinuerligt over tid under normale interaktioner eller når en simpel side konstant bruger flere hundrede MB uden tydelig grund. Et midlertidigt spike er normalt, men vedvarende vækst der ikke falder ved navigation eller reload kræver debugging. Brug automatiseret overvågning i produktion for at fange langsomt voksende læk før brugerne gør det.

Mikkel Schrøder er den dér stille type, der i årevis har siddet om aftenen med en kop kaffe og et åbent kodeprojekt, mens resten af huset er ved at falde til ro. Hans interesse for kodning startede, da han som teenager forsøgte at lave en simpel hjemmeside til sit favorit-fodboldhold og opdagede, at man kunne ændre alt ved at rode med HTML og CSS. Siden har han lært tingene ved at prøve sig frem, læse forumtråde og pille ved små projekter, indtil de gjorde det, han ville.

På Coding Class deler han ikke perfekte løsninger fra et glansbillede-univers, men de ting han faktisk selv har bokset med: mærkelige JavaScript-fejl, CSS der ikke opfører sig som forventet, og små Python-scripts, der starter i kaos og ender med at spare tid i hverdagen. Han kan godt lide at vise både den første, halvdårlige løsning og den forbedrede udgave, så du kan se forskellen og forstå tankegangen bag.

Mikkel brænder for at gøre programmering mindre skræmmende for dem, der ikke ser sig selv som "tech-typer". Derfor skriver han på helt almindeligt dansk, med små, konkrete kodeeksempler og fokus på, hvordan du selv kan komme fra teori til noget, der faktisk virker. På Coding Class forsøger han at bygge bro mellem manual-sproget og virkeligheden ved at vise, hvordan det føles at sidde med fejlen klokken 22.30 – og hvad der skulle til, før den forsvandt.

Send kommentar

You May Have Missed