flüssige Animationen ohne Jank
requestAnimationFrame ist die einzige korrekte Methode, um Animationen im Browser zu implementieren. Wer setTimeout oder setInterval nutzt, kämpft gegen den Rendering-Zyklus statt mit ihm. Dieser Artikel zeigt, wie rAF funktioniert, wie man einen robusten Render-Loop baut und welche Fallen den Frame-Rhythmus zerstören.
Inhaltsverzeichnis
- 1. Warum requestAnimationFrame statt setTimeout?
- 2. Die Browser-Rendering-Pipeline verstehen
- 3. Den ersten Render-Loop schreiben
- 4. Der DOMHighResTimeStamp-Parameter
- 5. cancelAnimationFrame und sauberes Aufräumen
- 6. Frame-Budget und Layout-Thrashing vermeiden
- 7. Frame-Throttling für niedrig-frequente Updates
- 8. rAF vs. CSS-Animationen vs. Web Animations API
- 9. Praxisbeispiel: Scroll-Progress-Indikator
- 10. Zusammenfassung
- 11. FAQ
1. Warum requestAnimationFrame statt setTimeout?
Die intuitive Antwort auf die Frage „Wie animiere ich etwas in JavaScript?" ist für viele Entwickler: setInterval(update, 16) – denn 1000 ms / 60 fps ≈ 16 ms. Diese Rechnung klingt plausibel, ist aber fundamental falsch. requestAnimationFrame synchronisiert die Callback-Ausführung mit dem Refresh-Zyklus des Displays, während setInterval völlig blind gegenüber dem Browser-Renderer operiert. Das Ergebnis bei setInterval: Der Browser rendert einen Frame, während JavaScript gerade eine DOM-Mutation berechnet – das führt zu unvollständigen Frames, die als Jank (Ruckeln) sichtbar werden.
Ein weiteres kritisches Problem: setInterval läuft weiter, wenn der Tab im Hintergrund ist. Das verschwendet CPU-Ressourcen und Akku bei Mobilgeräten. requestAnimationFrame pausiert automatisch, wenn der Tab nicht sichtbar ist, und nimmt den Rhythmus beim Wiederherstellen der Sichtbarkeit nahtlos auf. Browser können rAF außerdem auf die native Display-Frequenz anpassen – 60 Hz, 90 Hz oder 120 Hz – ohne dass der Entwickler irgendetwas anpassen muss. Diese drei Vorteile zusammen machen requestAnimationFrame zur einzig vertretbaren Wahl für JavaScript-Animationen.
2. Die Browser-Rendering-Pipeline verstehen
Um requestAnimationFrame richtig einzusetzen, muss man verstehen, wo es in der Browser-Rendering-Pipeline sitzt. Ein Frame beginnt mit Input-Events (Maus, Touch, Tastatur), danach werden rAF-Callbacks ausgeführt. Dann folgt Style-Recalculation, Layout, Paint und Composite. requestAnimationFrame-Callbacks laufen also bewusst vor dem Render-Schritt – das gibt dem Entwickler die Möglichkeit, DOM und CSSOM zu verändern, bevor der Browser den Frame berechnet. Eine Änderung in einem rAF-Callback sieht der Nutzer garantiert im nächsten Frame.
Die Faustregel für das 16-ms-Frame-Budget bei 60 Hz: rAF-Callback plus alle daraus resultierenden Style/Layout-Berechnungen müssen zusammen unter 10 ms bleiben, um dem Browser genug Zeit für Paint und Composite zu lassen. Überschreitet man das Budget, wird ein Frame übersprungen – das ist der „Dropped Frame", der als Ruckeln wahrnehmbar ist. requestAnimationFrame gibt keine Garantie, dass jeder Callback in jedem Frame ausgeführt wird: Bei zu langer Ausführungszeit priorisiert der Browser den Render-Schritt und verschiebt den nächsten rAF-Callback.
3. Den ersten Render-Loop schreiben
Das grundlegende Muster für einen requestAnimationFrame-Loop besteht aus einer Funktion, die sich am Ende selbst wieder per requestAnimationFrame registriert. Diese rekursive Struktur ist keine echte Rekursion – jeder Aufruf kehrt sofort zurück und der Browser ruft den Callback zum nächsten Frame-Zeitpunkt auf. Das Muster ist bewusst einfach gehalten, damit der Browser den Callback zu einem für ihn optimalen Zeitpunkt einplanen kann.
Wichtig ist, den Loop-State in einer äußeren Variablen zu halten, nicht innerhalb des Callbacks. Animationseigenschaften wie aktuelle Position, Geschwindigkeit und Richtung leben im Closure-Scope. Der Callback selbst berechnet den neuen Zustand basierend auf dem vergangenen Delta und schreibt das Ergebnis in den DOM. Das Lesen von DOM-Properties und das Schreiben in den DOM sind getrennte Operationen – niemals abwechselnd lesen und schreiben innerhalb desselben Callbacks, sonst provoziert man Layout-Thrashing.
// Basic requestAnimationFrame render loop with state management
let rafId = null;
const state = {
x: 0,
velocity: 2, // pixels per frame (60 fps baseline)
maxX: 800,
};
function update(timestamp) {
// Move element, reverse direction at boundaries
state.x += state.velocity;
if (state.x >= state.maxX || state.x <= 0) {
state.velocity *= -1;
}
// Write to DOM — only after all reads are done
element.style.transform = `translateX(${state.x}px)`;
// Schedule next frame — loop continues until cancelled
rafId = requestAnimationFrame(update);
}
// Start loop
rafId = requestAnimationFrame(update);
// Stop loop (e.g. on component unmount or user pause)
function stopLoop() {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
}
4. Der DOMHighResTimeStamp-Parameter
Jeder requestAnimationFrame-Callback erhält als erstes Argument einen DOMHighResTimeStamp – eine hochauflösende Zeitmarke in Millisekunden relativ zum Seitenaufruf. Diese Zeitmarke ist identisch für alle rAF-Callbacks, die im selben Frame-Flush ausgeführt werden. Das bedeutet: Mehrere unabhängige requestAnimationFrame-Loops, die im selben Frame aktiv sind, erhalten denselben Timestamp. Das ermöglicht präzise Synchronisierung zwischen verschiedenen Animationskomponenten ohne explizite Kommunikation.
Der entscheidende Vorteil gegenüber Date.now() liegt in der Genauigkeit und in der Entkopplung von der Systemuhr. Der Timestamp ist garantiert monoton steigend und wird nicht durch Systemuhr-Anpassungen beeinflusst. Für frame-rate-unabhängige Animationen berechnet man das Delta zum vorherigen Frame (delta = timestamp - lastTimestamp) und multipliziert die Bewegung mit diesem Delta. So läuft die Animation auf einem 120-Hz-Display doppelt so oft, aber in halber Schrittweite – das Ergebnis ist dieselbe wahrnehmbare Geschwindigkeit auf allen Display-Frequenzen.
5. cancelAnimationFrame und sauberes Aufräumen
requestAnimationFrame gibt einen numerischen Handle zurück, der für cancelAnimationFrame(handle) benötigt wird. Wer diesen Handle nicht speichert, kann den Loop nicht von außen stoppen. Das ist das häufigste Memory-Leak-Muster bei rAF-basierten Animationen: Eine React-Komponente oder ein Web-Component wird entfernt, der Loop läuft aber weiter, weil der Handle nicht in der Cleanup-Logik gespeichert wurde.
Das sichere Muster: Handle immer in einer Variable außerhalb des Callbacks speichern. In React-Komponenten im useEffect-Cleanup aufräumen. In Web-Components im disconnectedCallback. Bei Page-Visibility-Changes (document.addEventListener('visibilitychange', ...)) den Loop pausieren, wenn der Tab unsichtbar wird – requestAnimationFrame tut das bereits automatisch auf System-Ebene, aber explizites Pausieren verhindert auch unnötige State-Berechnungen im JavaScript selbst.
6. Frame-Budget und Layout-Thrashing vermeiden
Layout-Thrashing ist der häufigste Performance-Killer in requestAnimationFrame-Loops. Es entsteht, wenn JavaScript abwechselnd DOM-Eigenschaften liest und schreibt: Der Browser muss nach jeder Schreiboperation das Layout neu berechnen, bevor er eine Leseoperation zurückgeben kann. Eine Schleife, die für zehn Elemente jeweils el.offsetWidth liest und dann el.style.width setzt, erzwingt zehn Layout-Recalculations in einem einzigen Frame – statt einer einzigen am Ende.
Die Lösung folgt dem Batch-Muster: zuerst alle Reads, dann alle Writes. In komplexen Animations-Szenarien hilft FastDOM oder eine manuelle Read/Write-Queue. Für rein visuelle Transformationen ist transform und opacity die erste Wahl, weil beide Eigenschaften auf dem Compositor-Thread laufen und kein Layout auslösen. Änderungen an width, height, top oder left hingegen lösen immer Layout aus und sollten in Animations-Loops vermieden werden.
// Frame-rate-independent animation using DOMHighResTimeStamp delta
let lastTimestamp = null;
let rafId = null;
let posX = 0;
const SPEED_PX_PER_MS = 0.3; // 300 px/s — consistent across 60/120 Hz
function animate(timestamp) {
if (lastTimestamp === null) {
lastTimestamp = timestamp; // First frame: initialize
}
const delta = timestamp - lastTimestamp; // ms since last frame
lastTimestamp = timestamp;
// Cap delta to avoid huge jumps after tab was hidden
const safeDelta = Math.min(delta, 100);
posX = (posX + SPEED_PX_PER_MS * safeDelta) % 800;
// GOOD: use transform — no layout, runs on compositor thread
element.style.transform = `translateX(${posX.toFixed(2)}px)`;
rafId = requestAnimationFrame(animate);
}
// Pause when tab is hidden — saves CPU
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
cancelAnimationFrame(rafId);
rafId = null;
lastTimestamp = null; // Reset so delta is clean on resume
} else {
rafId = requestAnimationFrame(animate);
}
});
rafId = requestAnimationFrame(animate);
7. Frame-Throttling für niedrig-frequente Updates
Nicht jede Aufgabe braucht 60 Updates pro Sekunde. Ein Scroll-Fortschrittsbalken, eine Canvas-Visualisierung von Echtzeitdaten oder ein Audio-Visualizer können oft mit 30 fps oder weniger betrieben werden. Frame-Throttling mit requestAnimationFrame ist die saubere Lösung: Der Loop läuft weiterhin mit nativer Frame-Rate, aber der tatsächliche Update-Code wird nur ausgeführt, wenn das akkumulierte Delta den gewünschten Interval-Wert überschreitet.
Dieses Muster ist dem naive Ansatz mit setInterval überlegen, weil der rAF-Loop weiterhin mit dem Browser-Rendering-Zyklus synchronisiert ist. Wenn ein Update übersprungen wird, weil das Delta noch nicht ausreicht, kehrt der Callback sofort zurück – ohne DOM-Schreiboperationen, ohne Layout-Berechnung. Das ist deutlich günstiger als ein setInterval, das unabhängig vom Rendering-Zyklus feuert und dessen Updates eventuell mit einem laufenden Paint-Schritt kollidieren.
8. rAF vs. CSS-Animationen vs. Web Animations API
Die Wahl zwischen requestAnimationFrame, CSS-Animationen und der Web Animations API hängt vom Anwendungsfall ab. CSS-Animationen und CSS-Transitions sind für einfache, deklarativ beschreibbare Effekte die erste Wahl: Sie laufen auf dem Compositor-Thread, brauchen kein JavaScript und sind am performantesten. Sobald aber die Animation auf Echtzeit-Daten, Benutzereingaben oder komplexe Physik-Berechnungen reagieren muss, reicht CSS nicht mehr aus.
Die Web Animations API (element.animate()) bietet einen programmatischen Zugang zu CSS-Animations-Mechanismen und ist für viele mittlere Komplexitätsstufen ideal. Sie liefert Playback-Control (play, pause, reverse, cancel) und Promise-based Completion-Callbacks. requestAnimationFrame ist die tiefste Ebene: maximale Kontrolle, maximaler Aufwand. Für Canvas-Rendering, WebGL, komplexe Partikel-Systeme oder Physics-Engines gibt es keine Alternative zu rAF.
| Methode | Thread | Kontrolle | Ideal für |
|---|---|---|---|
| CSS Transition/Animation | Compositor | Niedrig (deklarativ) | Hover-Effekte, einfache Übergänge |
| Web Animations API | Compositor | Mittel (Playback-Control) | Sequenzen, Pause/Play, async |
| requestAnimationFrame | Main Thread | Maximal (imperativ) | Canvas, Physik, Echtzeit-Daten |
| setInterval/setTimeout | Main Thread | Niedrig (unsynced) | Nicht für Animationen geeignet |
9. Praxisbeispiel: Scroll-Progress-Indikator
Ein Scroll-Progress-Indikator ist das ideale Praxisbeispiel für requestAnimationFrame: Der Fortschrittsbalken soll flüssig reagieren, ohne jeden einzelnen Scroll-Event direkt zu verarbeiten. Das Muster: Scroll-Events setzen nur eine Flag oder speichern den aktuellen Scroll-Wert. Der rAF-Loop liest diese Werte einmal pro Frame und aktualisiert die Anzeige. So werden mehrere Scroll-Events, die zwischen zwei Frames auftreten, zu einem einzigen DOM-Update zusammengefasst.
Dieser Ansatz ist als „Passive Event Listener mit rAF-Scheduling" bekannt und ist die empfohlene Methode für alle Scroll-, Mouse- und Resize-Handler, die DOM-Änderungen auslösen. Der Scroll-Event-Listener wird als passive: true registriert, was dem Browser signalisiert, dass der Handler preventDefault() nicht aufruft. Das erlaubt dem Browser, das Scrollen auf dem Compositor-Thread fortzusetzen, ohne auf den JavaScript-Main-Thread warten zu müssen – und vermeidet Scroll-Jank vollständig.
// Scroll-progress indicator — passive listener + rAF scheduling
class ScrollProgress {
constructor(barElement) {
this.bar = barElement;
this.rafId = null;
this.scrollY = 0;
this.ticking = false;
// Passive: browser need not wait for JS before scrolling
window.addEventListener('scroll', this.onScroll.bind(this), { passive: true });
}
onScroll() {
this.scrollY = window.scrollY;
// Only schedule one rAF per frame — guard with ticking flag
if (!this.ticking) {
this.rafId = requestAnimationFrame(this.update.bind(this));
this.ticking = true;
}
}
update() {
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
const progress = docHeight > 0 ? this.scrollY / docHeight : 0;
// transform: scaleX — no layout, compositor-only
this.bar.style.transform = `scaleX(${progress})`;
this.ticking = false;
}
destroy() {
window.removeEventListener('scroll', this.onScroll);
if (this.rafId) cancelAnimationFrame(this.rafId);
}
}
const progressBar = document.getElementById('progress-bar');
progressBar.style.transformOrigin = 'left center';
const indicator = new ScrollProgress(progressBar);
10. Zusammenfassung
requestAnimationFrame ist das zentrale Werkzeug für alle JavaScript-Animationen, die über einfache CSS-Transitions hinausgehen. Es synchronisiert Callbacks mit dem Browser-Rendering-Zyklus, pausiert bei versteckten Tabs und passt sich automatisch an die native Display-Frequenz an. Der DOMHighResTimeStamp-Parameter ermöglicht frame-rate-unabhängige Animationen über Delta-Berechnungen. cancelAnimationFrame ist kein optionales Cleanup – es ist Pflicht, um Memory-Leaks in Komponenten-Architekturen zu vermeiden.
Die wichtigsten Leitlinien: Lesen vor Schreiben, um Layout-Thrashing zu vermeiden. transform und opacity bevorzugen, weil sie auf dem Compositor-Thread laufen. Scroll- und Input-Handler auf rAF-Scheduling delegieren, nicht direkt DOM-Operationen ausführen. Frame-Throttling einsetzen, wenn 60 fps für den Anwendungsfall überdimensioniert ist. Und immer: Den Loop-Handle speichern und sauber aufräumen.
Mironsoft
JavaScript Performance, Frontend-Architektur und Browser-APIs
Animationen, die wirklich flüssig laufen?
Wir analysieren bestehende Animation-Implementierungen, erkennen Layout-Thrashing und setInterval-Missbrauch, und ersetzen sie durch saubere requestAnimationFrame-Loops mit korrektem Frame-Budget-Management.
Performance-Audit
Layout-Thrashing und Dropped Frames in DevTools identifizieren
rAF-Refactoring
setTimeout-Animationen durch korrekte rAF-Loops ersetzen
Canvas & WebGL
Hochperformante Render-Loops für datenintensive Visualisierungen
requestAnimationFrame — Das Wichtigste auf einen Blick
Render-Synchronisierung
rAF läuft vor dem Render-Schritt des Browsers – Callbacks sehen den nächsten Frame. Pausiert automatisch bei unsichtbaren Tabs und passt sich an Display-Frequenz an.
Frame-Rate-Unabhängigkeit
DOMHighResTimeStamp-Delta verwenden: Bewegung in px/ms berechnen, nicht px/frame. So läuft die Animation auf 60 Hz und 120 Hz identisch schnell.
Layout-Thrashing verhindern
Erst alle DOM-Reads, dann alle DOM-Writes. transform und opacity bevorzugen – beide laufen auf dem Compositor-Thread ohne Layout-Trigger.
Sauberes Aufräumen
Handle speichern, cancelAnimationFrame im Cleanup aufrufen. Ohne Cancel: Memory-Leaks und CPU-Verschwendung in Komponenten-Architekturen.
11. FAQ: requestAnimationFrame
1Was macht requestAnimationFrame anders als setTimeout?
2Wie stoppe ich einen rAF-Loop?
rafId = requestAnimationFrame(fn), dann cancelAnimationFrame(rafId). Ohne Handle kein sauberer Stop möglich.3Was ist Layout-Thrashing?
4Wie macht man Animationen frame-rate-unabhängig?
delta = timestamp - lastTimestamp. Bewegung in px/ms statt px/frame definieren – gleiche Geschwindigkeit auf 60 Hz und 120 Hz.