diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js
index 0e28cb6e..91390fd9 100644
--- a/src/domain/session/room/timeline/TimelineViewModel.js
+++ b/src/domain/session/room/timeline/TimelineViewModel.js
@@ -46,36 +46,51 @@ export class TimelineViewModel extends ViewModel {
         this._endTile = null;
         this._topLoadingPromise = null;
         this._bottomLoadingPromise = null;
+        this._requestedStartTile = null;
+        this._requestedEndTile = null;
+        this._requestScheduled = false;
     }
 
-    setVisibleTileRange(startTile, endTile, isViewportFilled) {
-        // we should async batch things here?
+    /** if this.tiles is empty, call this with undefined for both startTile and endTile */
+    setVisibleTileRange(startTile, endTile) {
+        this._requestedStartTile = startTile;
+        this._requestedEndTile = endTile;
+        if (!this._requestScheduled) {
+            Promise.resolve().then(() => {
+                this._setVisibleTileRange(this._requestedStartTile, this._requestedEndTile);
+                this._requestScheduled = false;
+            });
+            this._requestScheduled = true;
+        }
+    }
 
-        // this will prevent a (small) inserted tile from being marked visible, won't it?
-        if (this._startTile === startTile && this._endTile === endTile) {
-            return;
+    /** if this.tiles is empty, call this with undefined for both startTile and endTile */
+    _setVisibleTileRange(startTile, endTile) {
+        let loadTop;
+        if (startTile && endTile) {
+            // old tiles could have been removed from tilescollection once we support unloading
+            this._startTile = startTile;
+            this._endTile = endTile;
+            const startIndex = this._tiles.getTileIndex(this._startTile);
+            const endIndex = this._tiles.getTileIndex(this._endTile);
+            for (const tile of this._tiles.sliceIterator(startIndex, endIndex)) {
+                tile.notifyVisible();
+            }
+            loadTop = startIndex < 5;
+            console.log("got tiles", startIndex, endIndex, loadTop);
+        } else {
+            loadTop = true;
+            console.log("no tiles, load more at top");
         }
 
-        // old tiles could have been removed from tilescollection once we support unloading
-        const oldStartIndex = this._startTile ? this._tiles.getTileIndex(this._startTile) : Number.MAX_SAFE_INTEGER;
-        const oldEndIndex = this._endTile ? this._tiles.getTileIndex(this._endTile) : Number.MIN_SAFE_INTEGER;
-        const newStartIndex = this._tiles.getTileIndex(startTile);
-        const newEndIndex = this._tiles.getTileIndex(endTile);
-
-        const minIndex = Math.min(oldStartIndex, newStartIndex);
-        const maxIndex = Math.max(oldEndIndex, newEndIndex);
-
-        let index = minIndex;
-        for (const tile of this._tiles.sliceIterator(minIndex, maxIndex)) {
-            const isVisible = index >= newStartIndex && index <= newEndIndex;
-            tile.setVisible(isVisible);
-            index += 1;
-        }
-
-        if (!isViewportFilled || (newStartIndex < 5 && !this._topLoadingPromise)) {
+        if (loadTop && !this._topLoadingPromise) {
             this._topLoadingPromise = this._timeline.loadAtTop(10).then(() => {
                 this._topLoadingPromise = null;
+                // check if more items need to be loaded by recursing
+                this.setVisibleTileRange(this._startTile, this._endTile);
             });
+        } else if (loadTop) {
+            console.log("loadTop is true but already loading");
         }
     }
 
diff --git a/src/domain/session/room/timeline/tiles/GapTile.js b/src/domain/session/room/timeline/tiles/GapTile.js
index c85b6a09..0863b3b6 100644
--- a/src/domain/session/room/timeline/tiles/GapTile.js
+++ b/src/domain/session/room/timeline/tiles/GapTile.js
@@ -44,10 +44,8 @@ export class GapTile extends SimpleTile {
         }
     }
 
-    setVisible(isVisible) {
-        if (isVisible) {
-            this.fill();
-        }
+    notifyVisible() {
+        this.fill();
     }
 
     updateEntry(entry, params) {
diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js
index 96679c97..4c1c1de0 100644
--- a/src/domain/session/room/timeline/tiles/SimpleTile.js
+++ b/src/domain/session/room/timeline/tiles/SimpleTile.js
@@ -123,9 +123,7 @@ export class SimpleTile extends ViewModel {
     
     }
 
-    setVisible(isVisible) {
-
-    }
+    notifyVisible() {}
 
     dispose() {
         this.setUpdateEmit(null);
diff --git a/src/platform/web/ui/session/room/TimelineView.ts b/src/platform/web/ui/session/room/TimelineView.ts
index 71af5f9f..8d95932f 100644
--- a/src/platform/web/ui/session/room/TimelineView.ts
+++ b/src/platform/web/ui/session/room/TimelineView.ts
@@ -132,11 +132,12 @@ export class TimelineView extends TemplateView<TimelineViewModel> {
     }
 
     private updateVisibleRange(startIndex: number, endIndex: number, isViewportFilled: boolean) {
+        // can be undefined, meaning the tiles collection is still empty
         const firstVisibleChild = this.tilesView!.getChildInstanceByIndex(startIndex);
         const lastVisibleChild = this.tilesView!.getChildInstanceByIndex(endIndex);
-        if (firstVisibleChild && lastVisibleChild) {
-            this.value.setVisibleTileRange(firstVisibleChild.value, lastVisibleChild.value, isViewportFilled);
-        }
+        //if (firstVisibleChild && lastVisibleChild) {
+        this.value.setVisibleTileRange(firstVisibleChild?.value, lastVisibleChild?.value, isViewportFilled);
+        //}
     }
 }